mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
Any EIO was read as the window closing, so a failing SD card ended the interface with 129 and no error.log. The error now counts as a closed window only once the terminal itself no longer answers tcgetattr.
13166 lines
557 KiB
Python
13166 lines
557 KiB
Python
#!/usr/bin/env python3
|
|
"""Universal BIOS installer for retrogaming platforms.
|
|
|
|
Self-contained script using only Python stdlib. Downloads missing BIOS files
|
|
from the retrobios repository and places them in the correct location for
|
|
the detected emulator platform.
|
|
|
|
Usage:
|
|
python install.py
|
|
python install.py --platform retroarch --dest ~/custom/bios
|
|
python install.py --check
|
|
python install.py --list-platforms
|
|
python install.py --platform retroarch --system sony-playstation --region us
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import codecs
|
|
import concurrent.futures
|
|
import contextlib
|
|
import datetime
|
|
import errno
|
|
import functools
|
|
import hashlib
|
|
import http.client
|
|
import io
|
|
import json
|
|
import os
|
|
import platform
|
|
import queue
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import unicodedata
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import weakref
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
import zlib
|
|
from collections.abc import Callable, Collection, Iterator, Mapping
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO
|
|
|
|
# Manifests are read from the same ref the bootstrap verified this installer
|
|
# against, so a file list and the code reading it always come from one commit.
|
|
# RETROBIOS_REF pins an installation to a tag when reproducibility matters.
|
|
DEFAULT_RELEASE_REF = "main"
|
|
RELEASE_REF = os.environ.get("RETROBIOS_REF", DEFAULT_RELEASE_REF)
|
|
DEFAULT_BASE_URL = (
|
|
"https://raw.githubusercontent.com/Abdess/retrobios/"
|
|
+ urllib.parse.quote(RELEASE_REF, safe="")
|
|
)
|
|
|
|
|
|
_LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
|
|
|
|
def _checked_base_url(value: str) -> str:
|
|
"""Refuse a base URL that is neither HTTPS nor loopback.
|
|
|
|
This base serves the manifest and the files it declares, so whoever
|
|
controls it controls the expected hashes too and verification stops
|
|
proving anything. install.sh and install.ps1 make the same check on the
|
|
URL they fetch the installer from.
|
|
|
|
Plain HTTP to loopback stays allowed: nothing sits between the two ends
|
|
to intercept it, and it is how the installer is exercised end to end.
|
|
"""
|
|
parsed = urllib.parse.urlparse(value)
|
|
if parsed.scheme == "https":
|
|
return value
|
|
host = (parsed.hostname or "").lower()
|
|
if parsed.scheme == "http" and host in _LOOPBACK_HOSTS:
|
|
return value
|
|
print(
|
|
f"Error: RETROBIOS_BASE_URL must use HTTPS, got {value!r}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
BASE_URL = _checked_base_url(os.environ.get("RETROBIOS_BASE_URL", DEFAULT_BASE_URL))
|
|
MANIFEST_URL = f"{BASE_URL}/install/{{platform}}.json"
|
|
TARGETS_URL = f"{BASE_URL}/install/targets/{{platform}}.json"
|
|
RAW_FILE_URL = f"{BASE_URL}/{{path}}"
|
|
RELEASE_URL = (
|
|
"https://github.com/Abdess/retrobios/releases/download/large-files/{asset}"
|
|
)
|
|
MAX_RETRIES = 3
|
|
# Seconds a connection has to open. A dropped network cost three tries of a
|
|
# 30-second timeout, 93 s, before the error: a connection that does not open
|
|
# now fails in this time, and once open a read waits as long as before, so
|
|
# a slow line is not cut.
|
|
CONNECT_TIMEOUT = 4.0
|
|
MAX_MANIFEST_BYTES = 16 * 1024 * 1024
|
|
MAX_TARGETS_BYTES = 4 * 1024 * 1024
|
|
MAX_MANIFEST_FILES = 100_000
|
|
MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024
|
|
# A data directory's source archive: PCSX2's source tarball is the largest.
|
|
MAX_DATA_ARCHIVE_SIZE = 4 * 1024 * 1024 * 1024
|
|
MAX_TOTAL_DOWNLOAD_SIZE = 64 * 1024 * 1024 * 1024
|
|
# Why a manifest leaves a file out, as the installer words it. The manifest
|
|
# schema and the generator are held to this list by the tests.
|
|
OMISSION_REASONS = {
|
|
"hash_mismatch": "hash mismatch",
|
|
"not_found": "not found",
|
|
"external": "external",
|
|
"user_provided": "user provided",
|
|
"no_platform_slug": "no platform folder",
|
|
"below_platform_folder": "below the platform folder",
|
|
"no_save_directory": "no save directory",
|
|
"no_core_folder": "no core folder",
|
|
"no_save_system": "no system runs the core",
|
|
}
|
|
# What a file is when it is not firmware, as the manifest's category field
|
|
# says, and as the installer words it. Firmware carries no category.
|
|
GAME_DATA = "game_data"
|
|
CATEGORY_LABELS = {GAME_DATA: "game data"}
|
|
_SHA1_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
|
|
# The region vocabulary of scripts/region.py, which this file cannot import.
|
|
# A test holds the two copies equal.
|
|
WORLD_REGION = "world"
|
|
REGION_TREE: dict[str, frozenset] = {
|
|
"north-america": frozenset({"canada"}),
|
|
"latin-america": frozenset({"brazil", "mexico", "argentina"}),
|
|
"europe": frozenset({
|
|
"uk", "france", "germany", "italy", "spain", "netherlands", "portugal",
|
|
"greece", "poland", "russia", "sweden", "norway", "denmark", "finland",
|
|
}),
|
|
"asia": frozenset({
|
|
"japan", "south-korea", "china", "taiwan", "hong-kong", "singapore",
|
|
"india", "asia-ntsc", "asia-pal",
|
|
}),
|
|
"oceania": frozenset({"australia", "new-zealand"}),
|
|
}
|
|
REGION_ALIASES: dict[str, str] = {
|
|
"jp": "japan",
|
|
"ntsc-j": "japan",
|
|
"us": "north-america",
|
|
"usa": "north-america",
|
|
"na": "north-america",
|
|
"ntsc-u": "north-america",
|
|
"eu": "europe",
|
|
"pal": "europe",
|
|
"kr": "south-korea",
|
|
"korea": "south-korea",
|
|
"auto": WORLD_REGION,
|
|
"gb": "uk",
|
|
"ca": "canada",
|
|
"br": "brazil",
|
|
"mx": "mexico",
|
|
"ar": "argentina",
|
|
"fr": "france",
|
|
"de": "germany",
|
|
"it": "italy",
|
|
"es": "spain",
|
|
"nl": "netherlands",
|
|
"pt": "portugal",
|
|
"gr": "greece",
|
|
"pl": "poland",
|
|
"ru": "russia",
|
|
"se": "sweden",
|
|
"no": "norway",
|
|
"dk": "denmark",
|
|
"fi": "finland",
|
|
"cn": "china",
|
|
"tw": "taiwan",
|
|
"hk": "hong-kong",
|
|
"sg": "singapore",
|
|
"in": "india",
|
|
"au": "australia",
|
|
"nz": "new-zealand",
|
|
}
|
|
AREA_TERRITORIES: dict[str, frozenset] = {
|
|
"asia-ntsc": frozenset({"taiwan"}),
|
|
"asia-pal": frozenset({"china"}),
|
|
}
|
|
_REGION_PARENT = {
|
|
member: parent for parent, members in REGION_TREE.items() for member in members
|
|
}
|
|
REGIONS = frozenset({WORLD_REGION} | set(REGION_TREE) | set(_REGION_PARENT))
|
|
# Marks a region group named after one file that no system names.
|
|
FILE_GROUP = ":file:"
|
|
MAX_SELECTION_TAGS = 1000
|
|
SELECTION_FIELDS = ("systems", "regions", "region_groups", "region_system_groups")
|
|
# The files cores load for systems the platform does not list (game engines,
|
|
# computers): no system of the platform owns them, and choosing systems
|
|
# leaves them out unless this name is chosen too.
|
|
OTHER_SYSTEMS = "other"
|
|
|
|
# Platforms with a manifest in install/. Manifest URLs are case sensitive,
|
|
# so user input is normalized against this list before any fetch.
|
|
AVAILABLE_PLATFORMS = (
|
|
"retroarch", "batocera", "recalbox", "retrobat", "emudeck",
|
|
"lakka", "retrodeck", "rocknix", "romm", "bizhawk", "misterfpga",
|
|
"retropie", "amberelec", "emuelec", "knulli",
|
|
"minui", "nextui", "myminui",
|
|
)
|
|
|
|
# The names the frontends give themselves, as each manifest's display_name
|
|
# spells them (a test holds the two equal). Detection and the platform menu
|
|
# come before any manifest is fetched, and str.capitalize() wrote Retroarch,
|
|
# Retrodeck and Misterfpga.
|
|
PLATFORM_NAMES = {
|
|
"retroarch": "RetroArch", "batocera": "Batocera", "recalbox": "Recalbox",
|
|
"retrobat": "RetroBat", "emudeck": "EmuDeck", "lakka": "Lakka",
|
|
"retrodeck": "RetroDECK", "rocknix": "ROCKNIX", "romm": "RomM",
|
|
"bizhawk": "BizHawk", "misterfpga": "MiSTer FPGA", "retropie": "RetroPie",
|
|
"amberelec": "AmberELEC", "emuelec": "EmuELEC", "knulli": "KNULLI",
|
|
"minui": "MinUI", "nextui": "NextUI", "myminui": "MyMinUI",
|
|
}
|
|
|
|
# Frontends whose BIOS live on their SD card, installed from a computer the
|
|
# card is mounted on. Each release unpacks as .system/ at the card root and
|
|
# writes its release name as the first line of .system/version.txt (MinUI
|
|
# makefile:22-24 and 152-157, NextUI makefile:33-35 and 249, MyMinUI
|
|
# makefile:16-19 and 124). The registry publishes the same rules as each
|
|
# manifest's `detect` (card_marker), and a test holds the two equal.
|
|
CARD_MARKER = ".system/version.txt"
|
|
_CARD_RULES: tuple[tuple[str, str, str], ...] = (
|
|
("minui", CARD_MARKER, "MinUI-"),
|
|
("nextui", CARD_MARKER, "NextUI-"),
|
|
("myminui", CARD_MARKER, "MyMinUI-"),
|
|
)
|
|
CARD_PLATFORMS = frozenset(name for name, _file, _prefix in _CARD_RULES)
|
|
|
|
SITE_URL = "https://abdess.github.io/retrobios/"
|
|
INSTALLER_PAGE = f"{SITE_URL}wiki/installer/"
|
|
TROUBLESHOOTING_PAGE = f"{SITE_URL}wiki/troubleshooting/"
|
|
BOOTSTRAP_URL = "https://raw.githubusercontent.com/Abdess/retrobios/main/install"
|
|
|
|
# Fallback BIOS destination per platform when --platform is forced
|
|
# but auto-detection finds nothing on the machine.
|
|
DEFAULT_DESTS = {
|
|
"batocera": Path("/userdata/bios"),
|
|
"recalbox": Path("/recalbox/share/bios"),
|
|
"lakka": Path("/storage/system"),
|
|
"retrodeck": Path.home() / "retrodeck",
|
|
"emudeck": Path.home() / "Emulation" / "bios",
|
|
"rocknix": Path("/storage/roms/bios"),
|
|
# Their BIOS lists name files under /storage/roms beside bios/.
|
|
"amberelec": Path("/storage/roms"),
|
|
"emuelec": Path("/storage/roms"),
|
|
"knulli": Path("/userdata/bios"),
|
|
"misterfpga": Path("/media/fat/games"),
|
|
"retropie": Path.home() / "RetroPie" / "BIOS",
|
|
"romm": Path("/romm/library/bios"),
|
|
}
|
|
|
|
|
|
def _os_default_dests(os_type: str) -> dict[str, Path]:
|
|
"""Where a platform keeps its BIOS on this OS, when it is not detected.
|
|
|
|
The same folders the platform's detection looks for: a platform named on
|
|
the command line but not found must not land in ~/bios, which none of
|
|
them reads.
|
|
"""
|
|
home = Path.home()
|
|
if os_type == "windows":
|
|
return {
|
|
# RetroArch's system directory defaults to <exe folder>\system
|
|
# (platform_win32.c), and its installer to C:\RetroArch-Win64.
|
|
"retroarch": _host_path("C:/RetroArch-Win64/system"),
|
|
# RetroBat's installer defaults to C:\RetroBat (installer.iss).
|
|
"retrobat": _host_path("C:/RetroBat/bios"),
|
|
# EmuDeck's Windows edition: C:\Emulation when its settings name
|
|
# no folder (emudeck-we functions/all.ps1:12-14).
|
|
"emudeck": _host_path("C:/Emulation/bios"),
|
|
}
|
|
if os_type == "wsl":
|
|
# RetroArch may be the Linux one inside the distribution, while RetroBat
|
|
# only exists on the Windows side, reached through /mnt/c.
|
|
return {
|
|
"retroarch": home / ".config" / "retroarch" / "system",
|
|
"retrobat": _host_path("C:/RetroBat/bios"),
|
|
}
|
|
if os_type == "darwin":
|
|
return {"retroarch": _macos_retroarch_base() / "system"}
|
|
return {
|
|
"retroarch": home / ".config" / "retroarch" / "system",
|
|
}
|
|
|
|
# Set when the run is interrupted, so queued downloads end instead of draining
|
|
# the pool: every future is submitted before the first one completes.
|
|
_stop = threading.Event()
|
|
# Set once a run starts writing: Ctrl+C before that has nothing to carry on.
|
|
_writing = threading.Event()
|
|
|
|
class _Interrupted(Exception):
|
|
"""Raised inside a worker once the run has been stopped."""
|
|
|
|
|
|
# A file this size or larger keeps its partial download when a transfer
|
|
# stops, and the next run asks the server for the rest: the release assets
|
|
# run to hundreds of megabytes. Smaller files simply start over.
|
|
RESUME_FROM_SIZE = 16 * 1024 * 1024
|
|
# Read once, before any download thread runs: reading the umask sets it.
|
|
_UMASK = os.umask(0)
|
|
os.umask(_UMASK)
|
|
|
|
|
|
def _enable_ansi() -> bool:
|
|
"""Whether escape sequences reach the terminal as colour and redraws.
|
|
|
|
Windows 10 consoles interpret them once virtual terminal processing is
|
|
switched on. Older ones print them raw, so the answer is then no.
|
|
"""
|
|
if os.name != "nt":
|
|
return True
|
|
try:
|
|
import ctypes # noqa: PLC0415
|
|
|
|
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
handle = kernel32.GetStdHandle(-11)
|
|
mode = ctypes.c_uint32()
|
|
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
return False
|
|
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
|
except (AttributeError, OSError):
|
|
return False
|
|
|
|
|
|
class Terminal:
|
|
"""What the output stream can show: a live line, colour, or neither.
|
|
|
|
A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb are honoured.
|
|
As no-color.org reads it, an empty NO_COLOR is not set: `NO_COLOR= cmd`
|
|
turned colour off. FORCE_COLOR or CLICOLOR_FORCE paints a pipe, for a
|
|
log viewer that shows colour, and never makes the line live.
|
|
"""
|
|
|
|
def __init__(self, stream: TextIO | None = None, quiet: bool = False) -> None:
|
|
self.stream = stream or sys.stdout
|
|
self.quiet = quiet
|
|
isatty = getattr(self.stream, "isatty", None)
|
|
self.live = bool(isatty and isatty()) and os.environ.get("TERM") != "dumb"
|
|
forced = any(os.environ.get(name, "0") not in ("", "0")
|
|
for name in ("FORCE_COLOR", "CLICOLOR_FORCE"))
|
|
self.colour = (
|
|
(self.live or forced) and not os.environ.get("NO_COLOR") and _enable_ansi()
|
|
)
|
|
if not self.colour:
|
|
self.live = self.live and os.name != "nt"
|
|
|
|
def paint(self, text: str, code: str) -> str:
|
|
return f"\033[{code}m{text}\033[0m" if self.colour else text
|
|
|
|
def ok(self, text: str) -> str:
|
|
return self.paint(text, "32")
|
|
|
|
def bad(self, text: str) -> str:
|
|
return self.paint(text, "31")
|
|
|
|
def dim(self, text: str) -> str:
|
|
return self.paint(text, "2")
|
|
|
|
def bold(self, text: str) -> str:
|
|
return self.paint(text, "1")
|
|
|
|
|
|
def format_duration(seconds: float) -> str:
|
|
"""0:42, 3:05 or 1:02:09."""
|
|
seconds = max(0, int(seconds + 0.5))
|
|
hours, rest = divmod(seconds, 3600)
|
|
minutes, secs = divmod(rest, 60)
|
|
return f"{hours}:{minutes:02}:{secs:02}" if hours else f"{minutes}:{secs:02}"
|
|
|
|
|
|
class Progress:
|
|
"""Bytes and files across the parallel downloads, shown as they move.
|
|
|
|
On a terminal one line redraws in place: a bar, the bytes, the speed and
|
|
the time left. Elsewhere each finished file gets its line, as a log
|
|
wants it. Failures are always printed, quiet or not.
|
|
"""
|
|
|
|
BAR = 24
|
|
|
|
def __init__(self, total_files: int, total_bytes: int, term: Terminal) -> None:
|
|
self.total_files = total_files
|
|
self.expected_bytes = total_bytes
|
|
self.total_bytes = max(total_bytes, 1)
|
|
self.term = term
|
|
self.files = 0
|
|
self.bytes = 0
|
|
self.resumed = 0
|
|
# Bytes that came from this machine and not the network: a copy
|
|
# found here, the pack, a resumed partial copy.
|
|
self.local = 0
|
|
# Files written to their place, which a stopped run counts: the
|
|
# queue's length said "118 files installed" over 8 on disk.
|
|
self.installed = 0
|
|
self.start = time.monotonic()
|
|
self._drawn = 0.0
|
|
self._evented = self.start
|
|
self._lock = threading.Lock()
|
|
|
|
def add(self, count: int, resumed: bool = False, network: bool | None = None) -> None:
|
|
"""count bytes written, resumed ones left out of the speed. network
|
|
is whether they came over the network, by default unless resumed."""
|
|
with self._lock:
|
|
self.bytes += count
|
|
if resumed:
|
|
self.resumed += count
|
|
fetched = not resumed if network is None else network
|
|
if not fetched:
|
|
self.local += count
|
|
now = time.monotonic()
|
|
if self.term.live and not self.term.quiet and now - self._drawn >= 0.1:
|
|
self._drawn = now
|
|
self._draw()
|
|
if EVENTS.on and now - self._evented >= EVENT_PROGRESS_INTERVAL:
|
|
self._evented = now
|
|
EVENTS.emit("progress", files_done=self.files, files_total=self.total_files,
|
|
bytes_done=self.bytes, bytes_total=self.expected_bytes)
|
|
self.emit_transfer()
|
|
|
|
def emit_transfer(self) -> None:
|
|
"""The transfer event: of the bytes done, those this machine held
|
|
and those the network brought."""
|
|
EVENTS.emit("transfer", bytes_local=self.local,
|
|
bytes_network=self.bytes - self.local)
|
|
|
|
def forget_resumed(self, count: int) -> None:
|
|
with self._lock:
|
|
self.resumed -= count
|
|
self.local -= count
|
|
|
|
def transferred(self) -> int:
|
|
"""Bytes fetched by this run, a resumed partial copy excluded."""
|
|
return self.bytes - self.resumed
|
|
|
|
def done(self, dest: str, ok: bool, reason: str = "") -> None:
|
|
with self._lock:
|
|
self.files += 1
|
|
line = None
|
|
if not ok and not self.term.quiet:
|
|
why = f" ({reason})" if reason else ""
|
|
line = f" {self.term.bad('FAILED')} {dest}{why}"
|
|
elif not self.term.live and not self.term.quiet:
|
|
line = f" [{self.files}/{self.total_files}] {dest} {self.term.ok('ok')}"
|
|
if line is not None:
|
|
self._clear()
|
|
print(line, file=self.term.stream, flush=True)
|
|
if self.term.live and not self.term.quiet:
|
|
self._draw()
|
|
|
|
def finish(self) -> None:
|
|
with self._lock:
|
|
self._clear()
|
|
|
|
def elapsed(self) -> float:
|
|
return time.monotonic() - self.start
|
|
|
|
def _clear(self) -> None:
|
|
if self.term.live and not self.term.quiet:
|
|
print("\r\033[K", end="", file=self.term.stream, flush=True)
|
|
|
|
def _draw(self) -> None:
|
|
share = min(self.bytes / self.total_bytes, 1.0)
|
|
elapsed = self.elapsed()
|
|
# A resumed partial copy was on disk before the run started: counted
|
|
# in the share, never in the speed.
|
|
speed = self.transferred() / elapsed if elapsed > 0 else 0
|
|
left = (self.total_bytes - self.bytes) / speed if speed > 0 else 0
|
|
parts = [
|
|
f"{share * 100:5.1f}%",
|
|
f"{format_size(self.bytes)} / {format_size(self.total_bytes)}",
|
|
f"{format_size(int(speed))}/s",
|
|
f"{format_duration(left)} left",
|
|
f"({self.files}/{self.total_files} files)",
|
|
]
|
|
# One physical line, whatever the width: \r\033[K clears only the
|
|
# line the cursor is on, so a wrapped line left a ghost per redraw
|
|
# on an 80-column console. The bar gives way first, then the least
|
|
# telling figures.
|
|
width = shutil.get_terminal_size((80, 24)).columns - 1
|
|
while True:
|
|
tail = " ".join(parts)
|
|
room = width - len(tail) - 6
|
|
if room >= 10 or len(parts) == 1:
|
|
break
|
|
parts.pop()
|
|
if room >= 10:
|
|
bar_width = min(self.BAR, room)
|
|
filled = int(share * bar_width)
|
|
text = f" [{'#' * filled}{'-' * (bar_width - filled)}] {tail}"
|
|
else:
|
|
text = f" {tail}"
|
|
print(f"\r\033[K{text[:width]}", end="", file=self.term.stream, flush=True)
|
|
|
|
|
|
# --events: one JSON object per line on stdout, for a program driving the
|
|
# installer (the handheld port shows its screens from them). Each kind has
|
|
# exactly these keys besides "event", in this order, and nothing else.
|
|
EVENT_SCHEMA = 1
|
|
EVENT_KEYS: dict[str, tuple[str, ...]] = {
|
|
# mode: install, check, dry-run or list.
|
|
"start": ("schema", "installer", "mode"),
|
|
# platforms: [{"platform", "name", "bios_dir"}], empty when none is found.
|
|
"detect": ("os", "os_name", "platforms"),
|
|
# stage: manifest (reason: network, not_found, invalid, clock for a
|
|
# refused certificate under a clock set in the past, or no_ca for no
|
|
# trusted certificates on the machine) or detect (reason: conflict, two
|
|
# systems found on one machine) or from (a folder named with --from,
|
|
# reason: not_found, not_a_folder, unreadable, or system for the root
|
|
# of the system and /proc, /sys, /dev).
|
|
"error": ("stage", "reason", "platform", "message"),
|
|
# A folder named with --from, once read: the files it holds (counted
|
|
# up to MAX_SCANNED_FILES, truncated past them), and the folders below
|
|
# it on another drive, not entered.
|
|
"scanned": ("folder", "files", "truncated", "other_drives"),
|
|
# state: present (hash verified), wrong_hash or missing.
|
|
"file": ("platform", "dest", "systems", "size", "state"),
|
|
"system": ("platform", "system", "name", "files", "present", "missing",
|
|
"wrong_hash", "bytes_needed"),
|
|
# The files in place being read, at most every half second, and the last.
|
|
"checking": ("files_done", "files_total"),
|
|
"check": ("platform", "name", "bios_dir", "files", "present", "missing",
|
|
"wrong_hash", "bytes_needed", "data_missing", "not_installable"),
|
|
# Right after check: the same counts over the files the platform's own
|
|
# list declares (cores: null), and their dests in file-event order. A
|
|
# kind of its own, since schema 1 never adds a key to the file event.
|
|
"declared": ("platform", "files", "present", "missing", "wrong_hash",
|
|
"bytes_needed", "dests"),
|
|
# A file to install that this machine already holds: misplaced in the
|
|
# BIOS folder (folder is its bios_dir, reason says why in words) or in
|
|
# a folder named with --from (reason empty). member names the ZIP member
|
|
# when path is an archive, else "". Its copy is a downloaded event.
|
|
"found": ("platform", "dest", "folder", "path", "member", "reason"),
|
|
# What a folder named with --from holds that could not be read, of the
|
|
# size of a file no copy was found for, or of no known size (a folder,
|
|
# a ZIP that would not open). member as in found, reason in words.
|
|
"unreadable": ("platform", "folder", "path", "member", "reason"),
|
|
"download": ("platform", "files", "bytes"),
|
|
"progress": ("files_done", "files_total", "bytes_done", "bytes_total"),
|
|
# Right after each progress event, and once when the files of a
|
|
# download event are done: bytes_done split into the bytes this machine
|
|
# held (a copy found here, the pack, a resumed partial copy) and the
|
|
# bytes the network brought. A speed is bytes_network over time.
|
|
"transfer": ("bytes_local", "bytes_network"),
|
|
"downloaded": ("dest", "ok", "reason", "size", "files_done", "files_total",
|
|
"bytes_done", "bytes_total"),
|
|
# failed: [{"dest", "reason"}]. skipped: [{"reason", "label", "files",
|
|
# "required"}], the files the collection cannot supply. left_out:
|
|
# [{"category", "label", "files", "dests"}], what --no-game-data left
|
|
# out, empty without it. kept: [{"folder", "files", "bytes"}], the files
|
|
# in place the run moved aside before replacing them (under --dry-run,
|
|
# would move), one per backup folder, empty when none.
|
|
"summary": ("mode", "installed", "up_to_date", "pending", "failed", "skipped",
|
|
"left_out", "kept", "interrupted"),
|
|
"end": ("code",),
|
|
}
|
|
# Seconds between two progress events while bytes arrive.
|
|
EVENT_PROGRESS_INTERVAL = 0.5
|
|
|
|
|
|
class EventStream:
|
|
"""The --events output: closed (nothing written) until opened."""
|
|
|
|
def __init__(self) -> None:
|
|
self.stream: TextIO | None = None
|
|
self._lock = threading.Lock()
|
|
|
|
def open(self, stream: TextIO) -> None:
|
|
self.stream = stream
|
|
|
|
@property
|
|
def on(self) -> bool:
|
|
return self.stream is not None
|
|
|
|
def emit(self, kind: str, **fields: object) -> None:
|
|
if self.stream is None:
|
|
return
|
|
keys = EVENT_KEYS[kind]
|
|
if set(fields) != set(keys):
|
|
raise ValueError(f"event {kind}: keys {sorted(fields)} != {sorted(keys)}")
|
|
line = json.dumps({"event": kind, **{key: fields[key] for key in keys}})
|
|
with self._lock:
|
|
self.stream.write(line + "\n")
|
|
self.stream.flush()
|
|
|
|
|
|
EVENTS = EventStream()
|
|
|
|
|
|
def _event_mode(args: argparse.Namespace) -> str:
|
|
if args.check:
|
|
return "check"
|
|
if args.dry_run:
|
|
return "dry-run"
|
|
if (args.list_platforms or args.list_targets or args.list_systems
|
|
or args.list_cores or args.list_emulators):
|
|
return "list"
|
|
return "install"
|
|
|
|
|
|
def _event_exit_code(code: object) -> int:
|
|
"""The process status sys.exit(code) gives."""
|
|
if code is None:
|
|
return 0
|
|
return code if isinstance(code, int) else 1
|
|
|
|
|
|
def _os_name() -> str:
|
|
"""The OS's own name, from os-release, else the kernel's."""
|
|
release = _parse_os_release()
|
|
return release.get("PRETTY_NAME") or release.get("NAME") or platform.system()
|
|
|
|
|
|
def _emit_check(
|
|
plat: str, manifest: dict, bios_path: Path, files: list[dict], up_to_date: list[dict],
|
|
mismatched: list[dict], data_missing: int, not_installable: int,
|
|
chosen: list[str] | None = None,
|
|
) -> None:
|
|
"""One event per file, per system and per platform after a check, then
|
|
one for the files of the platform's own list.
|
|
|
|
With systems chosen, only those are described: a file of SkyEmu's
|
|
carries four systems, and --system nintendo-gba described gb, gbc and
|
|
ds by the few files the choice kept of them.
|
|
"""
|
|
present = {id(entry) for entry in up_to_date}
|
|
wrong = {id(entry) for entry in mismatched}
|
|
systems: dict[str, dict[str, int]] = {}
|
|
for entry in files:
|
|
state = ("present" if id(entry) in present
|
|
else "wrong_hash" if id(entry) in wrong else "missing")
|
|
size = entry.get("size", 0)
|
|
EVENTS.emit("file", platform=plat, dest=entry["dest"],
|
|
systems=entry_systems(entry), size=size, state=state)
|
|
for system in entry_systems(entry) or [OTHER_SYSTEMS]:
|
|
counts = systems.setdefault(system, {
|
|
"files": 0, "present": 0, "missing": 0, "wrong_hash": 0,
|
|
"bytes_needed": 0,
|
|
})
|
|
counts["files"] += 1
|
|
counts[state] += 1
|
|
if state != "present":
|
|
counts["bytes_needed"] += size
|
|
names = manifest.get("system_names") or {}
|
|
for system in sorted(systems):
|
|
if chosen and system not in chosen:
|
|
continue
|
|
EVENTS.emit("system", platform=plat, system=system,
|
|
name=", ".join(names.get(system) or [system]), **systems[system])
|
|
needed = [entry for entry in files if id(entry) not in present]
|
|
EVENTS.emit(
|
|
"check", platform=plat, name=display_name(plat, manifest), bios_dir=str(bios_path),
|
|
files=len(files), present=len(up_to_date), missing=len(needed) - len(mismatched),
|
|
wrong_hash=len(mismatched),
|
|
bytes_needed=sum(entry.get("size", 0) for entry in needed),
|
|
data_missing=data_missing, not_installable=not_installable,
|
|
)
|
|
# The copy the platform itself checks at each of its slots, which is
|
|
# what --declared-only checks: a core's copy served in its place is
|
|
# judged against the platform's.
|
|
own_states = []
|
|
for entry in files:
|
|
if entry.get("cores") is not None:
|
|
continue
|
|
copy = declared_copy(entry)
|
|
if copy is entry or copy.get("sha1") == entry.get("sha1"):
|
|
state = ("present" if id(entry) in present
|
|
else "wrong_hash" if id(entry) in wrong else "missing")
|
|
else:
|
|
state = _declared_state(copy, entry, bios_path)
|
|
own_states.append((entry["dest"], copy.get("size", 0), state))
|
|
EVENTS.emit(
|
|
"declared", platform=plat, files=len(own_states),
|
|
present=sum(1 for _d, _s, state in own_states if state == "present"),
|
|
missing=sum(1 for _d, _s, state in own_states if state == "missing"),
|
|
wrong_hash=sum(1 for _d, _s, state in own_states if state == "wrong_hash"),
|
|
bytes_needed=sum(size for _d, size, state in own_states if state != "present"),
|
|
dests=[dest for dest, _s, _state in own_states],
|
|
)
|
|
|
|
|
|
def _declared_state(copy: dict, entry: dict, bios_path: Path) -> str:
|
|
"""present, missing or wrong_hash: the file at entry's place against copy."""
|
|
try:
|
|
path = _entry_path(bios_path, entry)
|
|
except ValueError:
|
|
return "missing"
|
|
try:
|
|
if not path.is_file():
|
|
return "missing" if not path.exists() else "wrong_hash"
|
|
verdict = _classify_local(copy, path)
|
|
except OSError:
|
|
return "wrong_hash"
|
|
return "present" if verdict == "up_to_date" else "wrong_hash"
|
|
|
|
|
|
def leave_out_game_data(
|
|
files: list[dict], omitted: list[dict]
|
|
) -> tuple[list[dict], list[dict], list[dict]]:
|
|
"""The files and omissions without game data, and what was left out.
|
|
|
|
The same cut as a pack built with --no-game-data: the manifest marks
|
|
each file that is a game's data, not firmware.
|
|
"""
|
|
left_out = [e for e in files + omitted if e.get("category") == GAME_DATA]
|
|
return (
|
|
[e for e in files if e.get("category") != GAME_DATA],
|
|
[e for e in omitted if e.get("category") != GAME_DATA],
|
|
left_out,
|
|
)
|
|
|
|
|
|
def keep_declared(files: list[dict], omitted: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
"""The files and omissions the platform's own list declares (cores: null).
|
|
|
|
A slot a core contradicts gets the platform's copy, and no core copy
|
|
stays behind as an alternative: narrowing by cores afterwards would
|
|
otherwise put a core's file back where a dropped system's was. Which of
|
|
the platform's declarations answers is left to that narrowing, as
|
|
without --declared-only: the first whose systems remain.
|
|
"""
|
|
def own(entries: list[dict]) -> list[dict]:
|
|
result = []
|
|
for entry in entries:
|
|
if entry.get("cores") is not None:
|
|
continue
|
|
kept = {k: v for k, v in _settle_slot(entry, set()).items()
|
|
if k not in ("alternatives", "declared_copy")}
|
|
declared = entry.get("declared_choices") or []
|
|
if len(declared) > 1:
|
|
kept["declared_choices"] = declared
|
|
result.append(kept)
|
|
return result
|
|
|
|
return own(files), own(omitted)
|
|
|
|
|
|
def declared_copy(entry: dict) -> dict:
|
|
"""The copy the platform's own list checks at a platform file's slot.
|
|
|
|
A narrowing that serves a core's copy there records the platform's
|
|
answer beside it, and an entry not yet settled answers by its first
|
|
declaration. Otherwise the entry is the platform's copy itself.
|
|
"""
|
|
recorded = entry.get("declared_copy")
|
|
if recorded is not None:
|
|
return recorded
|
|
declared = entry.get("declared_choices")
|
|
if declared:
|
|
return declared[0]
|
|
return entry
|
|
|
|
|
|
def _left_out_lines(left_out: list[dict], every: bool) -> list[str]:
|
|
"""What --no-game-data left out, named per category, ten each unless every."""
|
|
lines = []
|
|
for group in _left_out_events(left_out):
|
|
dests = group["dests"]
|
|
shown = dests if every else dests[:10]
|
|
more = (f" ... and {len(dests) - len(shown)} more (--verbose lists them all)"
|
|
if len(shown) < len(dests) else "")
|
|
lines.append(f" Left out as {group['label']} (--no-game-data): "
|
|
f"{_plural(len(dests), 'file')}: {', '.join(shown)}{more}")
|
|
return lines
|
|
|
|
|
|
def _left_out_events(left_out: list[dict]) -> list[dict]:
|
|
"""What --no-game-data left out, per category, every file named."""
|
|
groups: dict[str, dict] = {}
|
|
for entry in left_out:
|
|
category = entry.get("category", "")
|
|
group = groups.setdefault(category, {
|
|
"category": category, "label": CATEGORY_LABELS.get(category, category),
|
|
"files": 0, "dests": [],
|
|
})
|
|
group["files"] += 1
|
|
group["dests"].append(entry["dest"])
|
|
return [groups[category] for category in sorted(groups)]
|
|
|
|
|
|
def _skipped(omitted: list[dict]) -> list[dict]:
|
|
"""The files the collection cannot supply, grouped by reason."""
|
|
groups: dict[str, dict] = {}
|
|
for entry in omitted:
|
|
reason = entry.get("reason", "unknown")
|
|
group = groups.setdefault(reason, {
|
|
"reason": reason, "label": OMISSION_REASONS.get(reason, reason),
|
|
"files": 0, "required": 0,
|
|
})
|
|
group["files"] += 1
|
|
group["required"] += bool(entry.get("required", True))
|
|
return [groups[reason] for reason in sorted(groups)]
|
|
|
|
|
|
# The three RetroArch packages published for Android, newest ABI first.
|
|
ANDROID_RETROARCH_PACKAGES = (
|
|
"com.retroarch.aarch64",
|
|
"com.retroarch",
|
|
"com.retroarch.ra32",
|
|
)
|
|
# Where Android keeps each app's private data.
|
|
ANDROID_APP_DATA = Path("/data/data")
|
|
|
|
|
|
def _is_android() -> bool:
|
|
"""Android reports itself as Linux, so the runtime is named instead.
|
|
|
|
Termux inherits the two variables the Android runtime exports, and
|
|
/system/build.prop covers a shell that was started without them.
|
|
"""
|
|
if os.environ.get("ANDROID_ROOT") and os.environ.get("ANDROID_DATA"):
|
|
return True
|
|
return Path("/system/build.prop").exists()
|
|
|
|
|
|
def detect_os() -> str:
|
|
"""Return normalized OS identifier.
|
|
|
|
RETROBIOS_OS names the platform outright (linux, wsl, windows, darwin,
|
|
android), for a run that has to behave like another host: the PowerShell
|
|
wrapper tests drive a Windows layout on a Linux runner.
|
|
"""
|
|
forced = os.environ.get("RETROBIOS_OS", "").strip().lower()
|
|
if forced in ("linux", "wsl", "windows", "darwin", "android"):
|
|
return forced
|
|
system = platform.system().lower()
|
|
if system == "linux":
|
|
if _is_android():
|
|
return "android"
|
|
proc_version = Path("/proc/version")
|
|
if proc_version.exists():
|
|
try:
|
|
content = proc_version.read_text(encoding="utf-8", errors="replace")
|
|
if "microsoft" in content.lower():
|
|
return "wsl"
|
|
except OSError:
|
|
pass
|
|
return "linux"
|
|
if system == "darwin":
|
|
return "darwin"
|
|
if system == "windows":
|
|
return "windows"
|
|
return system
|
|
|
|
|
|
def _parse_os_release() -> dict[str, str]:
|
|
"""Parse /etc/os-release KEY=value format."""
|
|
result: dict[str, str] = {}
|
|
path = _on_device("/etc/os-release")
|
|
if not path.exists():
|
|
return result
|
|
try:
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
line = line.strip()
|
|
if "=" not in line or line.startswith("#"):
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
value = value.strip('"').strip("'")
|
|
result[key] = value
|
|
except OSError:
|
|
pass
|
|
return result
|
|
|
|
|
|
# Set under WSL: a Windows path read from a config or a rule names a drive
|
|
# that Linux sees under /mnt (drvfs). C:\\RetroBat\\bios was used as a relative
|
|
# path and nothing Windows-side was ever found from WSL.
|
|
_WSL = {"active": False, "root": "/mnt"}
|
|
_WINDOWS_VARIABLES = ("USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)")
|
|
|
|
|
|
def _host_path(text: str) -> Path:
|
|
"""A path as this machine opens it: under WSL, C:\\X is /mnt/c/X."""
|
|
match = re.match(r"^([A-Za-z]):(?:[\\/]+(.*))?$", text) if _WSL["active"] else None
|
|
if match:
|
|
rest = [part for part in re.split(r"[\\/]", match.group(2) or "") if part]
|
|
return Path(_WSL["root"], match.group(1).lower(), *rest)
|
|
return Path(text)
|
|
|
|
|
|
def _enter_wsl() -> None:
|
|
"""Read Windows paths through drvfs, and the Windows variables detection
|
|
needs, which WSL does not pass to Linux (only those named in WSLENV)."""
|
|
_WSL["active"] = True
|
|
for variable in _WINDOWS_VARIABLES:
|
|
if os.environ.get(variable):
|
|
continue
|
|
try:
|
|
# No stdin: cmd.exe inherited the terminal and swallowed the
|
|
# answer typed at the first prompt.
|
|
result = subprocess.run(
|
|
["cmd.exe", "/c", f"echo %{variable}%"],
|
|
capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL,
|
|
cwd="/mnt/c" if Path("/mnt/c").is_dir() else None,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return
|
|
value = result.stdout.strip()
|
|
if value and not value.startswith("%"):
|
|
os.environ[variable] = str(_host_path(value))
|
|
|
|
|
|
def _expand_retroarch_path(value: str, app_dir: Path, home: Path | None = None) -> Path:
|
|
"""Expand the notations RetroArch writes into its config values.
|
|
|
|
fill_pathname_expand_special (libretro-common/file/file_path.c) maps a
|
|
leading '~' to the home directory and a leading ':' to the application
|
|
directory, dropping the two leading characters in both cases.
|
|
"""
|
|
if value[:1] == "~":
|
|
return (home or Path(str(Path.home()))) / value[2:]
|
|
if value[:1] == ":":
|
|
return app_dir / value[2:]
|
|
return _host_path(os.path.expandvars(os.path.expanduser(value)))
|
|
|
|
|
|
def _retroarch_cfg_dir(
|
|
cfg_path: Path, key: str, app_dir: Path, default: str, home: Path | None = None
|
|
) -> Path | None:
|
|
"""A directory setting of retroarch.cfg, as RetroArch expands it.
|
|
|
|
A value left empty or at `default` is the directory RetroArch falls back
|
|
on, `default` under app_dir. None when the config is unreadable or does
|
|
not set the key.
|
|
"""
|
|
try:
|
|
lines = cfg_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
return None
|
|
for line in lines:
|
|
line = line.strip()
|
|
name, sep, value = line.partition("=")
|
|
if sep and name.strip() == key:
|
|
value = value.strip().strip('"').strip("'")
|
|
if not value or value == "default":
|
|
return app_dir / default
|
|
return _expand_retroarch_path(value, app_dir, home)
|
|
return None
|
|
|
|
|
|
def _steam_libraries() -> list[Path]:
|
|
"""Every Steam library, the default one first.
|
|
|
|
Steam lists the others in steamapps/libraryfolders.vdf, each as a
|
|
"path" line. A RetroArch installed on D:/SteamLibrary was not found
|
|
when only Program Files was searched.
|
|
"""
|
|
roots: list[Path] = []
|
|
for var in ("ProgramFiles(x86)", "ProgramFiles"):
|
|
base = os.environ.get(var, "")
|
|
if base and (Path(base) / "Steam").is_dir():
|
|
roots.append(Path(base) / "Steam")
|
|
libraries = list(roots)
|
|
for root in roots:
|
|
vdf = root / "steamapps" / "libraryfolders.vdf"
|
|
try:
|
|
text = vdf.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
continue
|
|
for raw in re.findall(r'"path"\s+"([^"]+)"', text):
|
|
library = Path(raw.replace("\\\\", "\\"))
|
|
if library not in libraries:
|
|
libraries.append(library)
|
|
return libraries
|
|
|
|
|
|
def _windows_retroarch_dirs() -> list[Path]:
|
|
"""Folders holding retroarch.exe where its installers put it.
|
|
|
|
C:/RetroArch-Win64 by default, C:/RetroArch for 32-bit builds, the same
|
|
names under Program Files, every Steam library, the Emulators folder of
|
|
an installed ES-DE (es_find_rules.xml lists all of them), and a Scoop
|
|
install, user or global.
|
|
"""
|
|
candidates = [_host_path("C:/RetroArch-Win64"), _host_path("C:/RetroArch")]
|
|
for var in ("ProgramFiles", "ProgramFiles(x86)"):
|
|
root = os.environ.get(var, "")
|
|
if root:
|
|
candidates += [Path(root) / "RetroArch-Win64", Path(root) / "RetroArch"]
|
|
candidates += [
|
|
Path(root) / "ES-DE" / "Emulators" / name
|
|
for name in ("RetroArch-Win64", "RetroArch")
|
|
]
|
|
candidates += [
|
|
library / "steamapps" / "common" / "RetroArch" for library in _steam_libraries()
|
|
]
|
|
# Scoop's Extras bucket: <root>\apps\retroarch\current, the root being
|
|
# %SCOOP% or ~\scoop for a user install, %SCOOP_GLOBAL% or
|
|
# %ProgramData%\scoop for a global one. Its system folder is persisted
|
|
# through a junction, so the folder beside retroarch.exe is the real one.
|
|
scoop_roots = [
|
|
os.environ.get("SCOOP") or (
|
|
str(Path(os.environ["USERPROFILE"]) / "scoop") if os.environ.get("USERPROFILE") else ""
|
|
),
|
|
os.environ.get("SCOOP_GLOBAL") or (
|
|
str(Path(os.environ["ProgramData"]) / "scoop") if os.environ.get("ProgramData") else ""
|
|
),
|
|
]
|
|
candidates += [
|
|
Path(root) / "apps" / "retroarch" / "current" for root in scoop_roots if root
|
|
]
|
|
return [folder for folder in candidates if (folder / "retroarch.exe").is_file()]
|
|
|
|
|
|
def _macos_retroarch_base() -> Path:
|
|
"""Where RetroArch on macOS keeps its system folder by default:
|
|
~/Documents/RetroArch, or Application Support/RetroArch for a portable
|
|
install. The Steam build is always portable (platform_darwin.m, `#if
|
|
HAVE_STEAM portable = YES`), with no portable.txt to say so: its
|
|
bundle under Steam's library, and no other RetroArch.app, mark it."""
|
|
support = Path.home() / "Library" / "Application Support" / "RetroArch"
|
|
if (support / "portable.txt").is_file():
|
|
return support
|
|
steam = (
|
|
Path.home() / "Library" / "Application Support" / "Steam" / "steamapps"
|
|
/ "common" / "RetroArch" / "RetroArch.app"
|
|
)
|
|
if steam.is_dir() and not Path("/Applications/RetroArch.app").is_dir():
|
|
return support
|
|
return Path.home() / "Documents" / "RetroArch"
|
|
|
|
|
|
# System directory -> the retroarch.cfg it was read from and the directory
|
|
# ':' expands to there, for finding the cores of the same installation.
|
|
_RETROARCH_CONFIGS: dict[Path, tuple[Path, Path]] = {}
|
|
|
|
|
|
def _parse_retroarch_system_dir(
|
|
cfg_path: Path, app_dir: Path | None = None, home: Path | None = None
|
|
) -> Path | None:
|
|
"""Parse system_directory from retroarch.cfg.
|
|
|
|
app_dir is RetroArch's application directory, used to expand ':' values.
|
|
It defaults to the directory holding the config, which is where a portable
|
|
install keeps both. home is what '~' means to that RetroArch, the user's
|
|
home when not given.
|
|
"""
|
|
if not cfg_path.exists():
|
|
return None
|
|
app_dir = app_dir or cfg_path.parent
|
|
system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system", home)
|
|
if system_dir is not None:
|
|
_RETROARCH_CONFIGS.setdefault(system_dir, (cfg_path, app_dir))
|
|
return system_dir
|
|
|
|
|
|
def _shell_unquote(value: str) -> str:
|
|
"""Resolve a shell right-hand side into its effective string value.
|
|
|
|
Handles concatenated quoted/unquoted segments the way bash does
|
|
(e.g. "/run/media/deck/EmuSD"/Emulation), expands variables in
|
|
double-quoted and unquoted segments, and stops at unquoted
|
|
whitespace or a comment.
|
|
"""
|
|
parts: list[str] = []
|
|
i = 0
|
|
n = len(value)
|
|
while i < n:
|
|
c = value[i]
|
|
if c == '"':
|
|
end = value.find('"', i + 1)
|
|
if end == -1:
|
|
parts.append(os.path.expandvars(value[i + 1:]))
|
|
break
|
|
parts.append(os.path.expandvars(value[i + 1:end]))
|
|
i = end + 1
|
|
elif c == "'":
|
|
end = value.find("'", i + 1)
|
|
if end == -1:
|
|
parts.append(value[i + 1:])
|
|
break
|
|
parts.append(value[i + 1:end])
|
|
i = end + 1
|
|
elif c.isspace() or c == "#":
|
|
break
|
|
else:
|
|
end = i
|
|
while end < n and value[end] not in "\"'#" and not value[end].isspace():
|
|
end += 1
|
|
parts.append(os.path.expandvars(value[i:end]))
|
|
i = end
|
|
return os.path.expanduser("".join(parts))
|
|
|
|
|
|
def _parse_bash_var(path: Path, key: str) -> str | None:
|
|
"""Extract the effective value of key= from a bash/shell file."""
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
line = line.strip()
|
|
if line.startswith(f"{key}="):
|
|
_, _, value = line.partition("=")
|
|
return _shell_unquote(value)
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
# Where an embedded system names the board it was built for, in the words
|
|
# of our target lists: batocera-system.mk writes BATOCERA_SYSTEM_ARCH,
|
|
# recalbox-system.mk RECALBOX_SYSTEM_PLATFORM, ROCKNIX's scripts/image puts
|
|
# HW_DEVICE in os-release (RK3326, our rk3326). Lakka's scripts/image writes
|
|
# LIBREELEC_DEVICE when the image is built for a device (RPi4) and always
|
|
# LIBREELEC_PROJECT (Generic): the first one set is the board.
|
|
_BOARD_SOURCES: dict[str, tuple[str, str]] = {
|
|
"batocera": ("file", "/usr/share/batocera/batocera.arch"),
|
|
"recalbox": ("file", "/recalbox/recalbox.arch"),
|
|
"rocknix": ("os_release", "HW_DEVICE"),
|
|
"lakka": ("os_release", "LIBREELEC_DEVICE|LIBREELEC_PROJECT"),
|
|
}
|
|
|
|
|
|
# A platform that names its board without publishing a core list per board.
|
|
# Knulli moves /usr/share/batocera to /usr/share/knulli (knulli-linux
|
|
# 57afa662, board/scripts/post-build-script.sh:264-278) and writes its board
|
|
# to knulli.arch (package/system/knulli-system/knulli-system.mk:98-99).
|
|
_DERIVATIVE_BOARDS: dict[str, tuple[str, str]] = {
|
|
"knulli": ("Knulli", "/usr/share/knulli/knulli.arch"),
|
|
}
|
|
|
|
|
|
def derivative_board(platform: str) -> tuple[str, str] | None:
|
|
"""(derivative, board) when a derivative of the platform names its board."""
|
|
name, where = _DERIVATIVE_BOARDS.get(platform, ("", ""))
|
|
if not where:
|
|
return None
|
|
try:
|
|
board = Path(where).read_text(encoding="utf-8", errors="replace").strip().lower()
|
|
except OSError:
|
|
return None
|
|
return (name, board) if board else None
|
|
|
|
|
|
def detect_board(platform: str) -> str | None:
|
|
"""The board this system was built for, or None where it names none."""
|
|
kind, where = _BOARD_SOURCES.get(platform, ("", ""))
|
|
if kind == "file":
|
|
try:
|
|
value = Path(where).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return None
|
|
elif kind == "os_release":
|
|
release = _parse_os_release()
|
|
value = next((release[key] for key in where.split("|") if release.get(key)), "")
|
|
else:
|
|
return None
|
|
return value.strip().lower() or None
|
|
|
|
|
|
def _parse_json_path(path: Path, *keys: str) -> str | None:
|
|
"""Extract a nested string value from a JSON file."""
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
for key in keys:
|
|
data = data[key]
|
|
return data if isinstance(data, str) and data else None
|
|
except (OSError, json.JSONDecodeError, KeyError, TypeError):
|
|
return None
|
|
|
|
|
|
def _parse_ps1_var(path: Path, key: str) -> str | None:
|
|
"""Extract value of $key= or key= from a PowerShell file."""
|
|
if not path.exists():
|
|
return None
|
|
normalized = key.lstrip("$")
|
|
try:
|
|
raw = path.read_bytes()
|
|
# Windows PowerShell's Out-File writes UTF-16, Set-Content -Encoding
|
|
# UTF8 a byte order mark: EmuDeck's settings.ps1 passes through both.
|
|
encoding = "utf-16" if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else "utf-8-sig"
|
|
for line in raw.decode(encoding, errors="replace").splitlines():
|
|
line = line.strip()
|
|
check = line.lstrip("$")
|
|
if check.startswith(f"{normalized}="):
|
|
_, _, value = check.partition("=")
|
|
return value.strip('"').strip("'")
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
# The root the embedded rules read under: "/" on a device, a folder holding a
|
|
# copy of one in the tests.
|
|
_SYSTEM_ROOT = Path("/")
|
|
|
|
|
|
def _on_device(path: str) -> Path:
|
|
"""A path of the running system, read under _SYSTEM_ROOT."""
|
|
if _SYSTEM_ROOT == Path("/"):
|
|
return Path(path)
|
|
return _SYSTEM_ROOT / path.lstrip("/")
|
|
|
|
|
|
class _OsRule(NamedTuple):
|
|
"""One piece of evidence that names an embedded system.
|
|
|
|
manifest is the platform whose files it gets, "" for a system the
|
|
installer does not lay out. name is the system's own name. values are the
|
|
KEY=value pairs of os_release (any one), the files of file_exists (all),
|
|
or the file and the pattern of file_matches. bios is the folder used when
|
|
none of configs (retroarch.cfg files, read in order, `*` allowed) names a
|
|
system_directory. home is what `~` means to that system's RetroArch.
|
|
base is the system this one is built on: both match, this one is meant.
|
|
"""
|
|
|
|
manifest: str
|
|
name: str
|
|
method: str
|
|
values: tuple[str, ...]
|
|
bios: str | None
|
|
configs: tuple[str, ...] = ()
|
|
home: str = ""
|
|
base: str = ""
|
|
|
|
|
|
# The platform whose files each of these systems gets. They publish their own
|
|
# BIOS list, and these platforms transcribe it.
|
|
_STANDIN_MANIFESTS = {
|
|
"AmberELEC": "amberelec",
|
|
"EmuELEC": "emuelec",
|
|
"KNULLI": "knulli",
|
|
}
|
|
|
|
# Where the ELEC family keeps RetroArch: the config runemu writes and copies
|
|
# back on every launch, and HOME=/storage for the services that start it.
|
|
_ELEC_CONFIGS = ("/storage/.config/retroarch/retroarch.cfg",)
|
|
# ArkOS and dArkOS: the 64-bit and the 32-bit RetroArch. "Switch to SD2 for
|
|
# Roms" rewrites /roms/ to /roms2/ in both (darkos daecf488,
|
|
# scripts/Switch to SD2 for Roms.sh:91-92), so a second card is read here.
|
|
_ARK_CONFIGS = ("/home/ark/.config/retroarch/retroarch.cfg",
|
|
"/home/ark/.config/retroarch32/retroarch.cfg")
|
|
_PLYMOUTH_TITLE = "/usr/share/plymouth/themes/text.plymouth"
|
|
|
|
# muOS runs RetroArch on the config it copies from its default on first launch
|
|
# (internal f41f4a89, script/control/retroarch.sh:8-20). The default names
|
|
# /run/muos/storage/bios, a bind mount of MUOS/bios on the second card when
|
|
# that card holds one, else on the first (script/device/bind.sh:4,116-150),
|
|
# and loads cores from /opt/muos/share/core (retroarch.default.cfg:2806,3151).
|
|
MUOS_RETROARCH_CONFIGS = (
|
|
"/opt/muos/share/info/config/retroarch.cfg",
|
|
"/opt/muos/share/emulator/retroarch/retroarch.default.cfg",
|
|
)
|
|
|
|
# Embedded systems, by what their own builds write. The registry publishes the
|
|
# same rules as each manifest's `detect`, and a test holds the two equal.
|
|
# MiSTer is a path rule below.
|
|
_EMBEDDED_RULES: tuple[_OsRule, ...] = (
|
|
# ROCKNIX writes OS_NAME and no ID since 2025 (scripts/image). Older
|
|
# images wrote ID=rocknix.
|
|
_OsRule("rocknix", "ROCKNIX", "os_release", ("OS_NAME=rocknix", "ID=rocknix"),
|
|
"/storage/roms/bios"),
|
|
# Knulli (knulli-linux 57afa662): OS_NAME in os-release
|
|
# (board/scripts/post-build-script.sh:233-236) and knulli.version
|
|
# (package/system/knulli-system/knulli-system.mk:98-102).
|
|
_OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "os_release", ("OS_NAME=knulli",),
|
|
"/userdata/bios", base="Batocera"),
|
|
_OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_exists",
|
|
("/usr/share/knulli/knulli.version",), "/userdata/bios", base="Batocera"),
|
|
_OsRule("lakka", "Lakka", "os_release", ("ID=lakka",), "/storage/system"),
|
|
# batocera.linux 38845725, batocera-system.mk:21-22 (the same lines in
|
|
# batocera-30): batocera.arch and batocera.version.
|
|
_OsRule("batocera", "Batocera", "file_exists", ("/usr/share/batocera/batocera.version",),
|
|
"/userdata/bios"),
|
|
_OsRule("recalbox", "Recalbox", "file_exists", ("/recalbox/recalbox.version",),
|
|
"/recalbox/share/bios"),
|
|
_OsRule("recalbox", "Recalbox", "file_exists", ("/usr/bin/recalbox-settings",),
|
|
"/recalbox/share/bios"),
|
|
_OsRule("retroarch", "muOS", "file_exists", ("/opt/muos",), "/run/muos/storage/bios",
|
|
configs=MUOS_RETROARCH_CONFIGS),
|
|
_OsRule("retroarch", "muOS", "file_exists", ("/mnt/mmc/MUOS",), "/mnt/mmc/MUOS/bios"),
|
|
# AmberELEC 5a3882cb: scripts/image:150-152 writes NAME="${DISTRONAME}",
|
|
# AmberELEC in distributions/AmberELEC/options:17. CHAos (tech4bot/AmberELEC
|
|
# c0a4b0c6) is built from the same tree and writes the same name
|
|
# (distributions/AmberELEC/options:14). runemu.py:22 launches with
|
|
# /storage/.config/retroarch/retroarch.cfg, whose default is "~/roms/bios"
|
|
# (packages/games/tools/retroarch/sources/retroarch.cfg:611), and
|
|
# emustation.service:6 sets HOME=/storage.
|
|
_OsRule(_STANDIN_MANIFESTS["AmberELEC"], "AmberELEC", "os_release", ("NAME=AmberELEC",),
|
|
"/storage/roms"),
|
|
# PAN4ELEC (Kegg1701/P4ELEC 9c19bfb2), an overlay on an AmberELEC image:
|
|
# AmberELEC/usr/bin/batocera-info:101 prints its name.
|
|
_OsRule(_STANDIN_MANIFESTS["AmberELEC"], "PAN4ELEC", "file_matches",
|
|
("/usr/bin/batocera-info", r"OPERATING SYSTEM: PAN4ELEC"),
|
|
"/storage/roms", base="AmberELEC"),
|
|
# EmuELEC d4c7b2f1: scripts/image:172-176 NAME and ID from DISTRONAME,
|
|
# EmuELEC in distributions/EmuELEC/options:4. setsettings.sh:22 names the
|
|
# config, and libretro_base/retroarch/package.mk:117 sets its
|
|
# system_directory to /storage/roms/bios.
|
|
_OsRule(_STANDIN_MANIFESTS["EmuELEC"], "EmuELEC", "os_release", ("NAME=EmuELEC",),
|
|
"/storage/roms"),
|
|
# JELOS 7b2e116c: scripts/image:118-120 OS_NAME="${DISTRONAME}", JELOS in
|
|
# distributions/JELOS/options:17. runemu.sh:37 names the config, whose
|
|
# default is "~/roms/bios" (retroarch/sources/RK3566/retroarch.cfg:716),
|
|
# and emustation.service:8 sets HOME=/storage.
|
|
_OsRule("retroarch", "JELOS", "os_release", ("OS_NAME=JELOS",),
|
|
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
|
|
# KOLOS (AnonLinuxOS/KOLOS 7daf5950) keeps OS_NAME=JELOS
|
|
# (distributions/JELOS/options:17) and adds jelos-ready.service
|
|
# ("KOLOS system ready script"), installed by scripts/install:100-102.
|
|
_OsRule("retroarch", "KOLOS", "file_exists",
|
|
("/usr/lib/systemd/system/jelos-ready.service",),
|
|
"/storage/roms/bios", _ELEC_CONFIGS, "/storage", base="JELOS"),
|
|
# UnofficialOS 22635796: scripts/image:119-120 OS_NAME, UnofficialOS in
|
|
# distributions/UnofficialOS/options:17, config at
|
|
# packages/unofficialos/sources/scripts/runemu.sh:37.
|
|
_OsRule("retroarch", "UnofficialOS", "os_release", ("OS_NAME=UnofficialOS",),
|
|
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
|
|
# Arch R (archr-linux/Arch-R dbd45d45): scripts/image:164-177 NAME and
|
|
# OS_NAME, ArchR in distributions/ArchR/options:17. factoryreset:9-10
|
|
# names the config, whose default is "~/roms/bios"
|
|
# (retroarch/sources/RK3326/retroarch.cfg:718).
|
|
_OsRule("retroarch", "ArchR", "os_release", ("OS_NAME=ArchR",),
|
|
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
|
|
# ArkOS: the user and the menu folder, and the boot splash title its
|
|
# updates write (christianhaitian/arkos dedf4278, Update-RGB10.sh:76).
|
|
_OsRule("retroarch", "ArkOS", "file_exists", ("/home/ark", "/opt/system"), "/roms/bios",
|
|
_ARK_CONFIGS),
|
|
_OsRule("retroarch", "ArkOS", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*ArkOS\b"),
|
|
"/roms/bios", _ARK_CONFIGS),
|
|
# dArkOS daecf488: finishing_touches.sh:359-361 writes "title=dArkOS
|
|
# (<date>)", kept by the dArkOSRE, dArkOSen and ArkOS4Clone overlays.
|
|
# Default system_directory "/roms/bios" (retroarch.cfg.rgb10:3043).
|
|
_OsRule("retroarch", "dArkOS", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*dArkOS"),
|
|
"/roms/bios", _ARK_CONFIGS, base="ArkOS"),
|
|
# RetroOZ 0.60 image (no build source is published): title=RetroOZ in
|
|
# the splash theme, /opt/.retrooz/version, and /etc/retrooz_version, the
|
|
# file PortMaster reads (device_info.txt:95). RetroArch is started with
|
|
# --config /home/odroid/.config/retroarch/retroarch.cfg (es_systems.cfg),
|
|
# system_directory "/roms/bios" (retroarch32/retroarch.cfg:3046).
|
|
_OsRule("retroarch", "RetroOZ", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*RetroOZ"),
|
|
"/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg",
|
|
"/home/odroid/.config/retroarch32/retroarch.cfg")),
|
|
_OsRule("retroarch", "RetroOZ", "file_exists", ("/opt/.retrooz",),
|
|
"/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg",
|
|
"/home/odroid/.config/retroarch32/retroarch.cfg")),
|
|
_OsRule("retroarch", "RetroOZ", "file_exists", ("/etc/retrooz_version",),
|
|
"/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg",
|
|
"/home/odroid/.config/retroarch32/retroarch.cfg")),
|
|
# TheRA is left undetected: no image or source of its Odroid Go builds is
|
|
# published, so neither its markers nor its folder could be read.
|
|
# spruceOS 824b1aae: the spruce folder at the card root. RetroArch reads
|
|
# Saves/ra-configs/retroarch-<platform>.cfg (spruce/scripts/
|
|
# helperFunctions.sh:157-165), seeded with system_directory
|
|
# "/mnt/SDCARD/BIOS" (RetroArch/platform/retroarch-Brick.cfg.bak:3279).
|
|
_OsRule("retroarch", "spruceOS", "file_exists", ("/mnt/SDCARD/spruce",),
|
|
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/Saves/ra-configs/retroarch-*.cfg",),
|
|
base="TrimUI"),
|
|
# CrossMix-OS 206d612f: crossmix-version.txt (System/usr/trimui/scripts/
|
|
# update_common.sh:20), RetroArch/retroarch.cfg:3202 (patch_ra_cfg.sh:5).
|
|
_OsRule("retroarch", "CrossMix-OS", "file_exists",
|
|
("/mnt/SDCARD/System/usr/trimui/crossmix-version.txt",),
|
|
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/retroarch.cfg",), base="TrimUI"),
|
|
# Onion 07505ea5: Makefile:75-76 writes onionVersion/version.txt, and
|
|
# static/build/.tmp_update/script/patch_ra_cfg.sh:6 names the config
|
|
# (system_directory at static/configs/RetroArch/.retroarch/retroarch.cfg:3009).
|
|
_OsRule("retroarch", "Onion", "file_exists",
|
|
("/mnt/SDCARD/.tmp_update/onionVersion/version.txt",),
|
|
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)),
|
|
# Allium ee0dda97: its base folder /mnt/SDCARD/.allium
|
|
# (crates/common/src/constants.rs:19-34). RetroArch runs with
|
|
# HOME=/mnt/SDCARD/RetroArch on .retroarch/retroarch.cfg
|
|
# (static/.allium/cores/retroarch/launch_without_savestate_auto_load.sh:2-7),
|
|
# and Allium's RetroArch patch makes $ROOT/BIOS the default system folder.
|
|
_OsRule("retroarch", "Allium", "file_exists", ("/mnt/SDCARD/.allium",),
|
|
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)),
|
|
# Koriki 8513071c: src/systemInfo/main.c:106 reads Koriki/version.txt,
|
|
# .tmp_update/runtime.sh:271 installs RetroArch/.retroarch/retroarch.cfg
|
|
# (system_directory at base/RetroArch/.retroarch/retroarch.cfg:3191).
|
|
_OsRule("retroarch", "Koriki", "file_exists", ("/mnt/SDCARD/Koriki/version.txt",),
|
|
"/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)),
|
|
# MiyooCFW (buildroot d5912eeb): board/miyoo/rootfs/etc/main sets HOME=/mnt
|
|
# (line 4), package/miyoo/miyoo-ctl/miyoo-ctl.mk:12 installs miyooctl.
|
|
# RetroArch is a DINGUX build: $HOME/.retroarch/retroarch.cfg, and
|
|
# $HOME/.retroarch/system when no config names a folder.
|
|
_OsRule("retroarch", "MiyooCFW", "file_exists", ("/etc/main", "/usr/bin/miyooctl"),
|
|
"/mnt/.retroarch/system", ("/mnt/.retroarch/retroarch.cfg",), "/mnt"),
|
|
# plumOS: os-release ID=plumos (game-de-it/plumOS-pixel2_v2 dec872c0,
|
|
# rootfs/pixel2/etc/os-release:2, PortMaster device_info.txt:101). Each
|
|
# device build appends its own system_directory at launch: the Pixel 2
|
|
# build /mnt/plumos-user/bios (package/app-layer-pixel2/bin/
|
|
# plumos-retroarch-launch:342, mount point made by rootfs/pixel2/sbin/
|
|
# init:29), the MF build <card>/BIOS (plumOS-MF 0095017c,
|
|
# plumos-retroarch-launch:15). Only the first is told apart by a file.
|
|
_OsRule("retroarch", "plumOS", "file_exists", ("/mnt/plumos-user",),
|
|
"/mnt/plumos-user/bios"),
|
|
_OsRule("retroarch", "plumOS", "os_release", ("ID=plumos",), None),
|
|
# TrimUI's stock firmware publishes no source naming its RetroArch BIOS
|
|
# folder. PortMaster finds its libraries in /usr/trimui
|
|
# (trimui/launch_hooks.txt:37). Named so that what runs on top of it is
|
|
# told apart, never installed to by a guess.
|
|
_OsRule("", "TrimUI", "file_exists", ("/usr/trimui",), None),
|
|
# MinUI, MyMinUI and NextUI keep their system in .system at the card root
|
|
# (MinUI dbf89435, workspace/all/common/defines.h:14-16) and give each core
|
|
# Bios/<tag>/ (workspace/all/minarch/minarch.c:2942). Their file lists
|
|
# write that layout below the card root, and the card's version.txt
|
|
# says which of the three it is (card_platform).
|
|
_OsRule("", "MinUI", "file_exists", ("/mnt/SDCARD/.system/res",), None, base="TrimUI"),
|
|
_OsRule("", "MinUI", "file_exists", ("/mnt/sdcard/.system/res",), None, base="TrimUI"),
|
|
_OsRule("retroarch", "Anbernic stock firmware", "file_exists",
|
|
("/mnt/vendor/bin/dmenu.bin",), "/mnt/mmc/bios"),
|
|
)
|
|
|
|
# Why a system found here gets no folder: said once per run.
|
|
_NO_FOLDER = {
|
|
"TrimUI": "its firmware publishes no source naming the folder its RetroArch reads",
|
|
"MinUI": f"its card names no release in {CARD_MARKER}, which tells MinUI, NextUI "
|
|
"and MyMinUI apart. Name the card's root and the one it runs: "
|
|
"--platform minui (or nextui, myminui) --dest /mnt/SDCARD",
|
|
}
|
|
# A system found by its card, with the end of the path that proves it: the
|
|
# card root is what comes before.
|
|
_CARD_SYSTEMS = {"MinUI": "/.system/res"}
|
|
|
|
# (platform, BIOS folder) -> the name the system found there gives itself.
|
|
_DETECTED_OS: dict[tuple[str, Path], str] = {}
|
|
|
|
|
|
def _rule_matches(rule: _OsRule, release: dict[str, str]) -> bool:
|
|
if rule.method == "os_release":
|
|
# Any of the KEY=value pairs, the value compared without case.
|
|
return any(
|
|
release.get(key) == value.lower()
|
|
for key, _, value in (pair.partition("=") for pair in rule.values)
|
|
)
|
|
if rule.method == "file_matches":
|
|
path, pattern = rule.values
|
|
try:
|
|
text = _on_device(path).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return False
|
|
return re.search(pattern, text, re.IGNORECASE | re.MULTILINE) is not None
|
|
return all(_exists(_on_device(value)) for value in rule.values)
|
|
|
|
|
|
def _config_files(pattern: str) -> list[tuple[Path, Path]]:
|
|
"""(file to read, its folder as the system sees it) for a config rule."""
|
|
device = pattern.replace("$HOME", str(Path.home()), 1)
|
|
folder, _, name = device.rpartition("/")
|
|
if "*" not in name:
|
|
return [(_on_device(device), Path(folder))]
|
|
try:
|
|
return [(path, Path(folder)) for path in sorted(_on_device(folder).glob(name))
|
|
if path.is_file()]
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def _os_system_dir(rules: list[_OsRule]) -> Path | None:
|
|
"""The folder a system's RetroArch reads: the system_directory of its
|
|
first config that sets one, else the folder its build defaults to.
|
|
|
|
The config also records where the cores of that installation are.
|
|
"""
|
|
for rule in rules:
|
|
home = Path(rule.home) if rule.home else None
|
|
for pattern in rule.configs:
|
|
for path, folder in _config_files(pattern):
|
|
system_dir = _parse_retroarch_system_dir(path, folder, home)
|
|
if system_dir is not None:
|
|
seen = _on_device(str(system_dir))
|
|
_RETROARCH_CONFIGS.setdefault(seen, _RETROARCH_CONFIGS[system_dir])
|
|
return seen
|
|
bios = next((rule.bios for rule in rules if rule.bios), None)
|
|
return _on_device(bios) if bios else None
|
|
|
|
|
|
def _detect_embedded() -> list[tuple[str, Path]]:
|
|
"""The embedded system this is, by what its own build writes.
|
|
|
|
A system built on another matches both: the derivative is meant. Two
|
|
unrelated systems matching are both returned, and os_conflict() names
|
|
them, so that no folder is chosen between them in silence.
|
|
"""
|
|
release = {k: v.lower() for k, v in _parse_os_release().items()}
|
|
matched = [rule for rule in _EMBEDDED_RULES if _rule_matches(rule, release)]
|
|
bases = {rule.base for rule in matched if rule.base}
|
|
found: list[tuple[str, Path]] = []
|
|
for name in dict.fromkeys(rule.name for rule in matched):
|
|
if name in bases:
|
|
continue
|
|
rules = [rule for rule in matched if rule.name == name]
|
|
plat = rules[0].manifest
|
|
folder = _os_system_dir(rules) if plat else None
|
|
if name in _CARD_SYSTEMS:
|
|
marker = next(value for rule in rules for value in rule.values
|
|
if value.endswith(_CARD_SYSTEMS[name]))
|
|
root = _on_device(marker[:-len(_CARD_SYSTEMS[name])])
|
|
plat = card_platform(root) or ""
|
|
if plat:
|
|
folder, name = root, display_name(plat)
|
|
if folder is None:
|
|
reason = _NO_FOLDER.get(
|
|
name, "nothing on it names the folder its RetroArch reads")
|
|
closing = "" if name in _CARD_SYSTEMS else " Name the folder with --dest."
|
|
print(f"{name} found here, but {reason}.{closing}", file=sys.stderr)
|
|
continue
|
|
_DETECTED_OS[(plat, folder)] = name
|
|
found.append((plat, folder))
|
|
return found
|
|
|
|
|
|
def detected_os(plat: str, path: Path) -> str:
|
|
"""The name of the system detection found this folder on, or ""."""
|
|
return _DETECTED_OS.get((plat, path), "")
|
|
|
|
|
|
def os_conflict(platforms: list[tuple[str, Path]]) -> list[str]:
|
|
"""The systems among these when there are two or more: a machine runs one."""
|
|
names = list(dict.fromkeys(filter(None, (detected_os(n, p) for n, p in platforms))))
|
|
return names if len(names) > 1 else []
|
|
|
|
|
|
def _system_lines() -> list[str]:
|
|
"""Each embedded system detection knows, with the files it gets."""
|
|
lines = []
|
|
for name in dict.fromkeys(rule.name for rule in _EMBEDDED_RULES):
|
|
plat = next(rule.manifest for rule in _EMBEDDED_RULES if rule.name == name)
|
|
if name in _CARD_SYSTEMS:
|
|
lines.append(f"{name}: minui, nextui or myminui, as the card's {CARD_MARKER} "
|
|
"names it (from a computer: --dest <card root>)")
|
|
elif plat:
|
|
lines.append(f"{name}: {plat}")
|
|
else:
|
|
lines.append(f"{name}: not installed to, {_NO_FOLDER[name]} (use --dest)")
|
|
return lines
|
|
|
|
|
|
def found_label(plat: str, path: Path) -> str:
|
|
"""How a detected folder is announced: the system's own name, and the
|
|
platform whose files it gets when that is another name."""
|
|
name = detected_os(plat, path)
|
|
shown = display_name(plat)
|
|
moved = "".join(f", {folder}/ at {place}"
|
|
for folder, place in sorted(_MOVED_FOLDERS.get(path, {}).items())
|
|
if folder in ("bios", "roms"))
|
|
if not name or name == shown:
|
|
return f"{shown} at {path}{moved}"
|
|
return f"{name} at {path} ({shown} files){moved}"
|
|
|
|
|
|
def _android_storage_root() -> Path:
|
|
"""Shared storage root, the parent RetroArch builds its defaults from."""
|
|
value = os.environ.get("EXTERNAL_STORAGE", "").strip()
|
|
return Path(value) if value else Path("/storage/emulated/0")
|
|
|
|
|
|
# Where each RetroArch package keeps its folders, RetroArch v1.22.2
|
|
# (pkg/android/phoenix/build.gradle:46-95). com.retroarch and
|
|
# com.retroarch.aarch64 are each published twice under one id: by
|
|
# retroarch.com (normal, aarch64) and on Google Play (playStoreNormal,
|
|
# playStorePlus). com.retroarch.ra32 has no Google Play flavor. The Google
|
|
# Play build hands RetroArch Android/data/<package>/files as the storage its
|
|
# defaults are built from, the other builds the shared storage root
|
|
# (MainMenuActivity.java:182-183).
|
|
_ANDROID_RETROARCH_PLAY_IDS = ("com.retroarch", "com.retroarch.aarch64")
|
|
# A Google Play versionCode is the manifest's 1597175267 (AndroidManifest.xml:5)
|
|
# plus one per half week since 1609632000 (build.gradle:76, 87, 168-175), so
|
|
# it stays below that start time. The other flavors use the build time in
|
|
# seconds (build.gradle:42), above it for every build since 2021. The launcher
|
|
# writes the code to retroarch.cfg (UserPreferences.java:150), which
|
|
# RetroArch keeps (configuration.c:2623).
|
|
_ANDROID_PLAY_VERSION_CEILING = 1609632000
|
|
|
|
|
|
def _android_cfg_value(cfg: Path, key: str) -> str | None:
|
|
"""A value of an Android retroarch.cfg, None when unreadable or unset."""
|
|
try:
|
|
lines = cfg.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
return None
|
|
for line in lines:
|
|
name, sep, value = line.partition("=")
|
|
if sep and name.strip() == key:
|
|
return value.strip().strip('"').strip("'")
|
|
return None
|
|
|
|
|
|
def _android_play_build(package: str, storage: Path, cfgs: tuple[Path, ...]) -> bool | None:
|
|
"""True for the Google Play build of this package, False for the
|
|
retroarch.com one, None when nothing it wrote tells them apart.
|
|
|
|
The version code in its config decides. Without a readable config, the
|
|
folder each build writes does: the Google Play build never asks for
|
|
shared storage (MainMenuActivity.java:199-202), and only it builds its
|
|
defaults under Android/data/<package>/files/RetroArch.
|
|
"""
|
|
if package not in _ANDROID_RETROARCH_PLAY_IDS:
|
|
return False
|
|
for cfg in cfgs:
|
|
code = _android_cfg_value(cfg, "bundle_assets_extract_version_current")
|
|
if code and code.isdigit() and int(code) > 0:
|
|
return int(code) < _ANDROID_PLAY_VERSION_CEILING
|
|
if (storage / "Android" / "data" / package / "files" / "RetroArch").is_dir():
|
|
return True
|
|
if (storage / "RetroArch").is_dir():
|
|
return False
|
|
return None
|
|
|
|
|
|
def _android_writable(folder: Path) -> bool:
|
|
"""Whether this shell can create folder: its nearest existing parent is
|
|
writable. Android 11 and later keep Android/data/<package> of other apps
|
|
from Termux."""
|
|
for parent in (folder, *folder.parents):
|
|
if parent.exists():
|
|
return parent.is_dir() and os.access(parent, os.W_OK)
|
|
return False
|
|
|
|
|
|
def _android_out_of_reach(name: str, folder: Path, what: str) -> str:
|
|
"""The line said for an emulator whose BIOS folder this shell cannot write."""
|
|
return (f"{name} is installed, but this shell cannot write its BIOS folder "
|
|
f"{folder} without root: {what} are not copied there.")
|
|
|
|
|
|
def _android_retroarch_builds() -> list[tuple[Path | None, str]]:
|
|
"""Each installed RetroArch build: its system folder when this shell can
|
|
write it, else None and the line that says why.
|
|
|
|
RetroArch v1.22.2: the launcher picks retroarch.cfg in the app external
|
|
files directory, else in the internal one
|
|
(pkg/android/phoenix-common/src/com/retroarch/browser/preferences/util/
|
|
UserPreferences.java:36-89), and passes it as CONFIGFILE
|
|
(MainMenuActivity.java:178, read at frontend/drivers/platform_unix.c:
|
|
1428-1444). A system_directory left at default resolves against
|
|
<storage>/RetroArch/system (platform_unix.c:1670-1697), storage being
|
|
what the launcher passes as SDCARD: Android/data/<package>/files for the
|
|
Google Play build, the shared storage root for the others.
|
|
"""
|
|
storage = _android_storage_root()
|
|
builds: list[tuple[Path | None, str]] = []
|
|
for package in ANDROID_RETROARCH_PACKAGES:
|
|
external = storage / "Android" / "data" / package / "files"
|
|
if not (ANDROID_APP_DATA / package).exists() and not external.exists():
|
|
continue
|
|
cfgs = (external / "retroarch.cfg",
|
|
ANDROID_APP_DATA / package / "files" / "retroarch.cfg")
|
|
play = _android_play_build(package, storage, cfgs)
|
|
play_root, shared_root = external / "RetroArch", storage / "RetroArch"
|
|
named = next(filter(None, (_android_cfg_value(cfg, "system_directory")
|
|
for cfg in cfgs)), "default")
|
|
if play is None and named == "default":
|
|
builds.append((None,
|
|
f"RetroArch ({package}) is installed, but nothing it has written tells "
|
|
f"which build it is: the Google Play build reads {play_root / 'system'}, "
|
|
f"the retroarch.com build reads {shared_root / 'system'}. "
|
|
"Name the folder with --dest."))
|
|
continue
|
|
root = play_root if play else shared_root
|
|
folder = next(filter(None, (_parse_retroarch_system_dir(cfg, root) for cfg in cfgs)),
|
|
root / "system")
|
|
private = (storage / "Android" / "data", ANDROID_APP_DATA)
|
|
if any(folder == base or base in folder.parents for base in private) \
|
|
and not _android_writable(folder):
|
|
build = "Google Play build" if play else "retroarch.com build"
|
|
builds.append((None, _android_out_of_reach(
|
|
f"RetroArch ({build}, {package})", folder, "BIOS files")))
|
|
continue
|
|
builds.append((folder, ""))
|
|
return builds
|
|
|
|
|
|
def _detect_android() -> list[tuple[str, Path]]:
|
|
"""Find the RetroArch system directory of each installed build on
|
|
Android, as _android_retroarch_builds() resolves it (RetroArch v1.22.2,
|
|
platform_unix.c:1670-1697).
|
|
|
|
With no package seen, <shared storage>/RetroArch is the folder of a
|
|
retroarch.com build whose private data this shell cannot see.
|
|
"""
|
|
found: list[tuple[str, Path]] = []
|
|
builds = _android_retroarch_builds()
|
|
for folder, _line in builds:
|
|
if folder is not None and ("retroarch", folder) not in found:
|
|
found.append(("retroarch", folder))
|
|
default_root = _android_storage_root() / "RetroArch"
|
|
if not builds and default_root.exists():
|
|
found.append(("retroarch", default_root / "system"))
|
|
return found
|
|
|
|
|
|
# Android emulators that keep their BIOS outside RetroArch's folder:
|
|
# (name, package, folder, files read there). DraStic keeps it in its private
|
|
# data (GammaOS Next 14 bb28e71d, gammaos/setup.sh:345-363 extracts it to
|
|
# /data/data/com.dsemu.drastic/files/DraStic/system), which no other app
|
|
# writes without root. Flycast reads <home>/data/ (flycast a9bd1c74,
|
|
# shell/android-studio/flycast/src/main/jni/src/Android.cpp:143-159), its
|
|
# home being Android/data/com.flycast.emulator/files, which Android 11 and
|
|
# later keep from other apps.
|
|
_ANDROID_APP_FOLDERS = (
|
|
("DraStic", "com.dsemu.drastic", "/data/data/com.dsemu.drastic/files/DraStic/system",
|
|
("nds_bios_arm7.bin", "nds_bios_arm9.bin", "nds_firmware.bin")),
|
|
("Flycast", "com.flycast.emulator",
|
|
"$STORAGE/Android/data/com.flycast.emulator/files/data",
|
|
("dc_boot.bin", "dc_nvmem.bin")),
|
|
)
|
|
|
|
|
|
def android_unreachable_folders() -> list[str]:
|
|
"""A line for each Android emulator installed here whose BIOS folder
|
|
this shell cannot write, said instead of skipping it in silence. A
|
|
RetroArch build whose folder is out of reach, or whose build cannot be
|
|
told, is among them.
|
|
|
|
An app's private folder can be tested for existence by any app, so the
|
|
emulator is known to be installed even where its folder is out of reach.
|
|
"""
|
|
lines = [line for folder, line in _android_retroarch_builds() if folder is None]
|
|
storage = str(_android_storage_root())
|
|
for name, package, folder, files in _ANDROID_APP_FOLDERS:
|
|
target = Path(folder.replace("$STORAGE", storage, 1))
|
|
if folder.startswith("/data/data/"):
|
|
target = ANDROID_APP_DATA / Path(folder).relative_to("/data/data")
|
|
if not (ANDROID_APP_DATA / package).exists() and not target.exists():
|
|
continue
|
|
if target.is_dir() and os.access(target, os.W_OK):
|
|
continue
|
|
lines.append(_android_out_of_reach(name, target, ", ".join(files)))
|
|
return lines
|
|
|
|
|
|
def _lnk_target(lnk_path: Path, exe_name: str) -> Path | None:
|
|
"""Read the target path of a Windows shortcut.
|
|
|
|
Shortcuts embed the target as a plain local path. Scanning for it avoids
|
|
parsing the whole .lnk structure for the one field that matters here.
|
|
"""
|
|
try:
|
|
if lnk_path.stat().st_size > 64 * 1024:
|
|
return None
|
|
blob = lnk_path.read_bytes()
|
|
except OSError:
|
|
return None
|
|
pattern = re.compile(
|
|
rb"[A-Za-z]:\\[ -~]{0,260}?" + re.escape(exe_name.encode()), re.IGNORECASE
|
|
)
|
|
match = pattern.search(blob)
|
|
if not match:
|
|
return None
|
|
return Path(match.group().decode("ascii", "replace").replace("\\", "/"))
|
|
|
|
|
|
def launchbox_root(os_type: str) -> Path | None:
|
|
"""Locate the LaunchBox installation directory.
|
|
|
|
LaunchBox installs wherever the user points its installer and writes no
|
|
uninstall registry key, so the Start menu shortcut is the only record of
|
|
that choice. Everything else falls back to the default location.
|
|
"""
|
|
if os_type not in ("windows", "wsl"):
|
|
return None
|
|
candidates: list[Path] = []
|
|
appdata = os.environ.get("APPDATA", "")
|
|
if appdata:
|
|
lnk = (
|
|
Path(appdata)
|
|
/ "Microsoft"
|
|
/ "Windows"
|
|
/ "Start Menu"
|
|
/ "Programs"
|
|
/ "LaunchBox"
|
|
/ "LaunchBox.lnk"
|
|
)
|
|
if lnk.exists():
|
|
target = _lnk_target(lnk, "LaunchBox.exe")
|
|
if target:
|
|
# The shortcut points at Core/LaunchBox.exe
|
|
candidates.append(target.parent.parent)
|
|
candidates.append(target.parent)
|
|
userprofile = os.environ.get("USERPROFILE", "")
|
|
if userprofile:
|
|
candidates.append(Path(userprofile) / "LaunchBox")
|
|
for root in candidates:
|
|
if (root / "Data" / "Emulators.xml").exists():
|
|
return root
|
|
return None
|
|
|
|
|
|
def launchbox_emulators(emulators_xml: Path) -> dict[str, Path]:
|
|
"""Map each emulator LaunchBox knows about to its installation directory.
|
|
|
|
LaunchBox stores its emulator list in Data/Emulators.xml. ApplicationPath
|
|
is either absolute or relative to the LaunchBox root directory. Keys are
|
|
the lowercase executable names.
|
|
"""
|
|
found: dict[str, Path] = {}
|
|
if not emulators_xml.exists():
|
|
return found
|
|
try:
|
|
if emulators_xml.stat().st_size > 10 * 1024 * 1024:
|
|
return found
|
|
raw = emulators_xml.read_text(encoding="utf-8", errors="replace")
|
|
# ElementTree expands internal entities, and LaunchBox writes no doctype
|
|
if "<!DOCTYPE" in raw.upper():
|
|
return found
|
|
tree = ET.ElementTree(ET.fromstring(raw))
|
|
except (ET.ParseError, OSError):
|
|
return found
|
|
lb_root = emulators_xml.parent.parent
|
|
for emu in tree.getroot().iter("Emulator"):
|
|
app_path = (emu.findtext("ApplicationPath") or "").replace("\\", "/")
|
|
if not app_path:
|
|
continue
|
|
exe = Path(app_path)
|
|
if not exe.is_absolute():
|
|
exe = lb_root / exe
|
|
if exe.parent.is_dir():
|
|
found.setdefault(exe.name.lower(), exe.parent)
|
|
return found
|
|
|
|
|
|
def _launchbox_retroarch_system_dir(emulators_xml: Path) -> Path | None:
|
|
"""Resolve the system dir of a RetroArch referenced by LaunchBox.
|
|
|
|
Mirrors the plugin's UpdateSystemPath: read system_directory from the
|
|
retroarch.cfg sitting next to the executable, else assume system/.
|
|
"""
|
|
emu_dir = launchbox_emulators(emulators_xml).get("retroarch.exe")
|
|
if emu_dir is None:
|
|
return None
|
|
system_dir = _parse_retroarch_system_dir(emu_dir / "retroarch.cfg", emu_dir)
|
|
return system_dir or emu_dir / "system"
|
|
|
|
|
|
def _pcsx2_bios_dir(emu_dir: Path) -> Path:
|
|
"""Resolve the BIOS folder of a PCSX2 installed under LaunchBox.
|
|
|
|
EmuFolders (pcsx2/Pcsx2Config.cpp) enters portable mode when portable.ini
|
|
or portable.txt sits next to the executable, and then takes the data root
|
|
from the contents of portable.txt, relative to the executable. Otherwise
|
|
the data root is Documents/PCSX2. Bios defaults to "bios" below it and the
|
|
Bios key of PCSX2.ini overrides that.
|
|
"""
|
|
documents = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "Documents"
|
|
portable_txt = emu_dir / "portable.txt"
|
|
portable = (emu_dir / "portable.ini").exists() or portable_txt.exists()
|
|
if portable:
|
|
subpath = ""
|
|
try:
|
|
subpath = portable_txt.read_text(
|
|
encoding="utf-8", errors="replace"
|
|
).strip()
|
|
except OSError:
|
|
pass
|
|
data_root = emu_dir / subpath if subpath else emu_dir
|
|
else:
|
|
data_root = documents / "PCSX2"
|
|
ini = data_root / "inis" / "PCSX2.ini"
|
|
try:
|
|
for line in ini.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if line.startswith("Bios = "):
|
|
value = line[len("Bios = "):].strip()
|
|
candidate = Path(value)
|
|
return candidate if candidate.is_absolute() else data_root / value
|
|
except OSError:
|
|
pass
|
|
return data_root / "bios"
|
|
|
|
|
|
def _xemu_bios_dir(emu_dir: Path) -> Path:
|
|
"""Resolve the BIOS folder of a xemu managed by LaunchBox.
|
|
|
|
Follows the plugin: xemu.toml next to the executable else the roaming
|
|
copy, with bootrom_path and flashrom_path naming a file whose directory
|
|
holds the images. Both default to bios/ under the executable.
|
|
"""
|
|
appdata = os.environ.get("APPDATA", "")
|
|
toml = emu_dir / "xemu.toml"
|
|
if not toml.exists() and appdata:
|
|
toml = Path(appdata) / "xemu" / "xemu" / "xemu.toml"
|
|
if toml.exists():
|
|
try:
|
|
for line in toml.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if line.startswith(("bootrom_path", "flashrom_path")):
|
|
parts = line.split("'")
|
|
if len(parts) > 1 and Path(parts[1]).exists():
|
|
return Path(parts[1]).parent
|
|
except OSError:
|
|
pass
|
|
return emu_dir / "bios"
|
|
|
|
|
|
def launchbox_bios_dirs(root: Path) -> dict[str, Path]:
|
|
"""Map emulator ids to the BIOS directory LaunchBox expects for them.
|
|
|
|
Only emulators whose destination LaunchBox itself computes are listed.
|
|
The rest keep the default locations declared in the manifest.
|
|
"""
|
|
emulators = launchbox_emulators(root / "Data" / "Emulators.xml")
|
|
dirs: dict[str, Path] = {}
|
|
emu_dir = emulators.get("pcsx2.exe")
|
|
if emu_dir:
|
|
dirs["pcsx2"] = _pcsx2_bios_dir(emu_dir)
|
|
emu_dir = emulators.get("xemu.exe")
|
|
if emu_dir:
|
|
dirs["xemu"] = _xemu_bios_dir(emu_dir)
|
|
emu_dir = emulators.get("dolphin.exe")
|
|
if emu_dir and (emu_dir / "portable.txt").exists():
|
|
# portable.txt beside the executable moves the user directory to
|
|
# User (SetUserDirectory in Source/Core/UICommon/UICommon.cpp,
|
|
# PORTABLE_USER_DIR in Source/Core/Common/CommonPaths.h)
|
|
dirs["dolphin"] = emu_dir / "User"
|
|
return dirs
|
|
|
|
|
|
def detect_frontends(os_type: str, home: Path | None = None) -> list[str]:
|
|
"""Detect frontends that reference emulators without owning a BIOS dir.
|
|
|
|
ES-DE resolves its application data directory to $ESDE_APPDATA_DIR or
|
|
<home>/ES-DE (es-core FileSystemUtil.cpp getAppDataDirectory).
|
|
"""
|
|
home = home or Path.home()
|
|
frontends: list[str] = []
|
|
esde_env = os.environ.get("ESDE_APPDATA_DIR", "")
|
|
if (esde_env and Path(esde_env).is_dir()) or (home / "ES-DE").is_dir():
|
|
frontends.append("esde")
|
|
if launchbox_root(os_type) is not None:
|
|
frontends.append("launchbox")
|
|
return frontends
|
|
|
|
|
|
# The registry's path_exists rules (platforms/_registry.yml install.detect),
|
|
# one row each: (os, platform, path, BIOS directory when it is not the path).
|
|
# The manifests publish them too, but detection runs before any manifest is
|
|
# fetched. A test holds this table equal to the registry. Without it a Linux
|
|
# home holding RetroPie/BIOS or .config/BizHawk/Firmware detected nothing.
|
|
_PATH_RULES: tuple[tuple[str, str, str, str | None], ...] = (
|
|
("windows", "retrobat", "C:\\RetroBat\\bios", None),
|
|
("windows", "retrobat", "%USERPROFILE%\\RetroBat\\bios", None),
|
|
("linux", "emudeck", "$HOME/Emulation/bios", None),
|
|
("darwin", "emudeck", "$HOME/Emulation/bios", None),
|
|
("linux", "retrodeck", "$HOME/.var/app/net.retrodeck.retrodeck", "$HOME/retrodeck"),
|
|
("linux", "romm", "/romm/library/bios", None),
|
|
# A library laid out as {platform}/bios has no library/bios: RomM's
|
|
# config folder is the evidence (config_manager.py:264-265).
|
|
("linux", "romm", "/romm/config/config.yml", "/romm/library/bios"),
|
|
("linux", "retropie", "$HOME/RetroPie/BIOS", None),
|
|
("windows", "bizhawk", "%USERPROFILE%\\BizHawk\\Firmware", None),
|
|
("linux", "misterfpga", "/media/fat/MiSTer", "/media/fat/games"),
|
|
)
|
|
|
|
|
|
def _rule_path(template: str) -> Path:
|
|
"""A rule's path on this machine: $HOME or %USERPROFILE%, then the rest."""
|
|
bases = (
|
|
("$HOME", Path.home()),
|
|
("%USERPROFILE%", Path(os.environ.get("USERPROFILE") or Path.home())),
|
|
)
|
|
for variable, base in bases:
|
|
if template.startswith(variable):
|
|
rest = [part for part in re.split(r"[\\/]", template[len(variable):]) if part]
|
|
return base.joinpath(*rest)
|
|
return _host_path(template)
|
|
|
|
|
|
# The registry's executable rules: (os, platform, glob under $HOME or
|
|
# %USERPROFILE%, folder beside the executable that holds the BIOS). BizHawk is
|
|
# a portable archive reading <EmuHawk folder>/Firmware (PathExtensions.cs
|
|
# DataDirectoryPath), wherever it was extracted; ~/.config/BizHawk/Firmware,
|
|
# the old guess, is a folder no build reads.
|
|
_EXE_RULES: tuple[tuple[str, str, tuple[str, ...], str], ...] = (
|
|
("linux", "bizhawk", ("$HOME/BizHawk*/EmuHawkMono.sh", "$HOME/*/BizHawk*/EmuHawkMono.sh"),
|
|
"Firmware"),
|
|
("windows", "bizhawk", ("%USERPROFILE%\\BizHawk*\\EmuHawk.exe",
|
|
"%USERPROFILE%\\*\\BizHawk*\\EmuHawk.exe"), "Firmware"),
|
|
)
|
|
|
|
|
|
def _detect_by_executable(os_type: str, found: list[tuple[str, Path]]) -> None:
|
|
"""Add a portable install found by its executable, the newest first."""
|
|
systems = ("linux", "windows") if os_type == "wsl" else (os_type,)
|
|
for rule_os, platform_name, patterns, subdir in _EXE_RULES:
|
|
if rule_os not in systems or any(name == platform_name for name, _ in found):
|
|
continue
|
|
hits: list[Path] = []
|
|
for pattern in patterns:
|
|
base, _, rest = pattern.partition("/" if "/" in pattern else "\\")
|
|
glob = "/".join(part for part in re.split(r"[\\/]", rest) if part)
|
|
try:
|
|
hits.extend(path for path in _rule_path(base).glob(glob) if path.is_file())
|
|
except OSError:
|
|
continue
|
|
if hits:
|
|
newest = max(hits, key=lambda path: path.stat().st_mtime)
|
|
found.append((platform_name, newest.parent / subdir))
|
|
|
|
|
|
# The registry's registry rules: (os, platform, key, value, BIOS folder under
|
|
# the folder the value names). RetroBat.exe writes its own folder there on
|
|
# every start (retrobat-executable 702da66a, RetroBat/RetroBat/Program.cs:57
|
|
# and 99, RegistryTools.cs:15-50), wherever it was installed. The path rules
|
|
# only knew C:\RetroBat and the profile folder.
|
|
_REGISTRY_RULES: tuple[tuple[str, str, str, str, str], ...] = (
|
|
("windows", "retrobat", "HKCU\\SOFTWARE\\RetroBat", "LatestKnownInstallPath", "bios"),
|
|
)
|
|
|
|
|
|
def _read_registry(key: str, value: str) -> str | None:
|
|
"""A string value of the Windows registry, read natively or, under WSL,
|
|
through reg.exe. None when the key, the value or the means is missing."""
|
|
if os.name == "nt":
|
|
import winreg # noqa: PLC0415
|
|
|
|
hive, _, subkey = key.partition("\\")
|
|
roots = {"HKCU": winreg.HKEY_CURRENT_USER, "HKLM": winreg.HKEY_LOCAL_MACHINE}
|
|
try:
|
|
with winreg.OpenKey(roots[hive], subkey) as handle:
|
|
data, _kind = winreg.QueryValueEx(handle, value)
|
|
except (OSError, KeyError):
|
|
return None
|
|
return data.strip() if isinstance(data, str) and data.strip() else None
|
|
if not _WSL["active"]:
|
|
return None
|
|
try:
|
|
result = subprocess.run(
|
|
["reg.exe", "query", key, "/v", value],
|
|
capture_output=True, text=True, errors="replace", timeout=10,
|
|
stdin=subprocess.DEVNULL, cwd="/mnt/c" if Path("/mnt/c").is_dir() else None,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
match = re.search(
|
|
rf"^\s*{re.escape(value)}\s+REG_(?:EXPAND_)?SZ\s+(.+?)\s*$", result.stdout, re.M
|
|
)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def _detect_by_registry(os_type: str, found: list[tuple[str, Path]]) -> None:
|
|
"""Add a platform whose own program names its folder in the registry.
|
|
|
|
A folder since moved or removed is passed over, so the path rules still
|
|
get their turn.
|
|
"""
|
|
systems = ("windows",) if os_type in ("windows", "wsl") else ()
|
|
for rule_os, platform_name, key, value, subdir in _REGISTRY_RULES:
|
|
if rule_os not in systems or any(name == platform_name for name, _ in found):
|
|
continue
|
|
folder = _read_registry(key, value)
|
|
if not folder:
|
|
continue
|
|
base = _host_path(folder.rstrip("\\/") or folder)
|
|
if base.is_dir():
|
|
found.append((platform_name, base / subdir))
|
|
|
|
|
|
def _detect_by_path(os_type: str, found: list[tuple[str, Path]]) -> None:
|
|
"""Add what a registry path rule finds, for a platform not found yet."""
|
|
systems = ("linux", "windows") if os_type == "wsl" else (os_type,)
|
|
for rule_os, platform_name, path, bios in _PATH_RULES:
|
|
if rule_os not in systems or any(name == platform_name for name, _ in found):
|
|
continue
|
|
if _rule_path(path).exists():
|
|
found.append((platform_name, _rule_path(bios or path)))
|
|
|
|
|
|
def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
|
|
"""Detect installed emulator platforms and their BIOS directories."""
|
|
found: list[tuple[str, Path]] = []
|
|
|
|
if os_type == "android":
|
|
found.extend(_detect_android())
|
|
for line in android_unreachable_folders():
|
|
print(line, file=sys.stderr)
|
|
|
|
if os_type in ("linux", "wsl"):
|
|
found.extend(_detect_embedded())
|
|
|
|
if os_type in ("linux", "wsl", "darwin"):
|
|
# EmuDeck on Linux and macOS: functions/all.sh:3-4 and 24 (EmuDeck
|
|
# dddb3022) reads $emudeckFolder/settings.sh on both, and vars.sh:2
|
|
# sets emudeckFolder to $HOME/.config/EmuDeck.
|
|
emudeck_settings = Path.home() / ".config" / "EmuDeck" / "settings.sh"
|
|
if emudeck_settings.exists():
|
|
emu_path = _parse_bash_var(emudeck_settings, "emulationPath")
|
|
if emu_path:
|
|
found.append(("emudeck", Path(emu_path) / "bios"))
|
|
|
|
if os_type in ("linux", "wsl"):
|
|
home = Path.home()
|
|
# RetroDECK: retrodeck.json since the cfg-to-json migration, which
|
|
# renames the old retrodeck.cfg to retrodeck.bak (global.sh:149-153)
|
|
retrodeck_conf_dir = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck"
|
|
retrodeck_json = retrodeck_conf_dir / "retrodeck.json"
|
|
retrodeck_cfg = retrodeck_conf_dir / "retrodeck.cfg"
|
|
if retrodeck_json.exists():
|
|
rd_home = _parse_json_path(retrodeck_json, "paths", "rd_home_path")
|
|
if not rd_home:
|
|
# A home moved to an SD card lives only in this file: the
|
|
# default is a guess, and the user is told so.
|
|
print(
|
|
f"Warning: could not read paths.rd_home_path from "
|
|
f"{retrodeck_json}; assuming {home / 'retrodeck'}",
|
|
file=sys.stderr,
|
|
)
|
|
rd_root = Path(rd_home) if rd_home else home / "retrodeck"
|
|
_record_moved_folders(rd_root, retrodeck_json)
|
|
found.append(("retrodeck", rd_root))
|
|
elif retrodeck_cfg.exists():
|
|
rd_home = _parse_bash_var(retrodeck_cfg, "rdhome")
|
|
found.append(("retrodeck", Path(rd_home) if rd_home else home / "retrodeck"))
|
|
|
|
# RetroArch Flatpak
|
|
flatpak_cfg = home / ".var" / "app" / "org.libretro.RetroArch" / "config" / "retroarch" / "retroarch.cfg"
|
|
ra_dir = _parse_retroarch_system_dir(flatpak_cfg)
|
|
if ra_dir:
|
|
found.append(("retroarch", ra_dir))
|
|
|
|
# RetroArch Snap
|
|
snap_cfg = home / "snap" / "retroarch" / "current" / ".config" / "retroarch" / "retroarch.cfg"
|
|
ra_dir = _parse_retroarch_system_dir(snap_cfg)
|
|
if ra_dir:
|
|
found.append(("retroarch", ra_dir))
|
|
|
|
# RetroArch native
|
|
native_cfg = home / ".config" / "retroarch" / "retroarch.cfg"
|
|
ra_dir = _parse_retroarch_system_dir(native_cfg)
|
|
if ra_dir:
|
|
found.append(("retroarch", ra_dir))
|
|
|
|
if os_type == "darwin":
|
|
# open_default_config_file reads <Application Support>/RetroArch/config
|
|
# (configuration.c). A value left at default is the documents folder,
|
|
# or Application Support itself for a portable install
|
|
# (platform_darwin.m). The config beside it is where older builds
|
|
# kept it.
|
|
support = Path.home() / "Library" / "Application Support" / "RetroArch"
|
|
for mac_cfg in (support / "config" / "retroarch.cfg", support / "retroarch.cfg"):
|
|
ra_dir = _parse_retroarch_system_dir(mac_cfg, _macos_retroarch_base())
|
|
if ra_dir:
|
|
found.append(("retroarch", ra_dir))
|
|
break
|
|
|
|
if os_type in ("windows", "wsl"):
|
|
emudeck = _emudeck_windows_bios()
|
|
if emudeck is not None:
|
|
found.append(("emudeck", emudeck))
|
|
|
|
# RetroArch Windows: open_default_config_file reads retroarch.cfg
|
|
# beside the exe, then under %APPDATA%\RetroArch, and a value left at
|
|
# default is <exe folder>\system (configuration.c, platform_win32.c).
|
|
appdata = os.environ.get("APPDATA", "")
|
|
appdata_cfg = Path(appdata) / "RetroArch" / "retroarch.cfg" if appdata else None
|
|
exe_dirs = _windows_retroarch_dirs() if os_type == "windows" else []
|
|
for exe_dir in exe_dirs:
|
|
ra_dir = _parse_retroarch_system_dir(exe_dir / "retroarch.cfg", exe_dir)
|
|
if ra_dir is None and appdata_cfg is not None:
|
|
ra_dir = _parse_retroarch_system_dir(appdata_cfg, exe_dir)
|
|
found.append(("retroarch", ra_dir or exe_dir / "system"))
|
|
break
|
|
if not exe_dirs and appdata_cfg is not None:
|
|
ra_dir = _parse_retroarch_system_dir(appdata_cfg)
|
|
if ra_dir:
|
|
found.append(("retroarch", ra_dir))
|
|
|
|
# RetroArch from Steam: a portable install under the default library
|
|
for var in ("ProgramFiles(x86)", "ProgramFiles"):
|
|
program_files = os.environ.get(var, "")
|
|
if not program_files:
|
|
continue
|
|
steam_cfg = (
|
|
Path(program_files) / "Steam" / "steamapps" / "common"
|
|
/ "RetroArch" / "retroarch.cfg"
|
|
)
|
|
ra_dir = _parse_retroarch_system_dir(steam_cfg)
|
|
if ra_dir and not any(name == "retroarch" for name, _ in found):
|
|
found.append(("retroarch", ra_dir))
|
|
break
|
|
|
|
# The UWP/MSIX package (sideloaded, or Xbox in dev mode)
|
|
if not any(name == "retroarch" for name, _ in found):
|
|
uwp = _retroarch_uwp_system_dir()
|
|
if uwp is not None:
|
|
found.append(("retroarch", uwp))
|
|
|
|
# Portable RetroArch referenced by LaunchBox
|
|
lb_root = launchbox_root(os_type)
|
|
if lb_root and not any(name == "retroarch" for name, _ in found):
|
|
system_dir = _launchbox_retroarch_system_dir(
|
|
lb_root / "Data" / "Emulators.xml"
|
|
)
|
|
if system_dir:
|
|
found.append(("retroarch", system_dir))
|
|
# BizHawk is a portable archive reading <EmuHawk folder>\Firmware
|
|
# (PathExtensions.cs DataDirectoryPath, PathEntryCollection "Firmware").
|
|
if lb_root and not any(name == "bizhawk" for name, _ in found):
|
|
emu_dir = launchbox_emulators(lb_root / "Data" / "Emulators.xml").get("emuhawk.exe")
|
|
if emu_dir is not None:
|
|
found.append(("bizhawk", emu_dir / "Firmware"))
|
|
|
|
if os_type in ("linux", "wsl", "darwin"):
|
|
bizhawk = _bizhawk_unix_firmware()
|
|
if bizhawk is not None and not any(name == "bizhawk" for name, _ in found):
|
|
found.append(("bizhawk", bizhawk))
|
|
|
|
_detect_by_executable(os_type, found)
|
|
_detect_by_registry(os_type, found)
|
|
_detect_by_path(os_type, found)
|
|
# An embedded system's config read again as the home folder's one (HOME is
|
|
# /storage on the ELEC family) is the same folder.
|
|
return list(dict.fromkeys(found))
|
|
|
|
|
|
def _emudeck_windows_bios() -> Path | None:
|
|
"""The BIOS folder of EmuDeck's Windows edition, as its scripts find it.
|
|
|
|
functions/all.ps1:4-14 (emudeck-we 037460ef) loads
|
|
%APPDATA%\\EmuDeck\\settings.ps1, else %USERPROFILE%\\EmuDeck\\settings.ps1,
|
|
and takes C:\\Emulation when neither sets $emulationPath. A network
|
|
install sets it through a drive mapped to $VolName in that session
|
|
(JSONtoPS1.ps1:166-172), which is the share itself here.
|
|
"""
|
|
candidates = [Path(os.environ[var]) / "EmuDeck" / "settings.ps1"
|
|
for var in ("APPDATA", "USERPROFILE") if os.environ.get(var)]
|
|
settings = next((path for path in candidates if path.is_file()), None)
|
|
if settings is None:
|
|
return None
|
|
emu_path = _parse_ps1_var(settings, "$emulationPath")
|
|
if emu_path and ("$" in emu_path or "+" in emu_path):
|
|
share = _parse_ps1_var(settings, "$VolName")
|
|
emu_path = f"{share.rstrip(chr(92))}\\Emulation" if share else None
|
|
return _host_path(emu_path or "C:\\Emulation") / "bios"
|
|
|
|
|
|
# Identity Name in pkg/msvc-uwp/RetroArch-msvcUWP/Package.appxmanifest.
|
|
RETROARCH_UWP_ID = "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8"
|
|
|
|
|
|
def _retroarch_uwp_system_dir() -> Path | None:
|
|
"""System directory of the RetroArch UWP package, if one is installed.
|
|
|
|
The package writes into its LocalState folder: uwp_main.cpp makes it the
|
|
home directory, configuration.c reads retroarch.cfg there, and
|
|
platform_uwp.c defaults the system directory to ~\\system. A `~` in the
|
|
config is therefore LocalState, not the user's profile.
|
|
"""
|
|
local = os.environ.get("LOCALAPPDATA", "").strip()
|
|
if not local:
|
|
return None
|
|
states = sorted(Path(local, "Packages").glob(f"{RETROARCH_UWP_ID}_*/LocalState"))
|
|
if not states:
|
|
return None
|
|
state = states[0]
|
|
cfg = state / "retroarch.cfg"
|
|
try:
|
|
for line in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
name, sep, value = line.strip().partition("=")
|
|
if sep and name.strip() == "system_directory":
|
|
value = value.strip().strip('"').strip("'")
|
|
if value.startswith("~"):
|
|
return state / value[1:].lstrip("\\/")
|
|
break
|
|
except OSError:
|
|
return state / "system"
|
|
return _retroarch_cfg_dir(cfg, "system_directory", state, "system") or state / "system"
|
|
|
|
|
|
def _bizhawk_unix_firmware() -> Path | None:
|
|
"""BizHawk's Firmware folder on Unix, where its data home says.
|
|
|
|
PathExtensions.cs reads $BIZHAWK_DATA_HOME before the executable's
|
|
folder, and the Nix package sets it to
|
|
$XDG_DATA_HOME/emuhawk-monort-<version> (Dist/launch-scripts.nix).
|
|
"""
|
|
data_home = os.environ.get("BIZHAWK_DATA_HOME", "").strip()
|
|
if data_home and Path(data_home).is_dir():
|
|
return Path(data_home) / "Firmware"
|
|
xdg = os.environ.get("XDG_DATA_HOME", "").strip() or str(Path.home() / ".local" / "share")
|
|
homes = sorted(Path(xdg).glob("emuhawk-monort-*"), key=lambda p: p.stat().st_mtime)
|
|
return homes[-1] / "Firmware" if homes else None
|
|
|
|
|
|
# A libretro core as RetroArch loads it: <name>_libretro.so|dll|dylib, and
|
|
# <name>_libretro_android.so on Android.
|
|
_CORE_FILE_RE = re.compile(
|
|
r"^(?P<name>.+?)_libretro(?:_android)?\.(?:so|dll|dylib)$", re.IGNORECASE
|
|
)
|
|
CORE_PLATFORMS = ("retroarch", "lakka", "retropie")
|
|
# Where these systems install cores whatever retroarch.cfg says: RetroPie
|
|
# gives each package its own folder, Lakka keeps the image's cores and the
|
|
# ones the online updater fetches apart.
|
|
_FIXED_CORE_DIRS = {
|
|
"retropie": (Path("/opt/retropie/libretrocores"),),
|
|
"lakka": (Path("/usr/lib/libretro"), Path("/storage/cores"), Path("/tmp/cores")),
|
|
}
|
|
|
|
|
|
def core_dirs(plat: str, bios_path: Path) -> list[Path]:
|
|
"""The directories the RetroArch writing to bios_path loads cores from.
|
|
|
|
The config detection read for this system directory names them in
|
|
libretro_directory, read here when the run was given --dest, as the
|
|
interface's runs all are. Without one, a retroarch.cfg beside the system
|
|
directory is the portable layout. Unset, the setting is cores/ under
|
|
RetroArch's own directory.
|
|
"""
|
|
found = _retroarch_config_for(bios_path)
|
|
if found is None and (bios_path.parent / "retroarch.cfg").is_file():
|
|
found = (bios_path.parent / "retroarch.cfg", bios_path.parent)
|
|
dirs: list[Path] = []
|
|
if found is not None:
|
|
cfg, app_dir = found
|
|
dirs.append(
|
|
_retroarch_cfg_dir(cfg, "libretro_directory", app_dir, "cores")
|
|
or app_dir / "cores"
|
|
)
|
|
dirs.extend(_FIXED_CORE_DIRS.get(plat, ()))
|
|
return list(dict.fromkeys(dirs))
|
|
|
|
|
|
def keeps_case(directory: Path) -> bool:
|
|
"""Whether names differing only by case are distinct files there.
|
|
|
|
Probed on the nearest existing directory, since the filesystem decides,
|
|
not the operating system: an ext4 drive mounted on macOS keeps case and
|
|
a casefolded ext4 directory folds it. Unprobeable: the system's default.
|
|
"""
|
|
probe_dir = directory
|
|
while not probe_dir.is_dir() and probe_dir != probe_dir.parent:
|
|
probe_dir = probe_dir.parent
|
|
try:
|
|
handle, name = tempfile.mkstemp(prefix=".retrobios-case-", dir=str(probe_dir))
|
|
except OSError:
|
|
return sys.platform not in ("win32", "darwin")
|
|
os.close(handle)
|
|
try:
|
|
twin = os.path.join(os.path.dirname(name), os.path.basename(name).upper())
|
|
return not os.path.exists(twin)
|
|
finally:
|
|
os.unlink(name)
|
|
|
|
|
|
def fold_case_twins(files: list[dict]) -> tuple[list[dict], list[str]]:
|
|
"""One file per name where the directory folds case, the others named.
|
|
|
|
The first in manifest order stays, as the pack's own order places it.
|
|
"""
|
|
kept: list[dict] = []
|
|
seen: set[str] = set()
|
|
folded: list[str] = []
|
|
for entry in files:
|
|
key = str(entry.get("dest", "")).casefold()
|
|
if key in seen:
|
|
folded.append(str(entry.get("dest", "")))
|
|
continue
|
|
seen.add(key)
|
|
kept.append(entry)
|
|
return kept, folded
|
|
|
|
|
|
def installed_cores(dirs: list[Path]) -> list[str]:
|
|
"""The cores present in these directories, by the name the buildbot uses.
|
|
|
|
A folder inside is looked into once, which is how RetroPie installs
|
|
each package. An unreadable directory holds nothing that can be seen.
|
|
"""
|
|
names: set[str] = set()
|
|
for directory in dirs:
|
|
try:
|
|
children = list(directory.iterdir())
|
|
except OSError:
|
|
continue
|
|
for child in children:
|
|
candidates = [child]
|
|
if child.is_dir():
|
|
try:
|
|
candidates = list(child.iterdir())
|
|
except OSError:
|
|
continue
|
|
for path in candidates:
|
|
match = _CORE_FILE_RE.match(path.name)
|
|
if match and path.is_file():
|
|
names.add(match.group("name"))
|
|
return sorted(names)
|
|
|
|
|
|
def display_name(plat: str, manifest: dict | None = None) -> str:
|
|
"""A platform's name as the frontend writes it, the manifest's when it has one."""
|
|
named = (manifest or {}).get("display_name")
|
|
if isinstance(named, str) and named.strip().isprintable() and 0 < len(named.strip()) <= 40:
|
|
return named.strip()
|
|
return PLATFORM_NAMES.get(plat, plat)
|
|
|
|
|
|
def _name_key(name: str) -> str:
|
|
"""Letters and digits only: MiSTer FPGA, misterfpga and Mister-FPGA are one name."""
|
|
return re.sub(r"[^a-z0-9]", "", name.lower())
|
|
|
|
|
|
# A list this short is printed whole rather than pointed at.
|
|
SHORT_LIST = 12
|
|
|
|
|
|
# Names people type for a system, each with its id. Only ever suggested:
|
|
# --system gba, psx and ps1 were refused with no hint on RetroArch, whose
|
|
# list names no system that way, and taking one in silence could install
|
|
# another console than the one meant.
|
|
SYSTEM_SHORT_NAMES = {
|
|
**dict.fromkeys(("psx", "ps1", "ps", "playstation1"), "sony-playstation"),
|
|
"ps2": "sony-playstation-2", "ps3": "sony-playstation-3", "psp": "sony-psp",
|
|
**dict.fromkeys(("vita", "psvita"), "sony-playstation-vita"),
|
|
"gb": "nintendo-gb", "gbc": "nintendo-gbc", "gba": "nintendo-gba",
|
|
**dict.fromkeys(("nes", "famicom", "fc"), "nintendo-nes"), "fds": "nintendo-fds",
|
|
**dict.fromkeys(("snes", "sfc", "superfamicom"), "nintendo-snes"),
|
|
"n64": "nintendo-64", "n64dd": "nintendo-64dd", "sgb": "nintendo-sgb",
|
|
**dict.fromkeys(("nds", "ds"), "nintendo-ds"), "dsi": "nintendo-dsi",
|
|
"3ds": "nintendo-3ds", "wiiu": "nintendo-wii-u", "switch": "nintendo-switch",
|
|
**dict.fromkeys(("gc", "ngc", "gcn", "gamecube"), "nintendo-gamecube"),
|
|
"pokemini": "nintendo-pokemon-mini",
|
|
**dict.fromkeys(("md", "gen", "genesis", "megadrive"), "sega-mega-drive"),
|
|
**dict.fromkeys(("scd", "segacd", "megacd"), "sega-mega-cd"),
|
|
"32x": "sega-32x", **dict.fromkeys(("sms", "mastersystem"), "sega-master-system"),
|
|
**dict.fromkeys(("gg", "gamegear"), "sega-game-gear"),
|
|
**dict.fromkeys(("ss", "saturn"), "sega-saturn"),
|
|
**dict.fromkeys(("dc", "dreamcast"), "sega-dreamcast"),
|
|
**dict.fromkeys(("naomi", "atomiswave"), "sega-dreamcast-arcade"),
|
|
**dict.fromkeys(("pce", "tg16", "turbografx", "turbografx16", "pcengine",
|
|
"supergrafx"), "nec-pc-engine"),
|
|
**dict.fromkeys(("pcecd", "tgcd", "turbografxcd"), "nec-pc-engine-cd"),
|
|
"pcfx": "nec-pc-fx",
|
|
**dict.fromkeys(("neogeo", "mvs", "aes"), "snk-neogeo"),
|
|
**dict.fromkeys(("neocd", "neogeocd"), "snk-neogeo-cd"),
|
|
**dict.fromkeys(("msx", "msx2"), "microsoft-msx"),
|
|
"c64": "commodore-c64", "c128": "commodore-c128",
|
|
**dict.fromkeys(("amiga", "cd32"), "commodore-amiga"),
|
|
"x68k": "sharp-x68000", **dict.fromkeys(("jag", "jaguar"), "atari-jaguar"),
|
|
**dict.fromkeys(("st", "atarist"), "atari-st"), "a800": "atari-400-800",
|
|
**dict.fromkeys(("coleco", "colecovision"), "coleco-colecovision"),
|
|
**dict.fromkeys(("intv", "intellivision"), "mattel-intellivision"),
|
|
"o2": "magnavox-odyssey2", "channelf": "fairchild-channel-f",
|
|
**dict.fromkeys(("zx", "spectrum", "zxspectrum"), "sinclair-zx-spectrum"),
|
|
"cpc": "amstrad-cpc", "bbc": "acorn-bbc-micro", "apple2": "apple-ii",
|
|
}
|
|
|
|
# A system some lists file under another id, by the file that stands for it.
|
|
# The Neo Geo card offers --system snk-neogeo, and RetroArch's list keeps
|
|
# neogeo.zip under arcade: the run suggests arcade, never takes it.
|
|
SYSTEM_KEY_FILES = {"snk-neogeo": "neogeo.zip"}
|
|
|
|
|
|
def key_file_holders(files: list[dict], omitted: list[dict]) -> dict[str, list[str]]:
|
|
"""The systems a list files each SYSTEM_KEY_FILES file under, for the
|
|
systems the list does not name itself."""
|
|
entries = files + omitted
|
|
known = {system for entry in entries for system in entry_systems(entry)}
|
|
holders: dict[str, list[str]] = {}
|
|
for system, name in SYSTEM_KEY_FILES.items():
|
|
if system in known:
|
|
continue
|
|
held = sorted({
|
|
holder for entry in entries
|
|
if PurePosixPath(str(entry.get("dest", ""))).name.lower() == name
|
|
for holder in entry_systems(entry)
|
|
})
|
|
if held:
|
|
holders[system] = held
|
|
return holders
|
|
|
|
|
|
def did_you_mean(
|
|
typed: list[str], known: list[str], listing: str,
|
|
aliases: dict[str, str] | None = None, inside_from: int = 4,
|
|
meant: dict[str, list[str]] | None = None,
|
|
) -> str:
|
|
"""The closest known names to what was typed, and the option listing them all.
|
|
|
|
A typo was answered with every name the platform has, up to 400 on one
|
|
line, with no hint that playstation is sony-playstation. An alias close
|
|
to what was typed suggests the name it stands for. A name holding what
|
|
was typed, inside_from letters or more, is suggested too. meant gives,
|
|
by the letters and digits of a typed name, the names it stands for
|
|
here, suggested instead of the close ones.
|
|
"""
|
|
import difflib # noqa: PLC0415
|
|
|
|
by_key: dict[str, str] = {}
|
|
for name in known:
|
|
by_key.setdefault(_name_key(name), name)
|
|
for alias, name in (aliases or {}).items():
|
|
by_key.setdefault(_name_key(alias), name)
|
|
close: list[str] = []
|
|
for word in typed:
|
|
key = _name_key(word)
|
|
if not key:
|
|
continue
|
|
if meant and key in meant:
|
|
close += [name for name in meant[key] if name not in close]
|
|
continue
|
|
matches = difflib.get_close_matches(key, list(by_key), n=3, cutoff=0.6)
|
|
if len(key) >= inside_from:
|
|
matches += sorted(k for k in by_key if key in k and k not in matches)
|
|
for k in matches:
|
|
# Two aliases of one system (psx, ps1) name it once.
|
|
if by_key[k] not in close:
|
|
close.append(by_key[k])
|
|
if close:
|
|
return f"Did you mean: {', '.join(close[:3])}? {listing} lists all {len(known)}."
|
|
if len(known) <= SHORT_LIST:
|
|
return f"Known: {', '.join(known)}."
|
|
return f"{listing} lists all {len(known)}."
|
|
|
|
|
|
def normalize_platform(name: str) -> str:
|
|
"""Return the canonical platform id for user-supplied input.
|
|
|
|
The site writes MiSTer FPGA and RetroArch: letters and digits decide,
|
|
as they do for verify.py and generate_pack.py.
|
|
"""
|
|
key = _name_key(name)
|
|
for plat in AVAILABLE_PLATFORMS:
|
|
if key and key in (_name_key(plat), _name_key(display_name(plat))):
|
|
return plat
|
|
# --list-platforms prints `muOS: retroarch`, and --platform muos was
|
|
# refused: a system named there stands for the list it gets.
|
|
for rule in _EMBEDDED_RULES:
|
|
if key and rule.manifest and key == _name_key(rule.name):
|
|
print(f" {rule.name} gets the {display_name(rule.manifest)} file list: "
|
|
f"--platform {rule.manifest}.")
|
|
return rule.manifest
|
|
print(f"Unknown platform '{name.strip()}'. "
|
|
+ did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"),
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _read_limited_json(
|
|
response: http.client.HTTPResponse, limit: int, label: str
|
|
) -> object:
|
|
"""Read a bounded UTF-8 JSON response.
|
|
|
|
A body shorter than its Content-Length is a dropped connection, raised
|
|
as one so the caller retries: parsed, the cut read as an invalid file
|
|
list and the run ended without the network advice.
|
|
"""
|
|
raw_length = response.headers.get("Content-Length") if response.headers else None
|
|
expected: int | None = None
|
|
if raw_length:
|
|
try:
|
|
expected = int(raw_length)
|
|
except ValueError:
|
|
expected = None
|
|
if expected is not None and expected > limit:
|
|
raise ValueError(f"{label} exceeds {limit} bytes")
|
|
try:
|
|
payload = response.read(limit + 1)
|
|
except http.client.IncompleteRead as exc:
|
|
raise ConnectionError(
|
|
f"{label} cut after {len(exc.partial)} bytes"
|
|
) from exc
|
|
if len(payload) > limit:
|
|
raise ValueError(f"{label} exceeds {limit} bytes")
|
|
if expected is not None and len(payload) < expected:
|
|
raise ConnectionError(f"{label} cut after {len(payload)} of {expected} bytes")
|
|
try:
|
|
return json.loads(payload.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValueError(f"invalid {label}: {exc}") from exc
|
|
|
|
|
|
def _safe_relative_path(value: object, field: str) -> PurePosixPath:
|
|
"""Validate a manifest-controlled relative POSIX path."""
|
|
if not isinstance(value, str) or not value or len(value) > 1024:
|
|
raise ValueError(f"invalid {field}")
|
|
if (
|
|
"\\" in value
|
|
or "\x00" in value
|
|
or "//" in value
|
|
or value.endswith("/")
|
|
or re.match(r"^[A-Za-z]:", value)
|
|
):
|
|
raise ValueError(f"unsafe {field}: {value!r}")
|
|
path = PurePosixPath(value)
|
|
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
|
|
raise ValueError(f"unsafe {field}: {value!r}")
|
|
return path
|
|
|
|
|
|
def _within(candidate: Path, root: Path) -> bool:
|
|
"""Whether *candidate* stays under *root* once both are resolved."""
|
|
try:
|
|
(root / candidate.name).resolve().relative_to(root)
|
|
except (OSError, ValueError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _safe_target_dir(value: object, field: str) -> str:
|
|
"""Validate a manifest-controlled standalone-copy directory.
|
|
|
|
These name emulator install directories, so unlike a BIOS destination they
|
|
are legitimately absolute and outside the BIOS tree. What they must never
|
|
do is climb: a '..' anywhere turns "copy next to the emulator" into "write
|
|
wherever the manifest likes".
|
|
"""
|
|
if not isinstance(value, str) or not value or len(value) > 2048:
|
|
raise ValueError(f"invalid {field}")
|
|
if "\x00" in value:
|
|
raise ValueError(f"unsafe {field}: {value!r}")
|
|
parts = re.split(r"[\\/]", value)
|
|
if any(part == ".." for part in parts):
|
|
raise ValueError(f"unsafe {field}: {value!r}")
|
|
return value
|
|
|
|
|
|
# A platform whose folders move one by one: root -> {folder under the root:
|
|
# where it is now}. RetroDECK's per-folder move (RetroDECK 7786a849,
|
|
# functions/api_data_processing.sh:913-935) records each new place in
|
|
# retrodeck.json paths.<folder>_path (config/retrodeck/retrodeck.json:3-23),
|
|
# and its manifest places files under bios/ and roms/ of the home folder.
|
|
_MOVED_FOLDERS: dict[Path, dict[str, Path]] = {}
|
|
|
|
|
|
def _record_moved_folders(root: Path, retrodeck_json: Path) -> None:
|
|
"""Each RetroDECK folder that is no longer <home>/<name>."""
|
|
try:
|
|
paths = json.loads(retrodeck_json.read_text(encoding="utf-8", errors="replace"))["paths"]
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
return
|
|
if not isinstance(paths, dict):
|
|
return
|
|
moved = {}
|
|
for key, value in paths.items():
|
|
name = key[:-len("_path")] if key.endswith("_path") else ""
|
|
if name and name != "rd_home" and isinstance(value, str) and value.strip():
|
|
place = Path(value.strip())
|
|
if place != root / name:
|
|
moved[name] = place
|
|
if moved:
|
|
_MOVED_FOLDERS[root] = moved
|
|
|
|
|
|
def _destination_path(root: Path, value: object) -> Path:
|
|
"""Place a manifest destination below *root*.
|
|
|
|
The manifest cannot climb: _safe_relative_path refuses absolute paths,
|
|
drive letters and '..'. Symbolic links met under the root are the
|
|
user's own layout and are followed. EmuDeck links bios/shadps4/sys_modules
|
|
into shadPS4's data directory, and the file has to land where the
|
|
emulator reads it.
|
|
"""
|
|
relative = _safe_relative_path(value, "dest")
|
|
moved = _MOVED_FOLDERS.get(root, {})
|
|
if len(relative.parts) > 1 and relative.parts[0] in moved:
|
|
return moved[relative.parts[0]].resolve() / Path(*relative.parts[1:])
|
|
return root.resolve() / Path(*relative.parts)
|
|
|
|
|
|
class SavePlace(NamedTuple):
|
|
"""The save directory a frontend's config names, whether it hands each
|
|
core a folder of its own below it (RetroArch's sort_savefiles_enable,
|
|
runloop.c runloop_path_set_redirect), and whether the file list's dests
|
|
hold that folder (its save_directory.by_core)."""
|
|
|
|
directory: Path
|
|
by_core: bool
|
|
listed_by_core: bool = True
|
|
|
|
|
|
# BIOS folder -> where the files read below its frontend's save directory go,
|
|
# set once the save directory is known (save_directory_for).
|
|
_SAVE_DIRS: dict[Path, SavePlace] = {}
|
|
|
|
|
|
def _entry_place(root: Path, entry: dict) -> tuple[Path, object]:
|
|
"""The folder an entry's dest is relative to, and the dest.
|
|
|
|
An entry with root: save is relative to the save directory the frontend
|
|
hands the core, below the BIOS folder's frontend save directory and the
|
|
core's own folder where the frontend sorts saves by core. Any other is
|
|
relative to the BIOS folder.
|
|
"""
|
|
if entry.get("root") != "save":
|
|
return root, entry.get("dest")
|
|
place = _SAVE_DIRS.get(root)
|
|
if place is None:
|
|
raise ValueError(f"no save directory known for {entry.get('dest')}")
|
|
dest = entry.get("dest")
|
|
folder = entry.get("core_folder")
|
|
# No core_folder: the core reads the same place whether the frontend
|
|
# sorts saves by core or not (an empty library_name, or Cemu adding its
|
|
# own folder only where the directory is not named after it).
|
|
if folder and isinstance(dest, str) and place.by_core != place.listed_by_core:
|
|
if place.listed_by_core:
|
|
dest = dest.split("/", 1)[1] if "/" in dest else dest
|
|
else:
|
|
dest = f"{folder}/{dest}"
|
|
return place.directory, dest
|
|
|
|
|
|
def _entry_path(root: Path, entry: dict) -> Path:
|
|
"""Where an entry is written, as _entry_place places it."""
|
|
return _destination_path(*_entry_place(root, entry))
|
|
|
|
|
|
def _entry_key(entry: dict) -> str:
|
|
"""What tells one entry of the list from another: its dest, below the
|
|
BIOS folder or below the save directory. KNULLI lists its own
|
|
fbneo/hiscore.dat in the BIOS tree and FB Alpha 2012's below the save
|
|
directory, two files under one dest. NUL never appears in a dest."""
|
|
dest = str(entry.get("dest"))
|
|
return f"save\0{dest}" if entry.get("root") == "save" else dest
|
|
|
|
|
|
def _check_root(entry: dict, dest: str) -> None:
|
|
"""root is absent or save, and core_folder one folder name of a save entry."""
|
|
root = entry.get("root")
|
|
if root is not None and root != "save":
|
|
raise ValueError(f"invalid root for {dest}")
|
|
folder = entry.get("core_folder")
|
|
if folder is None:
|
|
return
|
|
if root != "save":
|
|
raise ValueError(f"core_folder outside root: save for {dest}")
|
|
if len(_safe_relative_path(folder, f"core_folder of {dest}").parts) != 1:
|
|
raise ValueError(f"core_folder must be one folder name for {dest}")
|
|
|
|
|
|
def _check_save_directory(data: dict) -> None:
|
|
"""The manifest's save_directory, which every root: save entry needs."""
|
|
layout = data.get("save_directory")
|
|
entries = [
|
|
entry for field in ("files", "case_variants", "omitted_files")
|
|
for entry in data.get(field) or [] if isinstance(entry, dict)
|
|
]
|
|
if layout is None:
|
|
# An omission may name one: the list says the file exists and why
|
|
# this platform cannot place it.
|
|
installable = [
|
|
entry for field in ("files", "case_variants")
|
|
for entry in data.get(field) or [] if isinstance(entry, dict)
|
|
]
|
|
if any(entry.get("root") == "save" for entry in installable):
|
|
raise ValueError("entries below the save directory and no save_directory")
|
|
return
|
|
if (
|
|
not isinstance(layout, dict)
|
|
or set(layout) - {"by_system"} != {"folder", "by_core"}
|
|
or not isinstance(layout["by_core"], bool)
|
|
or layout.get("by_system", True) is not True
|
|
or not _safe_relative_path(layout["folder"], "save_directory.folder").parts
|
|
):
|
|
raise ValueError("invalid save_directory")
|
|
if layout["by_core"]:
|
|
# The core's folder heads each dest it names: dropped, never guessed,
|
|
# where the frontend does not sort saves by core.
|
|
for entry in entries:
|
|
folder = entry.get("core_folder")
|
|
dest = entry.get("dest")
|
|
if folder and isinstance(dest, str) and dest.split("/", 1)[0] != folder:
|
|
raise ValueError(f"{dest} does not start with its core_folder {folder}")
|
|
|
|
|
|
def _check_tags(entry: dict, field: str, where: str) -> None:
|
|
"""A selection field is absent or a short list of short strings."""
|
|
value = entry.get(field)
|
|
if value is None:
|
|
return
|
|
if (
|
|
not isinstance(value, list)
|
|
or len(value) > MAX_SELECTION_TAGS
|
|
or not all(isinstance(tag, str) and 0 < len(tag) <= 256 for tag in value)
|
|
):
|
|
raise ValueError(f"invalid {field} for {where}")
|
|
|
|
|
|
# What an alternative replaces on the entry it stands in for.
|
|
_COPY_FIELDS = (
|
|
"sha1", "sha256", "size", "repo_path", "release_asset", "storage", "cores", "executable",
|
|
)
|
|
|
|
|
|
def _check_omission(entry: dict, dest: str) -> None:
|
|
"""The fields of a file left out: an omitted entry, or the claim of a core
|
|
with nothing held at a destination another core's copy serves."""
|
|
if not isinstance(entry.get("name"), str) or not entry["name"]:
|
|
raise ValueError(f"invalid omitted file name for {dest}")
|
|
if not isinstance(entry.get("system", ""), str):
|
|
raise ValueError(f"invalid omitted system for {dest}")
|
|
if not isinstance(entry.get("required"), bool):
|
|
raise ValueError(f"invalid omitted required flag for {dest}")
|
|
if entry.get("reason") not in OMISSION_REASONS:
|
|
raise ValueError(f"invalid omission reason for {dest}")
|
|
if "held" in entry and entry["held"] is not True:
|
|
raise ValueError(f"invalid omitted held flag for {dest}")
|
|
cores = entry.get("cores")
|
|
if cores is not None and (
|
|
not isinstance(cores, list)
|
|
or not all(isinstance(core, str) for core in cores)
|
|
):
|
|
raise ValueError(f"invalid omitted cores list for {dest}")
|
|
for field in SELECTION_FIELDS:
|
|
_check_tags(entry, field, dest)
|
|
|
|
|
|
def _check_download(entry: dict, where: str, dest: str) -> int:
|
|
"""The size, hashes, source and cores of one downloadable copy, and its size."""
|
|
size = entry.get("size")
|
|
if isinstance(size, bool) or not isinstance(size, int) or not (0 <= size <= MAX_DOWNLOAD_SIZE):
|
|
raise ValueError(f"invalid size for {dest}")
|
|
|
|
sha1 = entry.get("sha1", "")
|
|
sha256 = entry.get("sha256", "")
|
|
if sha1 and (not isinstance(sha1, str) or not _SHA1_RE.fullmatch(sha1)):
|
|
raise ValueError(f"invalid SHA1 for {dest}")
|
|
if sha256 and (
|
|
not isinstance(sha256, str) or not _SHA256_RE.fullmatch(sha256)
|
|
):
|
|
raise ValueError(f"invalid SHA256 for {dest}")
|
|
if not sha1 and not sha256:
|
|
raise ValueError(f"missing content hash for {dest}")
|
|
|
|
release_asset = entry.get("release_asset")
|
|
repo_path = entry.get("repo_path")
|
|
if release_asset:
|
|
asset = _safe_relative_path(release_asset, f"{where}.release_asset")
|
|
if len(asset.parts) != 1:
|
|
raise ValueError(f"release asset must be a basename: {release_asset}")
|
|
elif repo_path:
|
|
source = _safe_relative_path(repo_path, f"{where}.repo_path")
|
|
if source.parts[0] != "bios":
|
|
raise ValueError(f"repo_path outside bios/: {repo_path}")
|
|
else:
|
|
raise ValueError(f"no download source for {dest}")
|
|
|
|
cores = entry.get("cores")
|
|
if cores is not None and (
|
|
not isinstance(cores, list) or not all(isinstance(core, str) for core in cores)
|
|
):
|
|
raise ValueError(f"invalid cores list for {dest}")
|
|
if not isinstance(entry.get("executable", False), bool):
|
|
raise ValueError(f"invalid executable flag for {dest}")
|
|
return size
|
|
|
|
|
|
def _check_slot(entry: dict, where: str, dest: str) -> int:
|
|
"""The copies a platform file takes as cores and systems drop, and the largest size."""
|
|
choices = entry.get("slot_choices", [])
|
|
declared = entry.get("declared_choices")
|
|
if declared is None and not choices:
|
|
return 0
|
|
if (
|
|
entry.get("cores") is not None
|
|
or not isinstance(choices, list)
|
|
or not isinstance(declared, list)
|
|
or not declared
|
|
or len(choices) > MAX_SELECTION_TAGS
|
|
or len(declared) > MAX_SELECTION_TAGS
|
|
):
|
|
raise ValueError(f"invalid slot choices for {dest}")
|
|
size = 0
|
|
for index, choice in enumerate(declared):
|
|
systems = choice.get("systems") if isinstance(choice, dict) else None
|
|
if (
|
|
not isinstance(systems, list)
|
|
or not systems
|
|
or not all(isinstance(s, str) for s in systems)
|
|
or not set(choice) <= (set(_COPY_FIELDS) - {"cores"}) | {"systems"}
|
|
):
|
|
raise ValueError(f"invalid declared choice for {dest}")
|
|
size = max(size, _check_download(choice, f"{where}.declared_choices[{index}]", dest))
|
|
for index, choice in enumerate(choices):
|
|
if (
|
|
not isinstance(choice, dict)
|
|
or not choice.get("cores")
|
|
or not set(choice) <= set(_COPY_FIELDS)
|
|
):
|
|
raise ValueError(f"invalid slot choice for {dest}")
|
|
# A choice naming only cores leaves the platform's own answer.
|
|
if set(choice) != {"cores"}:
|
|
size = max(size, _check_download(
|
|
choice, f"{where}.slot_choices[{index}]", dest
|
|
))
|
|
return size
|
|
|
|
|
|
def _check_entry(
|
|
entry: object, where: str, seen_destinations: set[str]
|
|
) -> tuple[int, int]:
|
|
"""Check one file entry, return its size and the largest copy it may fetch."""
|
|
if not isinstance(entry, dict):
|
|
raise ValueError(f"{where} must be an object")
|
|
dest = str(_safe_relative_path(entry.get("dest"), f"{where}.dest"))
|
|
key = _entry_key({**entry, "dest": dest})
|
|
if key in seen_destinations:
|
|
raise ValueError(f"duplicate manifest destination: {dest}")
|
|
seen_destinations.add(key)
|
|
_check_root(entry, dest)
|
|
|
|
size = _check_download(entry, where, dest)
|
|
largest = size
|
|
alternatives = entry.get("alternatives", [])
|
|
if not isinstance(alternatives, list) or len(alternatives) > MAX_SELECTION_TAGS:
|
|
raise ValueError(f"invalid alternatives for {dest}")
|
|
for alt_index, alternative in enumerate(alternatives):
|
|
if (
|
|
not isinstance(alternative, dict)
|
|
or not alternative.get("cores")
|
|
or not set(alternative) <= set(_COPY_FIELDS)
|
|
):
|
|
raise ValueError(f"invalid alternative for {dest}")
|
|
# Narrowing can swap in any copy: the cap holds for the largest.
|
|
largest = max(largest, _check_download(
|
|
alternative, f"{where}.alternatives[{alt_index}]", dest
|
|
))
|
|
largest = max(largest, _check_slot(entry, where, dest))
|
|
absent = entry.get("absent_claims", [])
|
|
if not isinstance(absent, list) or len(absent) > MAX_SELECTION_TAGS:
|
|
raise ValueError(f"invalid absent claims for {dest}")
|
|
for claim in absent:
|
|
if not isinstance(claim, dict):
|
|
raise ValueError(f"invalid absent claim for {dest}")
|
|
_check_omission(claim, dest)
|
|
for field in (*SELECTION_FIELDS, "declared_in"):
|
|
_check_tags(entry, field, dest)
|
|
return size, largest
|
|
|
|
|
|
_SHA1_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
|
|
|
|
def _check_data_directory(entry: object, where: str) -> None:
|
|
"""A data directory: its source archive, its mapping, its file list."""
|
|
if not isinstance(entry, dict):
|
|
raise ValueError(f"{where} must be an object")
|
|
if not isinstance(entry.get("ref"), str) or not entry["ref"]:
|
|
raise ValueError(f"{where}.ref is missing")
|
|
if entry.get("dest", "") != "":
|
|
_safe_relative_path(entry.get("dest"), f"{where}.dest")
|
|
url = entry.get("url")
|
|
parsed = urllib.parse.urlparse(url if isinstance(url, str) else "")
|
|
if not (parsed.scheme == "https" or (
|
|
parsed.scheme == "http" and (parsed.hostname or "") in _LOOPBACK_HOSTS
|
|
)):
|
|
raise ValueError(f"{where}.url must use HTTPS")
|
|
kind = entry.get("type")
|
|
if kind not in ("zip", "tarball"):
|
|
raise ValueError(f"{where}.type must be zip or tarball")
|
|
strip = entry.get("strip_components", 0)
|
|
if not isinstance(strip, int) or isinstance(strip, bool) or not 0 <= strip <= 16:
|
|
raise ValueError(f"{where}.strip_components is invalid")
|
|
if kind == "tarball" and (
|
|
not isinstance(entry.get("source_path"), str) or not entry["source_path"]
|
|
):
|
|
raise ValueError(f"{where}.source_path is missing")
|
|
for field in ("exclude", "systems"):
|
|
values = entry.get(field, [])
|
|
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
|
|
raise ValueError(f"{where}.{field} is invalid")
|
|
files = entry.get("files")
|
|
if not isinstance(files, dict) or not files or len(files) > MAX_MANIFEST_FILES:
|
|
raise ValueError(f"{where}.files is invalid")
|
|
for rel, meta in files.items():
|
|
_safe_relative_path(rel, f"{where}.files")
|
|
if (
|
|
not isinstance(meta, dict)
|
|
or not isinstance(meta.get("sha1"), str)
|
|
or not _SHA1_RE.match(meta["sha1"])
|
|
or not isinstance(meta.get("size"), int)
|
|
or not 0 <= meta["size"] <= MAX_DOWNLOAD_SIZE
|
|
):
|
|
raise ValueError(f"{where}.files[{rel!r}] is invalid")
|
|
|
|
|
|
def _validate_manifest(data: object, plat: str) -> dict:
|
|
"""Validate the untrusted install-manifest boundary using stdlib only."""
|
|
if not isinstance(data, dict):
|
|
raise ValueError("manifest root must be an object")
|
|
if data.get("manifest_version") not in (1, 2):
|
|
raise ValueError("unsupported manifest_version")
|
|
if data.get("platform") != plat:
|
|
raise ValueError("manifest platform does not match request")
|
|
files = data.get("files")
|
|
if not isinstance(files, list) or len(files) > MAX_MANIFEST_FILES:
|
|
raise ValueError("invalid manifest files list")
|
|
|
|
seen_destinations: set[str] = set()
|
|
total_size = 0
|
|
largest_total = 0
|
|
for index, entry in enumerate(files):
|
|
size, largest = _check_entry(entry, f"files[{index}]", seen_destinations)
|
|
total_size += size
|
|
largest_total += largest
|
|
if largest_total > MAX_TOTAL_DOWNLOAD_SIZE:
|
|
raise ValueError("manifest total size exceeds safety limit")
|
|
variants = data.get("case_variants", [])
|
|
if not isinstance(variants, list) or len(variants) > MAX_MANIFEST_FILES:
|
|
raise ValueError("invalid case_variants list")
|
|
for index, entry in enumerate(variants):
|
|
_size, largest = _check_entry(
|
|
entry, f"case_variants[{index}]", seen_destinations
|
|
)
|
|
largest_total += largest
|
|
if largest_total > MAX_TOTAL_DOWNLOAD_SIZE:
|
|
raise ValueError("manifest total size exceeds safety limit")
|
|
data_dirs = data.get("data_directories", [])
|
|
if not isinstance(data_dirs, list) or len(data_dirs) > MAX_SELECTION_TAGS:
|
|
raise ValueError("invalid data_directories list")
|
|
for index, entry in enumerate(data_dirs):
|
|
_check_data_directory(entry, f"data_directories[{index}]")
|
|
_check_save_directory(data)
|
|
|
|
declared_total_files = data.get("total_files")
|
|
if declared_total_files is not None and declared_total_files != len(files):
|
|
raise ValueError("manifest total_files does not match files list")
|
|
declared_total_size = data.get("total_size")
|
|
if declared_total_size is not None and declared_total_size != total_size:
|
|
raise ValueError("manifest total_size does not match file sizes")
|
|
|
|
omitted = data.get("omitted_files", [])
|
|
if not isinstance(omitted, list) or len(omitted) > MAX_MANIFEST_FILES:
|
|
raise ValueError("invalid omitted_files list")
|
|
seen_omitted: set[str] = set()
|
|
for index, entry in enumerate(omitted):
|
|
if not isinstance(entry, dict):
|
|
raise ValueError(f"omitted_files[{index}] must be an object")
|
|
dest = str(
|
|
_safe_relative_path(
|
|
entry.get("dest"), f"omitted_files[{index}].dest"
|
|
)
|
|
)
|
|
key = _entry_key({**entry, "dest": dest})
|
|
if key in seen_destinations or key in seen_omitted:
|
|
raise ValueError(f"duplicate or conflicting omitted destination: {dest}")
|
|
seen_omitted.add(key)
|
|
_check_omission(entry, dest)
|
|
_check_root(entry, dest)
|
|
claims = entry.get("alternatives", [])
|
|
if not isinstance(claims, list) or len(claims) > MAX_SELECTION_TAGS:
|
|
raise ValueError(f"invalid omitted alternatives for {dest}")
|
|
for claim in claims:
|
|
claim_cores = claim.get("cores") if isinstance(claim, dict) else None
|
|
if (
|
|
not isinstance(claim, dict)
|
|
or set(claim) != {"cores"}
|
|
or not isinstance(claim_cores, list)
|
|
or not claim_cores
|
|
or not all(isinstance(core, str) for core in claim_cores)
|
|
):
|
|
raise ValueError(f"invalid omitted alternative for {dest}")
|
|
for field in (*SELECTION_FIELDS, "declared_in"):
|
|
_check_tags(entry, field, dest)
|
|
declared_total_omitted = data.get("total_omitted")
|
|
if (
|
|
declared_total_omitted is not None
|
|
and declared_total_omitted != len(omitted)
|
|
):
|
|
raise ValueError("manifest total_omitted does not match omitted_files")
|
|
|
|
for field in ("core_names", "system_cores", "system_names"):
|
|
mapping = data.get(field)
|
|
if mapping is None:
|
|
continue
|
|
if not isinstance(mapping, dict) or len(mapping) > MAX_MANIFEST_FILES:
|
|
raise ValueError(f"invalid {field}")
|
|
for key in mapping:
|
|
if not isinstance(key, str) or not 0 < len(key) <= 256:
|
|
raise ValueError(f"invalid {field} key")
|
|
_check_tags(mapping, key, field)
|
|
if mapping[key] is None:
|
|
raise ValueError(f"invalid {field} for {key}")
|
|
titles = data.get("system_titles")
|
|
if titles is not None:
|
|
if not isinstance(titles, dict) or len(titles) > MAX_MANIFEST_FILES:
|
|
raise ValueError("invalid system_titles")
|
|
for key, title in titles.items():
|
|
if not isinstance(key, str) or not 0 < len(key) <= 256:
|
|
raise ValueError("invalid system_titles key")
|
|
if not isinstance(title, str) or not 0 < len(title) <= 256:
|
|
raise ValueError(f"invalid system_titles for {key}")
|
|
|
|
copies = data.get("standalone_copies", [])
|
|
if not isinstance(copies, list) or len(copies) > 10_000:
|
|
raise ValueError("invalid standalone_copies")
|
|
for index, entry in enumerate(copies):
|
|
if not isinstance(entry, dict):
|
|
raise ValueError(f"standalone_copies[{index}] must be an object")
|
|
# The shape do_standalone_copies reads, checked here: a note with a
|
|
# list for detect, a copy naming neither file nor pattern, or a list
|
|
# for emulator passed and crashed the copy step after the download.
|
|
where = f"standalone_copies[{index}]"
|
|
if "note" in entry:
|
|
detect = entry.get("detect", {})
|
|
if not isinstance(entry["note"], str) or not isinstance(detect, dict):
|
|
raise ValueError(f"invalid note entry at {where}")
|
|
for values in detect.values():
|
|
if not isinstance(values, list) or not all(
|
|
isinstance(value, str) for value in values
|
|
):
|
|
raise ValueError(f"invalid note entry at {where}")
|
|
continue
|
|
if "file" not in entry and "pattern" not in entry:
|
|
raise ValueError(f"{where} names neither a file nor a pattern")
|
|
if not isinstance(entry.get("emulator", ""), str):
|
|
raise ValueError(f"invalid emulator at {where}")
|
|
if "file" in entry:
|
|
_safe_relative_path(
|
|
entry["file"], f"standalone_copies[{index}].file"
|
|
)
|
|
if "source" in entry:
|
|
_safe_relative_path(
|
|
entry["source"], f"standalone_copies[{index}].source"
|
|
)
|
|
if "pattern" in entry:
|
|
pattern = entry["pattern"]
|
|
if (
|
|
not isinstance(pattern, str)
|
|
or not pattern
|
|
or len(pattern) > 256
|
|
or "/" in pattern
|
|
or "\\" in pattern
|
|
or ".." in pattern
|
|
):
|
|
raise ValueError(f"invalid standalone copy pattern: {pattern!r}")
|
|
targets = entry.get("targets", {})
|
|
if targets and not isinstance(targets, dict):
|
|
raise ValueError(f"invalid standalone copy targets at index {index}")
|
|
for values in (targets or {}).values():
|
|
if not isinstance(values, list) or len(values) > 100:
|
|
raise ValueError(f"invalid standalone copy targets at index {index}")
|
|
for value in values:
|
|
_safe_target_dir(value, f"standalone_copies[{index}].targets")
|
|
return data
|
|
|
|
|
|
def _validate_targets(data: object) -> dict[str, dict]:
|
|
"""Validate and normalize legacy list-valued target manifests.
|
|
|
|
A null core list means the target publishes no core inventory. That is a
|
|
known target with no filter, not a broken manifest: rejecting it would
|
|
discard every other target on the platform.
|
|
"""
|
|
if not isinstance(data, dict) or len(data) > 10_000:
|
|
raise ValueError("invalid targets manifest")
|
|
normalized: dict[str, dict] = {}
|
|
for target, value in data.items():
|
|
if not isinstance(target, str) or not target or len(target) > 128:
|
|
raise ValueError("invalid target name")
|
|
if isinstance(value, dict):
|
|
cores = value.get("cores")
|
|
else:
|
|
cores = value
|
|
if cores is None:
|
|
normalized[target] = {"cores": None}
|
|
continue
|
|
if not isinstance(cores, list) or len(cores) > 10_000 or not all(
|
|
isinstance(core, str) and 0 < len(core) <= 256 for core in cores
|
|
):
|
|
raise ValueError(f"invalid core list for target {target}")
|
|
normalized[target] = {"cores": cores}
|
|
return normalized
|
|
|
|
|
|
NETWORK_ADVICE = (" Check the connection, and HTTPS_PROXY if this network needs a "
|
|
"proxy, then run the same command again.\n"
|
|
f" More help: {TROUBLESHOOTING_PAGE}")
|
|
|
|
|
|
# The installer was written after this day. A clock that reads earlier is
|
|
# wrong, and TLS then finds every certificate not yet valid: a handheld
|
|
# with no clock battery boots on 1970 or 1980.
|
|
CLOCK_FLOOR = datetime.date(2026, 1, 1)
|
|
# Where systems keep their trusted certificates, for a Python built to look
|
|
# elsewhere: Debian, Fedora, openSUSE, the BSDs and macOS, Homebrew, Termux.
|
|
CA_BUNDLES = (
|
|
"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt",
|
|
"/etc/ssl/ca-bundle.pem", "/etc/pki/tls/cacert.pem", "/etc/ssl/cert.pem",
|
|
"/usr/local/etc/openssl/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem",
|
|
"/data/data/com.termux/files/usr/etc/tls/cert.pem",
|
|
)
|
|
# Set from --verbose: the error text Python gives stays beside the words.
|
|
_RAW_ERRORS = threading.Event()
|
|
|
|
|
|
def _default_ca_present() -> bool:
|
|
"""Whether the certificates this Python reads by default exist."""
|
|
import ssl # noqa: PLC0415
|
|
|
|
paths = ssl.get_default_verify_paths()
|
|
for value in (os.environ.get("SSL_CERT_FILE"), paths.cafile, paths.openssl_cafile):
|
|
if value and Path(value).is_file():
|
|
return True
|
|
for value in (os.environ.get("SSL_CERT_DIR"), paths.capath, paths.openssl_capath):
|
|
try:
|
|
if value and any(Path(value).iterdir()):
|
|
return True
|
|
except OSError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _known_bundle() -> str:
|
|
"""A certificate bundle the system keeps where this Python does not look."""
|
|
return next((path for path in CA_BUNDLES if Path(path).is_file()), "")
|
|
|
|
|
|
def _ca_bundle_found() -> bool:
|
|
"""Whether any list of trusted certificates is on this machine.
|
|
|
|
Windows verifies against its own store, which Python reads directly.
|
|
"""
|
|
return os.name == "nt" or _default_ca_present() or bool(_known_bundle())
|
|
|
|
|
|
def network_words(
|
|
exc: BaseException, today: datetime.date | None = None, verbose: bool | None = None,
|
|
) -> tuple[str, str]:
|
|
"""The reason a request failed, and the same in words a beginner reads.
|
|
|
|
The reason is clock, no_ca, network, or offline for a request the run
|
|
was told not to make. Offline, the run printed
|
|
"[Errno -2] Name or service not known": the raw text now follows the
|
|
words only under --verbose.
|
|
"""
|
|
if isinstance(exc, _Offline):
|
|
return "offline", str(exc)
|
|
reason = getattr(exc, "reason", None)
|
|
inner = reason if isinstance(reason, BaseException) else exc
|
|
raw = str(reason or exc)
|
|
code, words = "network", ""
|
|
cert_error = getattr(sys.modules.get("ssl"), "SSLCertVerificationError", None)
|
|
if cert_error is not None and isinstance(inner, cert_error):
|
|
today = today or datetime.date.today()
|
|
if not _ca_bundle_found():
|
|
code = "no_ca"
|
|
words = ("This system has no list of trusted certificates to check the "
|
|
"server with. Install the ca-certificates package, or name a "
|
|
"certificate bundle with SSL_CERT_FILE, then run again")
|
|
elif today < CLOCK_FLOOR:
|
|
code = "clock"
|
|
words = (f"The clock reads {today.isoformat()}: connect to Wi-Fi and let the "
|
|
"time sync, or set the date, then run again")
|
|
else:
|
|
words = "the server's certificate was rejected"
|
|
bundle = "" if _default_ca_present() else _known_bundle()
|
|
words += (f": this Python reads no certificate list, run again with "
|
|
f"SSL_CERT_FILE={bundle}" if bundle
|
|
else ", which a proxy or a filter on this network can cause")
|
|
elif isinstance(inner, socket.gaierror):
|
|
words = "no internet connection, or the server name could not be looked up"
|
|
elif isinstance(inner, (socket.timeout, TimeoutError)):
|
|
words = "the connection timed out"
|
|
elif isinstance(inner, ConnectionRefusedError):
|
|
words = "the server refused the connection"
|
|
elif isinstance(inner, OSError) and inner.errno in (
|
|
errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN):
|
|
words = "no internet connection: the network cannot be reached"
|
|
elif isinstance(inner, (ConnectionResetError, ConnectionAbortedError,
|
|
http.client.HTTPException)):
|
|
words = "the connection was cut"
|
|
if not words:
|
|
return code, raw
|
|
if _RAW_ERRORS.is_set() if verbose is None else verbose:
|
|
words += f" [{raw}]"
|
|
return code, words
|
|
|
|
|
|
def _address_lines(url: str) -> list[str]:
|
|
"""The address that failed, and the override that chose it if one did.
|
|
|
|
A RETROBIOS_BASE_URL left from a mirror that is down read as a network
|
|
fault: neither the host nor the variable was named.
|
|
"""
|
|
lines = [f" Address: {url}"]
|
|
if os.environ.get("RETROBIOS_BASE_URL"):
|
|
lines.append(" RETROBIOS_BASE_URL is set in this environment and chose that "
|
|
"address: unset it to read from the project itself.")
|
|
return lines
|
|
|
|
|
|
def _open_socket(
|
|
address: tuple[str, int], timeout: object, source_address: object = None,
|
|
) -> socket.socket:
|
|
"""A connection given CONNECT_TIMEOUT to open, then the caller's timeout."""
|
|
bounded = isinstance(timeout, (int, float))
|
|
limit = min(CONNECT_TIMEOUT, timeout) if bounded else CONNECT_TIMEOUT
|
|
sock = socket.create_connection(address, limit, source_address)
|
|
sock.settimeout(timeout if bounded else socket.getdefaulttimeout())
|
|
return sock
|
|
|
|
|
|
class _BoundedHTTPConnection(http.client.HTTPConnection):
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self._create_connection = _open_socket
|
|
|
|
|
|
class _BoundedHTTPHandler(urllib.request.HTTPHandler):
|
|
def do_open(self, http_class: Any, req: Any, **kwargs: Any) -> Any:
|
|
return super().do_open(_BoundedHTTPConnection, req, **kwargs)
|
|
|
|
|
|
def install_bounded_opener() -> None:
|
|
"""Every request of the run opens its connection in CONNECT_TIMEOUT.
|
|
|
|
The proxy variables are read as before: build_opener keeps its
|
|
ProxyHandler, and only the HTTP and HTTPS handlers are replaced.
|
|
"""
|
|
handlers: list[type] = [_BoundedHTTPHandler]
|
|
if hasattr(http.client, "HTTPSConnection"):
|
|
class _BoundedHTTPSConnection(http.client.HTTPSConnection):
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self._create_connection = _open_socket
|
|
|
|
class _BoundedHTTPSHandler(urllib.request.HTTPSHandler):
|
|
def do_open(self, http_class: Any, req: Any, **kwargs: Any) -> Any:
|
|
return super().do_open(_BoundedHTTPSConnection, req, **kwargs)
|
|
|
|
handlers.append(_BoundedHTTPSHandler)
|
|
urllib.request.install_opener(urllib.request.build_opener(*handlers))
|
|
|
|
|
|
# The wait for each answer of a file list, its first byte included: a
|
|
# server that takes the connection and never answers (a captive portal, a
|
|
# stuck proxy) held the run 93 s on three waits of 30 s. A list is a few
|
|
# MB, never a slow stream.
|
|
LIST_TIMEOUT = 15.0
|
|
# Attempts after a server that took the connection went silent: once more,
|
|
# a stall is rarely a blip.
|
|
STALLED_ATTEMPTS = 2
|
|
|
|
|
|
def _stalled(exc: BaseException) -> bool:
|
|
"""Whether exc is a wait that ran out, the connection made."""
|
|
reason = getattr(exc, "reason", None)
|
|
return isinstance(exc, socket.timeout) or isinstance(reason, socket.timeout)
|
|
|
|
|
|
def _fetch_json(
|
|
url: str, limit: int, label: str, attempts: int = MAX_RETRIES,
|
|
timeout: float | None = None,
|
|
) -> object:
|
|
"""A JSON document, tried MAX_RETRIES times like every file download.
|
|
|
|
A file list fetched once ended the run on one timeout over flaky Wi-Fi.
|
|
A 4xx answer or an invalid document is final: asking again changes
|
|
nothing, and a server gone silent is asked once more only.
|
|
"""
|
|
_network_allowed(label)
|
|
timeout = LIST_TIMEOUT if timeout is None else timeout
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
return _read_limited_json(resp, limit, label)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code < 500 and exc.code != 429 or attempt == attempts:
|
|
raise
|
|
delay = _retry_delay(attempt, exc.headers.get("Retry-After") if exc.headers else None)
|
|
problem = f"HTTP {exc.code}"
|
|
except (urllib.error.URLError, OSError, http.client.HTTPException) as exc:
|
|
code, problem = network_words(exc)
|
|
# A wrong clock or no certificate list fails every attempt alike.
|
|
if (attempt == attempts or code != "network"
|
|
or (_stalled(exc) and attempt >= STALLED_ATTEMPTS)):
|
|
if isinstance(exc, http.client.HTTPException):
|
|
raise ConnectionError(f"connection cut: {exc!r}") from exc
|
|
# How many were made, for the line that says so.
|
|
exc.attempts = attempt # type: ignore[union-attr]
|
|
raise
|
|
delay = _retry_delay(attempt, None)
|
|
# Said, or three 30-second attempts read as a frozen program.
|
|
print(f" The {label} did not arrive ({problem}), trying again "
|
|
f"({attempt + 1}/{attempts}) in {delay:.0f} s.", file=sys.stderr)
|
|
time.sleep(delay)
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
def fetch_manifest(plat: str) -> dict:
|
|
"""Download platform manifest JSON, or read the one this run was given."""
|
|
if LOCAL.manifest is not None:
|
|
return read_local_manifest(LOCAL.manifest, plat)
|
|
if LOCAL.pack is not None:
|
|
try:
|
|
return _validate_manifest(LOCAL.pack.manifest(plat), plat)
|
|
except ValueError as exc:
|
|
print(f" The file list in {LOCAL.pack.name} is not valid: {exc}",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
url = MANIFEST_URL.format(platform=plat)
|
|
try:
|
|
return _validate_manifest(_fetch_json(url, MAX_MANIFEST_BYTES, "file list"), plat)
|
|
except ValueError as exc:
|
|
EVENTS.emit("error", stage="manifest", reason="invalid", platform=plat,
|
|
message=f"the file list is not valid: {exc}")
|
|
print(f" The file list for {plat} is not valid: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except urllib.error.HTTPError as exc:
|
|
EVENTS.emit(
|
|
"error", stage="manifest", reason="not_found" if exc.code == 404 else "network",
|
|
platform=plat, message=f"the file list could not be fetched: HTTP {exc.code}",
|
|
)
|
|
if exc.code == 404 and os.environ.get("RETROBIOS_BASE_URL"):
|
|
print(f" No file list for {plat} there.", file=sys.stderr)
|
|
for line in _address_lines(url):
|
|
print(line, file=sys.stderr)
|
|
sys.exit(1)
|
|
if exc.code == 404:
|
|
# Asked once, a 4xx being final. The name was checked before any
|
|
# request, so the revision is what has no such list.
|
|
print(f" No file list for {plat} at {RELEASE_REF}.", file=sys.stderr)
|
|
if RELEASE_REF != DEFAULT_RELEASE_REF:
|
|
print(f" RETROBIOS_REF={RELEASE_REF} names a tag that does not exist or "
|
|
f"predates {plat}: unset RETROBIOS_REF, or pick a tag from "
|
|
"https://github.com/Abdess/retrobios/releases.", file=sys.stderr)
|
|
else:
|
|
print(" The repository may be moving its files: run the same command "
|
|
"again in a few minutes.", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f" Could not fetch the file list for {plat}: HTTP {exc.code}", file=sys.stderr)
|
|
for line in _address_lines(url):
|
|
print(line, file=sys.stderr)
|
|
print(NETWORK_ADVICE, file=sys.stderr)
|
|
sys.exit(1)
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
code, words = network_words(exc)
|
|
EVENTS.emit("error", stage="manifest", reason=code, platform=plat,
|
|
message=words if code != "network"
|
|
else f"the file list could not be fetched: {words}")
|
|
if code == "offline":
|
|
print(f" No file list for {plat}: {OFFLINE_WORDS}. Give it with "
|
|
"--manifest FILE (a copy of install/<platform>.json) or "
|
|
"--from-pack PACK, or run without --offline.", file=sys.stderr)
|
|
sys.exit(1)
|
|
if code != "network":
|
|
print(f" Could not fetch the file list for {plat}.", file=sys.stderr)
|
|
print(f" {words}.", file=sys.stderr)
|
|
sys.exit(1)
|
|
tries = getattr(exc, "attempts", MAX_RETRIES)
|
|
print(f" Could not fetch the file list for {plat} after {tries} "
|
|
f"attempts: {words}", file=sys.stderr)
|
|
for line in _address_lines(url):
|
|
print(line, file=sys.stderr)
|
|
print(NETWORK_ADVICE, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def fetch_targets(plat: str) -> "dict | None":
|
|
"""Download the target core list.
|
|
|
|
Empty on 404, where the platform publishes no targets. None when the list
|
|
could not be read: a timeout says nothing about the platform, and
|
|
answering "no targets" for it sent users to the full pack.
|
|
"""
|
|
url = TARGETS_URL.format(platform=plat)
|
|
try:
|
|
return _validate_targets(_fetch_json(url, MAX_TARGETS_BYTES, "target list"))
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return {}
|
|
print(f" Error: failed to fetch targets for {plat}: {exc}", file=sys.stderr)
|
|
for line in _address_lines(url):
|
|
print(line, file=sys.stderr)
|
|
print(NETWORK_ADVICE, file=sys.stderr)
|
|
return None
|
|
except ValueError as exc:
|
|
print(f" Error: the target list for {plat} is not valid: {exc}", file=sys.stderr)
|
|
return None
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
print(f" Error: failed to fetch targets for {plat}: "
|
|
f"{network_words(exc)[1]}", file=sys.stderr)
|
|
for line in _address_lines(url):
|
|
print(line, file=sys.stderr)
|
|
print(NETWORK_ADVICE, file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def _for_cores(entry: dict, chosen: set[str]) -> dict | None:
|
|
"""The copy of a core file the chosen cores read, or None.
|
|
|
|
A destination holds one file. When several cores read it and disagree
|
|
on its content, the manifest carries the first core's copy and the
|
|
others as alternatives, in the order the pack builder tries them: a
|
|
pack built without the first core carries the next one's copy.
|
|
"""
|
|
if chosen.intersection(entry.get("cores") or []):
|
|
return entry
|
|
for alternative in entry.get("alternatives", []):
|
|
if chosen.intersection(alternative["cores"]):
|
|
kept = {
|
|
k: v for k, v in entry.items()
|
|
if k not in _COPY_FIELDS
|
|
and k not in ("alternatives", "slot_choices", "declared_choices")
|
|
}
|
|
kept.update((k, alternative[k]) for k in _COPY_FIELDS if k in alternative)
|
|
return kept
|
|
return None
|
|
|
|
|
|
def _settle_slot(
|
|
entry: dict,
|
|
chosen: set[str],
|
|
kept_system: Callable[[str], bool] | None = None,
|
|
) -> dict:
|
|
"""A platform file as a pack built for the chosen cores carries it.
|
|
|
|
Where a core's own source contradicts the platform and the frontend only
|
|
checks the path, the pack serves the core's copy: the first of
|
|
`slot_choices` naming a chosen core decides. Otherwise the platform
|
|
answers, through the first of `declared_choices` whose systems remain.
|
|
"""
|
|
declared = entry.get("declared_choices")
|
|
if declared is None:
|
|
return entry
|
|
platform_copy = next(
|
|
(c for c in declared
|
|
if kept_system is None or any(kept_system(s) for s in c["systems"])),
|
|
declared[0],
|
|
)
|
|
copy = next(
|
|
(c for c in entry.get("slot_choices", []) if chosen.intersection(c["cores"])),
|
|
None,
|
|
)
|
|
if copy is None or "sha1" not in copy:
|
|
copy = platform_copy
|
|
kept = {
|
|
k: v for k, v in entry.items()
|
|
if k not in _COPY_FIELDS
|
|
and k not in ("slot_choices", "declared_choices", "declared_copy")
|
|
}
|
|
kept.update((k, copy[k]) for k in _COPY_FIELDS if k in copy and k != "cores")
|
|
kept["cores"] = None
|
|
if copy is not platform_copy and copy.get("sha1") != platform_copy.get("sha1"):
|
|
# What the platform checks here, for the `declared` event.
|
|
kept["declared_copy"] = {k: platform_copy[k] for k in _COPY_FIELDS
|
|
if k in platform_copy and k != "cores"}
|
|
return kept
|
|
|
|
|
|
def _filter_by_target(
|
|
files: list[dict], target_cores: list[str]
|
|
) -> list[dict]:
|
|
"""Keep the platform's files, and the core files the given cores read."""
|
|
chosen = set(target_cores)
|
|
result: list[dict] = []
|
|
for f in files:
|
|
if f.get("cores") is None:
|
|
result.append(_settle_slot(f, chosen))
|
|
continue
|
|
kept = _for_cores(f, chosen)
|
|
if kept is not None:
|
|
result.append(kept)
|
|
return result
|
|
|
|
|
|
def designated_cores(manifest: dict, names: list[str]) -> set[str]:
|
|
"""The cores a list of names designates, the way the pack builder reads it.
|
|
|
|
A target list, or a core found installed, names a core the buildbot's
|
|
way (`mednafen_psx`), and a file names the profile that reads it
|
|
(`beetle_psx`). `core_names` gives each profile its other names.
|
|
"""
|
|
wanted = set(names)
|
|
chosen = set(wanted)
|
|
for profile, aliases in (manifest.get("core_names") or {}).items():
|
|
if wanted.intersection(aliases):
|
|
chosen.add(profile)
|
|
return chosen
|
|
|
|
|
|
def narrow_to_cores(
|
|
manifest: dict,
|
|
names: list[str],
|
|
files: list[dict] | None = None,
|
|
omitted: list[dict] | None = None,
|
|
) -> tuple[list[dict], list[dict]]:
|
|
"""Keep what the named cores read, as a pack built for them carries.
|
|
|
|
A file a core loads stays when that core is named. A file of the
|
|
platform's own list stays when one of its systems does: a system goes
|
|
when none of the cores `system_cores` gives it is named, and a system
|
|
it does not list stays whatever is named. A dropped system's file gives
|
|
way to the first named core claiming its destination. A manifest without
|
|
the two maps keeps the platform's list whole and matches cores by name.
|
|
"""
|
|
cores = designated_cores(manifest, names)
|
|
reach = manifest.get("system_cores") or {}
|
|
|
|
def kept_system(system: str) -> bool:
|
|
return system not in reach or bool(cores.intersection(reach[system]))
|
|
|
|
displaced: list[dict] = []
|
|
|
|
def kept(entries: list[dict]) -> list[dict]:
|
|
result: list[dict] = []
|
|
for entry in entries:
|
|
if entry.get("cores") is not None:
|
|
copy = _for_cores(entry, cores)
|
|
if copy is not None:
|
|
result.append(copy)
|
|
continue
|
|
# None of the cores served here is named: a named core that
|
|
# wants the slot and has nothing held is a file left out.
|
|
claim = next(
|
|
(c for c in entry.get("absent_claims", [])
|
|
if cores.intersection(c.get("cores") or [])),
|
|
None,
|
|
)
|
|
if claim is not None:
|
|
displaced.append({"dest": entry["dest"], **claim})
|
|
continue
|
|
# The systems declaring the file, where they are fewer than the
|
|
# ones reading it.
|
|
systems = entry.get("declared_in") or entry_systems(entry)
|
|
if not systems or any(kept_system(s) for s in systems):
|
|
result.append(_settle_slot(entry, cores, kept_system))
|
|
continue
|
|
# The system is dropped, which frees its destination for the
|
|
# first named core claiming it, as in a pack built for them.
|
|
copy = _for_cores(entry, cores)
|
|
if copy is not None:
|
|
result.append(copy)
|
|
return result
|
|
|
|
files = manifest.get("files", []) if files is None else files
|
|
omitted = manifest.get("omitted_files", []) if omitted is None else omitted
|
|
kept_files = kept(files)
|
|
return kept_files, kept(omitted) + displaced
|
|
|
|
|
|
def canonical_region(raw: str) -> str:
|
|
"""One region name or alias, spelled the way manifests spell it."""
|
|
key = raw.strip().lower()
|
|
key = REGION_ALIASES.get(key, key)
|
|
if key not in REGIONS:
|
|
import difflib # noqa: PLC0415
|
|
|
|
close = difflib.get_close_matches(key, [*REGION_ALIASES, *REGIONS], n=3, cutoff=0.5)
|
|
hint = f" Did you mean: {', '.join(close)}?" if close else ""
|
|
raise ValueError(
|
|
f"unknown region: {raw.strip()}.{hint} "
|
|
f"Short forms: {', '.join(REGION_ALIASES)}; regions: {', '.join(sorted(REGIONS))}"
|
|
)
|
|
return key
|
|
|
|
|
|
def _region_comparable(a: str, b: str) -> bool:
|
|
return (
|
|
a == b
|
|
or _REGION_PARENT.get(a) == b
|
|
or _REGION_PARENT.get(b) == a
|
|
or a in AREA_TERRITORIES.get(b, ())
|
|
or b in AREA_TERRITORIES.get(a, ())
|
|
)
|
|
|
|
|
|
def _region_rank(file_regions: set[str], requested: list[str]) -> int:
|
|
for index, wanted in enumerate(requested):
|
|
if any(_region_comparable(wanted, region) for region in file_regions):
|
|
return index
|
|
return len(requested)
|
|
|
|
|
|
def region_drops(
|
|
entries: list[dict],
|
|
requested: list[str],
|
|
systems: list[str] | None = None,
|
|
held: set[str] | None = None,
|
|
) -> set[str]:
|
|
"""Destinations a region priority list withdraws, as _entry_key spells them.
|
|
|
|
The pack builder's rule (region.resolve_region_drops), replayed over the
|
|
groups the manifest records. Per group, the best-ranked regional file
|
|
stays. A world file beats unmatched regional ones the code does not search
|
|
before it (`priority`, lower first). When nothing matches and no world
|
|
file exists, every regional file stays, so a group is never emptied. A
|
|
file with no region never competes and always stays.
|
|
|
|
A core extra competes whatever systems are chosen, while a platform's own
|
|
declaration competes only when its system is kept, as in the pack.
|
|
|
|
``held`` names the destinations the manifest can install. A match it
|
|
lacks never displaces a held file: the American BIOS stays under
|
|
--region japan when the Japanese one is missing. A group named after a
|
|
single file (FILE_GROUP) competes with nothing and keeps nothing.
|
|
"""
|
|
if not requested:
|
|
return set()
|
|
kept_systems = set(systems) if systems else None
|
|
groups: dict[str, list[tuple[set, str, object]]] = {}
|
|
for entry in entries:
|
|
regions = entry.get("regions")
|
|
if not regions:
|
|
continue
|
|
joined = list(entry.get("region_groups") or [])
|
|
joined += [
|
|
group for group in entry.get("region_system_groups") or []
|
|
if kept_systems is None or group in kept_systems
|
|
]
|
|
priority = entry.get("priority")
|
|
if not isinstance(priority, int) or isinstance(priority, bool):
|
|
priority = None
|
|
for group in joined:
|
|
groups.setdefault(group, []).append((set(regions), _entry_key(entry), priority))
|
|
keep: set[str] = set()
|
|
drop: set[str] = set()
|
|
for group_id, members in groups.items():
|
|
if FILE_GROUP in group_id:
|
|
continue
|
|
world = {dest for regions, dest, _p in members if WORLD_REGION in regions}
|
|
ranks = {dest: priority for _regions, dest, priority in members}
|
|
regional = [
|
|
(_region_rank(regions, requested), dest)
|
|
for regions, dest, _p in members
|
|
if WORLD_REGION not in regions
|
|
]
|
|
keep |= world
|
|
if not regional:
|
|
continue
|
|
matched = [(rank, dest) for rank, dest in regional if rank < len(requested)]
|
|
winners, rivals = matched, world
|
|
if held is not None:
|
|
winners = [(rank, dest) for rank, dest in matched if dest in held]
|
|
rivals = world & held
|
|
if winners:
|
|
best = min(rank for rank, _dest in winners)
|
|
keep |= {
|
|
dest for rank, dest in matched
|
|
if rank == best or (rank < best and dest not in (held or ()))
|
|
}
|
|
drop |= {dest for _rank, dest in regional if dest not in keep}
|
|
elif rivals:
|
|
world_ranks = [ranks[dest] for dest in rivals]
|
|
best_world = min(world_ranks) if None not in world_ranks else None
|
|
for _rank, dest in regional:
|
|
ours = ranks[dest]
|
|
if best_world is not None and ours is not None and ours < best_world:
|
|
keep.add(dest)
|
|
else:
|
|
drop.add(dest)
|
|
else:
|
|
keep |= {dest for _rank, dest in regional}
|
|
return drop - keep
|
|
|
|
|
|
def entry_systems(entry: dict) -> list[str]:
|
|
"""The platform systems a file or an omission belongs to.
|
|
|
|
An omission's `system` is spelled the way its profile spells it. Only
|
|
`systems` names the platform's own, the ones --system accepts.
|
|
"""
|
|
return list(entry.get("systems") or [])
|
|
|
|
|
|
def narrow(
|
|
files: list[dict],
|
|
omitted: list[dict],
|
|
systems: list[str],
|
|
cores: list[str],
|
|
regions: list[str],
|
|
) -> tuple[list[dict], list[dict]]:
|
|
"""Keep what the chosen systems, cores and regions need.
|
|
|
|
A core choice keeps the platform's own list and narrows only the files
|
|
cores load, the rule --target already follows. Regions are then decided
|
|
the way the pack builder decides them under --system: over the chosen
|
|
systems' declarations and every core extra. The system filter comes last.
|
|
"""
|
|
if cores:
|
|
files = _filter_by_target(files, cores)
|
|
omitted = _filter_by_target(omitted, cores)
|
|
if regions:
|
|
# The builder's held set: what the collection serves. An omission
|
|
# answers the request but takes no installable file's place, unless
|
|
# the builder holds it (held: true) and only this download lacks it:
|
|
# the pack drops the fallback it displaces, and so does this.
|
|
held = {_entry_key(f) for f in files} | {
|
|
_entry_key(o) for o in omitted if o.get("held")
|
|
}
|
|
dropped = region_drops(files + omitted, regions, systems, held)
|
|
files = [f for f in files if _entry_key(f) not in dropped]
|
|
omitted = [o for o in omitted if _entry_key(o) not in dropped]
|
|
if systems:
|
|
wanted = set(systems)
|
|
files = [f for f in files if _chosen(f, wanted)]
|
|
omitted = [o for o in omitted if _chosen(o, wanted)]
|
|
return files, omitted
|
|
|
|
|
|
def _chosen(entry: dict, wanted: set[str]) -> bool:
|
|
systems = entry_systems(entry)
|
|
if not systems:
|
|
return OTHER_SYSTEMS in wanted
|
|
return bool(wanted & set(systems))
|
|
|
|
|
|
def records_selection(files: list[dict]) -> bool:
|
|
"""Whether a manifest carries what narrowing reads. Older ones do not."""
|
|
return any("systems" in entry for entry in files)
|
|
|
|
|
|
def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[str]]:
|
|
"""The systems, cores and regions a manifest lets one narrow to."""
|
|
systems = {s for e in files + omitted for s in entry_systems(e)}
|
|
ordered = sorted(systems)
|
|
if any(not entry_systems(f) for f in files):
|
|
ordered.append(OTHER_SYSTEMS)
|
|
cores = {
|
|
c for f in files
|
|
for copy in [f, *f.get("alternatives", []), *f.get("slot_choices", [])]
|
|
for c in (copy.get("cores") or [])
|
|
}
|
|
regions = {
|
|
r for e in files + omitted for r in (e.get("regions") or [])
|
|
if r != WORLD_REGION
|
|
}
|
|
return {
|
|
"systems": ordered,
|
|
"cores": sorted(cores),
|
|
"regions": sorted(regions),
|
|
}
|
|
|
|
|
|
def resolve_choices(
|
|
values: list[str], known: list[str], label: str,
|
|
aliases: dict[str, str] | None = None,
|
|
holders: dict[str, list[str]] | None = None,
|
|
) -> list[str]:
|
|
"""Names given on the command line, matched without regard to case.
|
|
|
|
Each value is one name or a comma-separated list. A whole value that is
|
|
itself a name wins over splitting it: Recalbox names one system
|
|
`msx1,msx2,msxturbor`. An unknown name is refused, never ignored, since
|
|
carrying on would install something other than what was asked.
|
|
aliases maps another name of a known one to it, as Batocera's `psx`
|
|
stands for sony-playstation. A known name always wins over an alias.
|
|
holders maps a system the list files under other ids to those ids
|
|
(key_file_holders): an unknown name for it is answered with them.
|
|
"""
|
|
aliases = {alias: name for alias, name in (aliases or {}).items() if name in known}
|
|
by_lower = {name.lower(): name for name in known}
|
|
for alias, name in aliases.items():
|
|
by_lower.setdefault(alias.lower(), name)
|
|
# Spelled as the site writes it (Sony PlayStation), a name is still one
|
|
# name when no other one reads the same once case and spacing go.
|
|
by_key: dict[str, str | None] = {}
|
|
for name in known:
|
|
key = _name_key(name)
|
|
by_key[key] = None if key in by_key else name
|
|
for alias, name in aliases.items():
|
|
by_key.setdefault(_name_key(alias), name)
|
|
chosen: list[str] = []
|
|
unknown: list[str] = []
|
|
for value in values:
|
|
value = value.strip()
|
|
tokens = [value] if value.lower() in by_lower else value.split(",")
|
|
for token in (t.strip() for t in tokens):
|
|
if not token:
|
|
continue
|
|
name = by_lower.get(token.lower()) or by_key.get(_name_key(token))
|
|
if name is None:
|
|
unknown.append(token)
|
|
elif name not in chosen:
|
|
chosen.append(name)
|
|
if unknown:
|
|
if not known:
|
|
raise ValueError(f"unknown {label}: {', '.join(unknown)}. This list records none.")
|
|
listing = {"system": "--list-systems", "emulator": "--list-emulators"}.get(
|
|
label, "--list-cores")
|
|
hints, inside_from = aliases, 4
|
|
held = holders or {}
|
|
stands_for: dict[str, str] = {}
|
|
notes = ""
|
|
if label == "system":
|
|
# gba is the end of nintendo-gba: three letters are enough here.
|
|
hints = {**{short: name for short, name in SYSTEM_SHORT_NAMES.items()
|
|
if name in known}, **aliases}
|
|
inside_from = 3
|
|
stands_for = {_name_key(system): system for system in held}
|
|
for short, name in SYSTEM_SHORT_NAMES.items():
|
|
if name in held:
|
|
stands_for.setdefault(_name_key(short), name)
|
|
named = dict.fromkeys(
|
|
stands_for[_name_key(token)] for token in unknown
|
|
if _name_key(token) in stands_for)
|
|
notes = "".join(
|
|
f"This list files {SYSTEM_KEY_FILES[system]} under "
|
|
f"{', '.join(held[system])}. " for system in named)
|
|
meant = {key: held[system] for key, system in stands_for.items()}
|
|
raise ValueError(
|
|
f"unknown {label}: {', '.join(unknown)}. {notes}"
|
|
f"{did_you_mean(unknown, known, listing, hints, inside_from, meant)}"
|
|
)
|
|
if values and not chosen:
|
|
raise ValueError(f"--{label} needs at least one name")
|
|
return chosen
|
|
|
|
|
|
def resolve_regions(values: list[str]) -> list[str]:
|
|
"""A region priority list, best first, from names or aliases."""
|
|
chosen: list[str] = []
|
|
for value in values:
|
|
for token in value.split(","):
|
|
if token.strip():
|
|
region = canonical_region(token)
|
|
if region not in chosen:
|
|
chosen.append(region)
|
|
if values and not chosen:
|
|
raise ValueError("--region needs at least one region")
|
|
return chosen
|
|
|
|
|
|
def parse_selection(text: str, count: int, ordered: bool = False) -> list[int]:
|
|
"""Numbers typed at a prompt: `3`, `1,4`, `2-5`, or `all`, 1-based.
|
|
|
|
Returned sorted, or in the typed order when the order is a priority.
|
|
"""
|
|
if text.strip().lower() in ("all", "*"):
|
|
return list(range(1, count + 1))
|
|
chosen: list[int] = []
|
|
for token in (t.strip() for t in text.split(",")):
|
|
if not token:
|
|
continue
|
|
low, sep, high = token.partition("-")
|
|
try:
|
|
start = int(low)
|
|
end = int(high) if sep else start
|
|
except ValueError:
|
|
raise ValueError(f"not a number or range: {token}") from None
|
|
if start > end or start < 1 or end > count:
|
|
raise ValueError(f"out of range: {token} (1-{count})")
|
|
for number in range(start, end + 1):
|
|
if number not in chosen:
|
|
chosen.append(number)
|
|
return chosen if ordered else sorted(chosen)
|
|
|
|
|
|
def _digest_file(path: Path, algorithms: tuple[str, ...]) -> dict[str, str]:
|
|
"""Compute several digests of a file in a single read.
|
|
|
|
Checking a 3 GB collection against both a SHA-1 and a SHA-256 used to walk
|
|
every file twice, single threaded. Hashing is cheap next to the I/O, so
|
|
the read is what has to happen once.
|
|
"""
|
|
with open(path, "rb") as fh:
|
|
return _digest_handle(fh, algorithms)
|
|
|
|
|
|
def _digest_handle(fh: BinaryIO, algorithms: tuple[str, ...]) -> dict[str, str]:
|
|
"""Digests of an open file, read from its start."""
|
|
hashers = {name: hashlib.new(name) for name in algorithms}
|
|
fh.seek(0)
|
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
for hasher in hashers.values():
|
|
hasher.update(chunk)
|
|
return {name: hasher.hexdigest() for name, hasher in hashers.items()}
|
|
|
|
|
|
def _sha1_file(path: Path) -> str:
|
|
"""Compute SHA1 of a file."""
|
|
return _digest_file(path, ("sha1",))["sha1"]
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
"""Compute SHA256 of a file."""
|
|
return _digest_file(path, ("sha256",))["sha256"]
|
|
|
|
|
|
def _cfg_true(value: str | None) -> bool:
|
|
return (value or "").strip().lower() == "true"
|
|
|
|
|
|
_CONFIGS_READ: list[bool] = []
|
|
|
|
|
|
def _retroarch_config_for(bios_path: Path) -> tuple[Path, Path] | None:
|
|
"""The retroarch.cfg whose system_directory is bios_path, and the
|
|
folder ':' expands to there.
|
|
|
|
A run given --dest detects nothing, so the configs are read here, once
|
|
and silently, the first time a save directory or the cores folder is
|
|
asked for: the interface names every folder with --dest, and its runs
|
|
sent Azahar's keys.txt to a guessed folder where line mode read
|
|
savefile_directory, and found no core where line mode found them.
|
|
"""
|
|
found = _RETROARCH_CONFIGS.get(bios_path)
|
|
if found is not None or _CONFIGS_READ:
|
|
return found
|
|
_CONFIGS_READ.append(True)
|
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
detect_platforms(_INVOCATION.os_type or detect_os())
|
|
return _RETROARCH_CONFIGS.get(bios_path)
|
|
|
|
|
|
def save_directory_for(
|
|
bios_path: Path, manifest: dict, override: Path | None = None,
|
|
) -> SavePlace | str:
|
|
"""The save directory the platform's frontend hands its cores, or why
|
|
this run cannot tell.
|
|
|
|
A RetroArch found by its retroarch.cfg answers from it: savefile_directory
|
|
as RetroArch expands it, a value left at default or naming no folder
|
|
being saves/ beside the config (configuration.c config_load_file keeps
|
|
the default unless path_is_directory), and sort_savefiles_enable, true by
|
|
default (config.def.h:1099). savefiles_in_content_dir and
|
|
sort_savefiles_by_content_enable put the directory beside each game: no
|
|
single place then. Without a config, the BIOS folder standing at its
|
|
default name puts the save directory beside it, as the pack lays them out,
|
|
and a list whose dests start above the BIOS folder (RetroDECK) puts it
|
|
in the folder the run installs to. ``override`` (--save-dir) names the directory itself. A launcher that
|
|
writes the save directory at each launch, one folder per system
|
|
(by_system: Batocera, RetroBat), is not read from any retroarch.cfg:
|
|
the one on disk holds the last launch's system.
|
|
"""
|
|
layout = manifest.get("save_directory")
|
|
if not layout:
|
|
return "the file list names no save directory for this platform"
|
|
folder = layout["folder"]
|
|
listed = bool(layout["by_core"])
|
|
by_core = listed
|
|
found = None if layout.get("by_system") else _retroarch_config_for(bios_path)
|
|
if found is not None:
|
|
cfg_path, app_dir = found
|
|
for key in ("savefiles_in_content_dir", "sort_savefiles_by_content_enable"):
|
|
if _cfg_true(_android_cfg_value(cfg_path, key)):
|
|
return (f"RetroArch keeps saves beside each game ({key} in {cfg_path}): "
|
|
"name the folder with --save-dir")
|
|
sort = _android_cfg_value(cfg_path, "sort_savefiles_enable")
|
|
if sort is not None:
|
|
by_core = _cfg_true(sort)
|
|
if override is not None:
|
|
return SavePlace(override, by_core, listed)
|
|
directory = _retroarch_cfg_dir(cfg_path, "savefile_directory", app_dir, folder)
|
|
if directory is None or not directory.is_dir():
|
|
directory = app_dir / folder
|
|
return SavePlace(directory, by_core, listed)
|
|
if override is not None:
|
|
return SavePlace(override, by_core, listed)
|
|
base = manifest.get("base_destination") or ""
|
|
if base and bios_path.name == base:
|
|
return SavePlace(bios_path.parent / folder, by_core, listed)
|
|
if not base:
|
|
# The dests start below the folder holding the BIOS folder
|
|
# (RetroDECK's ~/retrodeck holds bios/ and saves/).
|
|
return SavePlace(bios_path / folder, by_core, listed)
|
|
return (f"{bios_path} is not the frontend's default BIOS folder: "
|
|
"name its save directory with --save-dir")
|
|
|
|
|
|
def place_save_entries(
|
|
files: list[dict], bios_path: Path, manifest: dict, override: Path | None,
|
|
display: str,
|
|
) -> tuple[list[dict], list[dict]]:
|
|
"""Find the save directory for the entries read below it.
|
|
|
|
Returns the files to install and, when the save directory is unknown,
|
|
the entries left out as omissions, said once with the reason.
|
|
"""
|
|
saved = [entry for entry in files if entry.get("root") == "save"]
|
|
if not saved:
|
|
return files, []
|
|
place = save_directory_for(bios_path, manifest, override)
|
|
if isinstance(place, SavePlace):
|
|
_SAVE_DIRS[bios_path] = place
|
|
where = str(place.directory)
|
|
if (manifest.get("save_directory") or {}).get("by_system"):
|
|
where += f"{os.sep}<system>"
|
|
if place.by_core:
|
|
where += f"{os.sep}<core>"
|
|
print(f" {_plural(len(saved), 'file')} read from {display}'s save directory "
|
|
f"go to {where}")
|
|
return files, []
|
|
print(f" {_plural(len(saved), 'file')} read from {display}'s save directory "
|
|
f"left out: {place}")
|
|
kept = [entry for entry in files if entry.get("root") != "save"]
|
|
unplaced = [
|
|
{"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name,
|
|
"system": "", "required": False, "reason": "no_save_directory",
|
|
"cores": entry.get("cores"), "root": "save",
|
|
**({"core_folder": entry["core_folder"]} if entry.get("core_folder") else {})}
|
|
for entry in saved
|
|
]
|
|
return kept, unplaced
|
|
|
|
|
|
def _classify_local(entry: dict, dest: Path) -> str:
|
|
"""Return 'up_to_date' or 'mismatched' for a file already on disk."""
|
|
expected = {
|
|
name: entry.get(name, "").lower()
|
|
for name in ("sha256", "sha1")
|
|
if entry.get(name)
|
|
}
|
|
try:
|
|
is_file = dest.is_file()
|
|
except OSError:
|
|
# A folder above it this user cannot enter: what sits there is not
|
|
# known to be the right file.
|
|
return "mismatched"
|
|
if not is_file:
|
|
# A folder at the file's place: --check says so, an install fails
|
|
# on that file alone.
|
|
return "mismatched"
|
|
if not expected:
|
|
return "up_to_date"
|
|
try:
|
|
actual = _digest_file(dest, tuple(expected))
|
|
except OSError:
|
|
# Unreadable (no permission, a read error on a failing card): its
|
|
# contents are not known to be the right ones.
|
|
return "mismatched"
|
|
if all(actual[name] == value for name, value in expected.items()):
|
|
return "up_to_date"
|
|
return "mismatched"
|
|
|
|
|
|
def _held(path: Path) -> bool:
|
|
"""Whether something may sit at path. A place that cannot be looked at
|
|
(a folder above it this user cannot enter, a card that answers a read
|
|
error) counts as held, so the file is reported unreadable and the run
|
|
goes on with the others: Path.exists() lets PermissionError through."""
|
|
try:
|
|
return path.exists()
|
|
except OSError:
|
|
return True
|
|
|
|
|
|
def _exists(path: Path) -> bool:
|
|
"""Path.exists(), False where the place cannot be looked at."""
|
|
try:
|
|
return path.exists()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _is_file(path: Path) -> bool:
|
|
"""Path.is_file(), False where the place cannot be looked at."""
|
|
try:
|
|
return path.is_file()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def unreachable_words(exc: OSError) -> str:
|
|
"""Why the place of a file could not be looked at, in words."""
|
|
return (f"could not be read ({exc.strerror or exc}): this user may not "
|
|
f"have access to its folder")
|
|
|
|
|
|
def check_local(
|
|
files: list[dict], bios_path: Path, jobs: int = 8,
|
|
term: "Terminal | None" = None,
|
|
) -> tuple[list[dict], list[dict], list[dict]]:
|
|
"""Check which files exist locally and have correct hashes.
|
|
|
|
Returns (to_download, up_to_date, mismatched). With a terminal, a count
|
|
moves while the files are read: 3.9 GB on an SD card went by in minutes
|
|
of silence, read as a hung run and stopped with Ctrl+C.
|
|
"""
|
|
to_download: list[dict] = []
|
|
present: list[tuple[dict, Path]] = []
|
|
|
|
for f in files:
|
|
dest = _entry_path(bios_path, f)
|
|
if _held(dest):
|
|
present.append((f, dest))
|
|
else:
|
|
to_download.append(f)
|
|
|
|
up_to_date: list[dict] = []
|
|
mismatched: list[dict] = []
|
|
if present:
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
verdicts = pool.map(
|
|
lambda item: (item[0], _classify_local(item[0], item[1])), present
|
|
)
|
|
live = term is not None and term.live and not term.quiet
|
|
last = evented = 0.0
|
|
for done, (entry, verdict) in enumerate(verdicts, 1):
|
|
if verdict == "up_to_date":
|
|
up_to_date.append(entry)
|
|
else:
|
|
mismatched.append(entry)
|
|
now = time.monotonic()
|
|
# The program driving the installer gets the count the
|
|
# terminal gets: a screen with a bare timer over 6 GB on an
|
|
# SD card read as hung.
|
|
if EVENTS.on and (now - evented >= EVENT_PROGRESS_INTERVAL
|
|
or done == len(present)):
|
|
evented = now
|
|
EVENTS.emit("checking", files_done=done, files_total=len(present))
|
|
if live and (now - last >= 0.1 or done == len(present)):
|
|
last = now
|
|
print(f"\r read {done}/{len(present)}", end="", file=term.stream,
|
|
flush=True)
|
|
if live:
|
|
print("\r\033[K", end="", file=term.stream, flush=True)
|
|
|
|
return to_download, up_to_date, mismatched
|
|
|
|
|
|
# Where a file in place that this run replaces is kept: a folder beside the
|
|
# one written to, never inside it. Several emulators find their BIOS by
|
|
# content, not by name: DuckStation, SwanStation and Rustation read every
|
|
# file of the BIOS folder, BizHawk walks its firmware folder recursively. A
|
|
# kept copy anywhere under that folder could be taken for the BIOS.
|
|
BACKUP_DIR = "retrobios-backup"
|
|
# Written into every run folder the installer creates. --restore only accepts
|
|
# a folder holding it, so a folder someone else placed (a shared BIOS folder
|
|
# on a network share) is never copied over the user's files.
|
|
BACKUP_MARKER = ".retrobios-backup"
|
|
|
|
|
|
def _check_holder(holder: Path) -> os.stat_result:
|
|
"""Refuse a backup holder the user's files must not go into.
|
|
|
|
A link would send them wherever it points. On a system with user ids, a
|
|
folder another user owns was not made by this user's runs, and its owner
|
|
could read or swap what is moved in. Returns what was checked, so the
|
|
caller can confirm the same folder is still there once it has written
|
|
into it.
|
|
"""
|
|
info = os.lstat(holder)
|
|
if not stat.S_ISDIR(info.st_mode):
|
|
raise OSError(errno.ELOOP, f"{holder} is a link or not a folder")
|
|
getuid = getattr(os, "getuid", None)
|
|
if getuid is not None and info.st_uid != getuid():
|
|
raise OSError(errno.EPERM, f"{holder} belongs to another user")
|
|
return info
|
|
|
|
|
|
def _same_folder(path: Path, checked: os.stat_result) -> bool:
|
|
"""Whether path is still the folder checked, not a link swapped in since."""
|
|
now = os.lstat(path)
|
|
return (stat.S_ISDIR(now.st_mode)
|
|
and (now.st_dev, now.st_ino) == (checked.st_dev, checked.st_ino))
|
|
|
|
|
|
class _NotKept(Exception):
|
|
"""The file in place differs and could not be set aside: it stays."""
|
|
|
|
|
|
class KeptFiles:
|
|
"""The files this run moved aside before replacing them.
|
|
|
|
A file is kept at <holder>/<run>/<folder name>/<path below the folder>,
|
|
the holder being retrobios-backup beside the folder written to. The run
|
|
folder is named by date and time, so a second run never mixes with the
|
|
first, and copying its contents into the holder's parent puts every file
|
|
back where it was. Only when nothing can be written beside the folder
|
|
(a drive root, a read-only parent) does the holder go inside it, with
|
|
no folder name, and the same copy puts the files back.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self.stamp = ""
|
|
# Holder -> the run folder this run created in it.
|
|
self._runs: dict[Path, Path] = {}
|
|
# Run folder -> [files, bytes] kept (or, under --dry-run, to keep).
|
|
self.counts: dict[Path, list[int]] = {}
|
|
|
|
def reset(self) -> None:
|
|
with self._lock:
|
|
self.stamp = ""
|
|
self._runs.clear()
|
|
self.counts.clear()
|
|
|
|
def _stamp(self) -> str:
|
|
if not self.stamp:
|
|
self.stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
return self.stamp
|
|
|
|
@staticmethod
|
|
def _holders(folder: Path) -> list[tuple[Path, str]]:
|
|
beside = [(folder.parent / BACKUP_DIR, folder.name)] if (
|
|
folder.name and folder.parent != folder) else []
|
|
return beside + [(folder / BACKUP_DIR, "")]
|
|
|
|
def place(self, folder: Path, create: bool) -> tuple[Path, Path]:
|
|
"""(run folder, folder the relative path goes under) for files of folder."""
|
|
with self._lock:
|
|
stamp = self._stamp()
|
|
failure: OSError | None = None
|
|
for holder, prefix in self._holders(folder):
|
|
if not create:
|
|
return holder / stamp, holder / stamp / prefix
|
|
run = self._runs.get(holder)
|
|
if run is None:
|
|
try:
|
|
holder.mkdir(parents=True, exist_ok=True)
|
|
checked = _check_holder(holder)
|
|
for n in range(1, 1000):
|
|
run = holder / (stamp if n == 1 else f"{stamp}-{n}")
|
|
try:
|
|
run.mkdir(mode=0o700)
|
|
break
|
|
except FileExistsError:
|
|
continue
|
|
else:
|
|
# Every name taken: never reuse a folder this run
|
|
# did not create.
|
|
raise OSError(errno.EEXIST, f"no free folder name in {holder}")
|
|
# The holder checked before is still the one the run
|
|
# folder was made in, and the run folder is a folder.
|
|
if not (_same_folder(holder, checked)
|
|
and stat.S_ISDIR(os.lstat(run).st_mode)):
|
|
raise OSError(errno.ELOOP, f"{holder} changed while in use")
|
|
(run / BACKUP_MARKER).write_text(
|
|
"Files the RetroBIOS installer replaced.\n", encoding="utf-8")
|
|
except OSError as exc:
|
|
failure = exc
|
|
continue
|
|
self._runs[holder] = run
|
|
return run, run / prefix
|
|
raise failure or OSError(errno.EEXIST, "no backup folder name left")
|
|
|
|
def plan(self, folder: Path, size: int) -> Path:
|
|
"""Count a file a real run would keep, writing nothing. Returns its run folder."""
|
|
run, _under = self.place(folder, create=False)
|
|
with self._lock:
|
|
tally = self.counts.setdefault(run, [0, 0])
|
|
tally[0] += 1
|
|
tally[1] += size
|
|
return run
|
|
|
|
def keep(
|
|
self, folder: Path, rel: PurePosixPath, dest: Path,
|
|
expected: dict[str, str], fresh: Path | None = None,
|
|
) -> None:
|
|
"""Move the file at dest aside when its content is not the one coming.
|
|
|
|
Nothing is done for a missing file, a link (its target is not
|
|
touched by the replace) or a file already holding the content, by
|
|
the manifest's hashes or, without them, byte for byte against the
|
|
fresh copy. Raises _NotKept when the file could not be set aside:
|
|
the replace must not happen then.
|
|
"""
|
|
try:
|
|
info = os.lstat(dest)
|
|
except FileNotFoundError:
|
|
return
|
|
except OSError as exc:
|
|
raise _NotKept(exc.strerror or str(exc)) from exc
|
|
if not stat.S_ISREG(info.st_mode):
|
|
return
|
|
try:
|
|
if expected:
|
|
same = _digest_file(dest, tuple(expected)) == expected
|
|
else:
|
|
same = fresh is not None and _same_bytes(dest, fresh)
|
|
except FileNotFoundError:
|
|
return # another run replaced it meanwhile
|
|
except OSError as exc:
|
|
raise _NotKept(exc.strerror or str(exc)) from exc
|
|
if same:
|
|
return
|
|
try:
|
|
run, under = self.place(folder, create=True)
|
|
kept = under / Path(*rel.parts)
|
|
if os.path.lexists(kept):
|
|
# This run kept the user's file at that path already: the
|
|
# one in place now is one this run wrote.
|
|
return
|
|
kept.parent.mkdir(parents=True, exist_ok=True)
|
|
_move_aside(dest, kept)
|
|
except FileNotFoundError:
|
|
if not os.path.lexists(dest):
|
|
return # another run moved or replaced it meanwhile
|
|
raise _NotKept("the backup folder could not be written") from None
|
|
except OSError as exc:
|
|
raise _NotKept(exc.strerror or str(exc)) from exc
|
|
with self._lock:
|
|
tally = self.counts.setdefault(run, [0, 0])
|
|
tally[0] += 1
|
|
tally[1] += info.st_size
|
|
|
|
def summary(self) -> list[dict]:
|
|
"""[{folder, files, bytes}], one per run folder, for the report and events."""
|
|
with self._lock:
|
|
return [{"folder": str(run), "files": files, "bytes": size}
|
|
for run, (files, size) in sorted(self.counts.items())]
|
|
|
|
|
|
KEPT = KeptFiles()
|
|
|
|
|
|
def _same_bytes(a: Path, b: Path) -> bool:
|
|
if a.stat().st_size != b.stat().st_size:
|
|
return False
|
|
return _digest_file(a, ("sha1",)) == _digest_file(b, ("sha1",))
|
|
|
|
|
|
def _move_aside(dest: Path, kept: Path) -> None:
|
|
"""Move dest to kept: a rename on one drive, else a copy then a delete.
|
|
|
|
The copy goes under a temporary name first, so an interrupted copy
|
|
never leaves a cut file under the kept name, and dest is only deleted
|
|
once its copy is whole.
|
|
"""
|
|
try:
|
|
os.rename(dest, kept)
|
|
return
|
|
except OSError:
|
|
if not _is_file(dest):
|
|
raise
|
|
scratch = kept.parent / f".{kept.name}.part"
|
|
try:
|
|
shutil.copy2(dest, scratch)
|
|
os.replace(scratch, kept)
|
|
except OSError:
|
|
if os.path.lexists(scratch):
|
|
os.unlink(scratch)
|
|
raise
|
|
dest.unlink()
|
|
|
|
|
|
def _kept_slot(root: Path, value: object) -> tuple[Path, PurePosixPath]:
|
|
"""The folder a manifest destination is written in, and its path below
|
|
it, as _destination_path places it: a RetroDECK folder moved elsewhere
|
|
is its own folder."""
|
|
relative = _safe_relative_path(value, "dest")
|
|
moved = _MOVED_FOLDERS.get(root, {})
|
|
if len(relative.parts) > 1 and relative.parts[0] in moved:
|
|
return moved[relative.parts[0]].resolve(), PurePosixPath(*relative.parts[1:])
|
|
return root.resolve(), relative
|
|
|
|
|
|
def plan_kept(entries: list[dict], root: Path) -> dict[Path, list[int]]:
|
|
"""Count, under --dry-run, the files in place a real run would keep.
|
|
|
|
entries are files whose content check failed. Returns {run folder:
|
|
[files, bytes]} for these entries, also counted in KEPT for the summary.
|
|
"""
|
|
planned: dict[Path, list[int]] = {}
|
|
for entry in entries:
|
|
try:
|
|
dest = _entry_path(root, entry)
|
|
info = os.lstat(dest)
|
|
except (OSError, ValueError):
|
|
continue
|
|
if stat.S_ISREG(info.st_mode):
|
|
run = KEPT.plan(_kept_slot(*_entry_place(root, entry))[0], info.st_size)
|
|
tally = planned.setdefault(run, [0, 0])
|
|
tally[0] += 1
|
|
tally[1] += info.st_size
|
|
return planned
|
|
|
|
|
|
def _kept_lines() -> list[str]:
|
|
"""Where the files this run replaced are, and how to put them back."""
|
|
lines = []
|
|
for item in KEPT.summary():
|
|
folder = Path(item["folder"])
|
|
lines.append(f"Kept {_plural(item['files'], 'file')} you had "
|
|
f"({format_size(item['bytes'])}), which the collection's "
|
|
f"copies replaced, in {folder}")
|
|
lines.append(f" To put them back: {_INVOCATION.launcher()} --restore "
|
|
f"{_INVOCATION.word(str(folder))}")
|
|
return lines
|
|
|
|
|
|
def restore_kept(run: Path, dry_run: bool = False) -> int:
|
|
"""Put every file of a backup run folder back where it was.
|
|
|
|
run is a <holder>/<date-time> folder a run wrote, its holder named
|
|
retrobios-backup. Each file goes to the same path below the holder's
|
|
parent, replacing what is there. The backup is left in place. Returns
|
|
the exit code: 0 when every file went back, 1 when one failed, 2 when
|
|
the folder is no backup.
|
|
"""
|
|
run = Path(os.path.abspath(os.path.expanduser(str(run))))
|
|
if (not run.is_dir() or run.is_symlink() or run.parent.name != BACKUP_DIR
|
|
or run.parent.is_symlink() or not (run / BACKUP_MARKER).is_file()):
|
|
print(f"Error: {run} is not a folder the installer kept files in.", file=sys.stderr)
|
|
print(f" Name one of the dated folders inside a {BACKUP_DIR} folder, as the "
|
|
"end of the install printed it.", file=sys.stderr)
|
|
return 2
|
|
root = run.parent.parent
|
|
# Links inside the backup are never followed: os.walk does not descend
|
|
# into a linked folder and a linked file is skipped.
|
|
files = sorted(
|
|
Path(base) / name
|
|
for base, _dirs, names in os.walk(run, followlinks=False)
|
|
for name in names
|
|
if not (Path(base) / name).is_symlink()
|
|
and not (name.startswith(".") and name.endswith(".part"))
|
|
and not (Path(base) == run and name == BACKUP_MARKER)
|
|
)
|
|
if not files:
|
|
print(f"{run} holds no file. Nothing to put back.")
|
|
return 0
|
|
restored = size = failed = 0
|
|
for src in files:
|
|
dest = root / src.relative_to(run)
|
|
if dry_run:
|
|
print(f" would put back {dest}")
|
|
restored += 1
|
|
size += src.stat().st_size
|
|
continue
|
|
try:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
_clear_read_only(dest)
|
|
_replace_with_copy(src, dest)
|
|
except OSError as exc:
|
|
print(f" failed: {dest} ({exc.strerror or exc})", file=sys.stderr)
|
|
failed += 1
|
|
continue
|
|
restored += 1
|
|
size += src.stat().st_size
|
|
if dry_run:
|
|
print(f"\nWould put back {_plural(restored, 'file')} ({format_size(size)}) "
|
|
f"under {root}. Nothing was written.")
|
|
return 0
|
|
print(f"\nPut back {_plural(restored, 'file')} ({format_size(size)}) under {root}.")
|
|
if failed:
|
|
print(f"{_plural(failed, 'file')} could not be put back.", file=sys.stderr)
|
|
print(f"The copies are still in {run}. Delete that folder once you no longer need it.")
|
|
return 1 if failed else 0
|
|
|
|
|
|
def _download_one(
|
|
f: dict, bios_path: Path, verbose: bool = False,
|
|
progress: Progress | None = None, copy: LocalCopy | None = None,
|
|
) -> tuple[str, bool, str]:
|
|
"""Download a single file, or copy it from where copy found it on this
|
|
machine. Returns (dest, success, reason it failed)."""
|
|
if _stop.is_set():
|
|
return str(f.get("dest", "?")), False, "stopped"
|
|
try:
|
|
dest = _entry_path(bios_path, f)
|
|
except ValueError:
|
|
return str(f.get("dest", "?")), False, "unsafe destination"
|
|
try:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
if _write_refused(exc):
|
|
return f["dest"], False, NOT_WRITABLE
|
|
return f["dest"], False, exc.strerror or str(exc)
|
|
try:
|
|
in_place_folder = dest.is_dir()
|
|
except OSError as exc:
|
|
return f["dest"], False, unreachable_words(exc)
|
|
if in_place_folder:
|
|
# Fetched, it failed only at the rename, on an errno and two paths.
|
|
return f["dest"], False, FOLDER_IN_PLACE
|
|
|
|
if copy is not None:
|
|
result = _copy_local(f, bios_path, dest, copy, progress)
|
|
if result[1]:
|
|
copy.index.count_copy(copy.misplaced)
|
|
return result
|
|
if result[2] in ("stopped", NOT_WRITABLE, READ_ONLY) or result[2].startswith(NOT_KEPT):
|
|
return result
|
|
# The copy went or changed since it was read (a card pulled out, a
|
|
# file another run replaced): the file is fetched as if never found,
|
|
# and a fetch that fails too names both reasons.
|
|
if verbose:
|
|
print(f" {f['dest']}: {result[2]}, fetching it instead", file=sys.stderr)
|
|
fetched = _download_one(f, bios_path, verbose, progress)
|
|
why = fetched[2]
|
|
if (fetched[1] or why in ("stopped", NOT_WRITABLE, READ_ONLY)
|
|
or why.startswith(NOT_KEPT)):
|
|
return fetched
|
|
both = f"{result[2]}, and {why}"
|
|
# The fetch's kind of failure stays, so the advice is the right one.
|
|
kind = type(why) if isinstance(why, (NetworkReason, OfflineReason)) else str
|
|
return fetched[0], False, kind(both)
|
|
|
|
if LOCAL.pack is not None:
|
|
return _extract_from_pack(f, bios_path, dest, progress)
|
|
if offline_requested():
|
|
return f["dest"], False, OfflineReason(f"not fetched: {OFFLINE_WORDS}")
|
|
if f.get("release_asset"):
|
|
url = RELEASE_URL.format(asset=urllib.parse.quote(f["release_asset"], safe=""))
|
|
else:
|
|
url = RAW_FILE_URL.format(path=urllib.parse.quote(f["repo_path"], safe="/"))
|
|
|
|
expected_size = f["size"]
|
|
# A large file keeps a fixed partial name so the next attempt, or the
|
|
# next run, asks only for what is missing.
|
|
resumable = expected_size >= RESUME_FROM_SIZE
|
|
reason = "download failed"
|
|
refused = False
|
|
full_partial = False
|
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
tmp_path: Path | None = None
|
|
counted = 0
|
|
resumed_count = 0
|
|
partial = None
|
|
try:
|
|
offset = 0
|
|
if resumable:
|
|
tmp_path = dest.parent / f".{dest.name}.part"
|
|
_partial_size(tmp_path)
|
|
# Two runs into one BIOS folder used to append to the same
|
|
# partial copy: the one that finds it locked downloads into
|
|
# a private file instead.
|
|
partial = _claim_partial(tmp_path)
|
|
if partial is None:
|
|
tmp_path = None
|
|
else:
|
|
offset = os.fstat(partial.fileno()).st_size
|
|
if offset >= expected_size and _NO_RENAME_WHILE_OPEN:
|
|
# The run that verified this copy releases its lock
|
|
# to rename it: truncating it there destroyed that
|
|
# copy and failed the rename. It is left alone and
|
|
# this run writes a private file.
|
|
partial.close()
|
|
partial = None
|
|
tmp_path = None
|
|
offset = 0
|
|
full_partial = True
|
|
elif offset >= expected_size:
|
|
partial.truncate(0)
|
|
offset = 0
|
|
request = urllib.request.Request(url)
|
|
if offset:
|
|
request.add_header("Range", f"bytes={offset}-")
|
|
with urllib.request.urlopen(request, timeout=60) as resp:
|
|
if offset and getattr(resp, "status", 200) != 206:
|
|
offset = 0 # the server sent the whole file again
|
|
partial.truncate(0)
|
|
raw_length = resp.headers.get("Content-Length") if resp.headers else None
|
|
if raw_length and int(raw_length) != expected_size - offset:
|
|
raise ValueError(
|
|
f"Content-Length {raw_length} != expected {expected_size - offset}"
|
|
)
|
|
if partial is not None:
|
|
partial.seek(offset)
|
|
out = _Unclosed(partial)
|
|
else:
|
|
out = tempfile.NamedTemporaryFile(
|
|
mode="wb", dir=dest.parent, prefix=f".{dest.name}.",
|
|
suffix=".part", delete=False,
|
|
)
|
|
tmp_path = Path(out.name)
|
|
with out:
|
|
downloaded = offset
|
|
if progress is not None and offset:
|
|
progress.add(offset, resumed=True)
|
|
counted += offset
|
|
resumed_count = offset
|
|
while True:
|
|
if _stop.is_set():
|
|
raise _Interrupted()
|
|
chunk = resp.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
downloaded += len(chunk)
|
|
if downloaded > expected_size or downloaded > MAX_DOWNLOAD_SIZE:
|
|
raise ValueError("download exceeded declared size")
|
|
out.write(chunk)
|
|
if progress is not None:
|
|
progress.add(len(chunk))
|
|
counted += len(chunk)
|
|
if downloaded != expected_size:
|
|
raise ValueError(
|
|
f"downloaded {downloaded} bytes, expected {expected_size}"
|
|
)
|
|
|
|
expected = {
|
|
name: f[name].lower() for name in ("sha256", "sha1") if f.get(name)
|
|
}
|
|
# A large file is hashed through the handle that holds its lock.
|
|
# On Windows that lock is mandatory: reopening the path to read
|
|
# it failed with a sharing violation, which read as an unwritable
|
|
# folder for every file of 16 MB or more (issue 82).
|
|
if partial is not None:
|
|
actual = _digest_handle(partial, tuple(expected))
|
|
else:
|
|
actual = _digest_file(tmp_path, tuple(expected))
|
|
if "sha256" in expected and actual["sha256"] != expected["sha256"]:
|
|
reason = "SHA-256 mismatch"
|
|
raise _Corrupt()
|
|
if "sha1" in expected and actual["sha1"] != expected["sha1"]:
|
|
reason = "SHA-1 mismatch"
|
|
raise _Corrupt()
|
|
|
|
# The mode a plain open() gives, executable where the repository
|
|
# file is: the temporary file is private, and a BIOS folder
|
|
# written with sudo has to stay readable by the emulator's user.
|
|
os.chmod(tmp_path, (0o777 if f.get("executable") else 0o666) & ~_UMASK)
|
|
_clear_read_only(dest)
|
|
# The file in place, a personal dump or another revision, is
|
|
# moved aside first: replaced, it was gone for good.
|
|
KEPT.keep(*_kept_slot(*_entry_place(bios_path, f)), dest, expected, tmp_path)
|
|
if partial is not None and _NO_RENAME_WHILE_OPEN:
|
|
# Windows renames no open file. Elsewhere the lock is held
|
|
# until the copy has its final name.
|
|
partial.close()
|
|
_replace_released(tmp_path, dest, expected)
|
|
else:
|
|
os.replace(tmp_path, dest)
|
|
if full_partial:
|
|
_drop_full_partial(dest, expected_size)
|
|
return f["dest"], True, ""
|
|
|
|
except _Interrupted:
|
|
# Stopped mid-transfer: a large file keeps its partial copy for
|
|
# the next run, and a small one is dropped rather than left beside
|
|
# the destination.
|
|
if tmp_path is not None and partial is None and tmp_path.exists():
|
|
tmp_path.unlink()
|
|
return f["dest"], False, "stopped"
|
|
|
|
except _NotKept as exc:
|
|
# No attempt changes it, and the file in place stays as it is.
|
|
if partial is None and tmp_path is not None and tmp_path.exists():
|
|
tmp_path.unlink()
|
|
return f["dest"], False, f"{NOT_KEPT} ({exc})"
|
|
|
|
except _Corrupt:
|
|
if verbose:
|
|
print(f" {reason} on attempt {attempt}", file=sys.stderr)
|
|
# Closed before the delete: Windows refuses to remove a file
|
|
# still open (WinError 32), and the error ended the run.
|
|
if partial is not None:
|
|
partial.close()
|
|
if tmp_path is not None and tmp_path.exists():
|
|
tmp_path.unlink()
|
|
delay = 0.0
|
|
|
|
except urllib.error.HTTPError as exc:
|
|
reason = f"HTTP {exc.code}"
|
|
if exc.code in (403, 429):
|
|
reason += " (GitHub rate limit or access refused)"
|
|
if exc.code >= 500 or exc.code == 429:
|
|
reason = NetworkReason(reason)
|
|
if verbose:
|
|
print(f" Attempt {attempt} failed: {exc}", file=sys.stderr)
|
|
if tmp_path is not None and partial is None and tmp_path.exists():
|
|
tmp_path.unlink()
|
|
if exc.code == 416 and tmp_path is not None and tmp_path.exists():
|
|
if partial is not None:
|
|
partial.close()
|
|
tmp_path.unlink() # the partial copy no longer fits the file
|
|
delay = _retry_delay(attempt, exc.headers.get("Retry-After") if exc.headers else None)
|
|
|
|
# http.client raises IncompleteRead (an HTTPException, not an
|
|
# OSError) when a chunked transfer is cut, as a proxy re-encoding
|
|
# the stream does: it is a dropped connection like any other.
|
|
except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException) as exc:
|
|
if (isinstance(exc, OSError) and not isinstance(exc, urllib.error.URLError)
|
|
and _write_refused(exc)):
|
|
# No attempt can change a refused write. A read-only file in
|
|
# place is not a folder this user cannot write.
|
|
reason = READ_ONLY if _read_only_file(dest) else NOT_WRITABLE
|
|
refused = True
|
|
elif _transfer_failure(exc):
|
|
reason = NetworkReason(network_words(exc)[1])
|
|
else:
|
|
reason = str(getattr(exc, "reason", "") or exc)
|
|
if verbose:
|
|
print(f" Attempt {attempt} failed: {exc}", file=sys.stderr)
|
|
if not refused and tmp_path is not None and partial is None and tmp_path.exists():
|
|
tmp_path.unlink()
|
|
delay = _retry_delay(attempt, None)
|
|
|
|
finally:
|
|
if partial is not None:
|
|
partial.close()
|
|
|
|
if progress is not None and counted:
|
|
progress.add(-counted)
|
|
if resumed_count:
|
|
progress.forget_resumed(resumed_count)
|
|
if refused:
|
|
return f["dest"], False, reason
|
|
if attempt < MAX_RETRIES and delay and _stop.wait(delay):
|
|
return f["dest"], False, "stopped"
|
|
|
|
return f["dest"], False, reason
|
|
|
|
|
|
# Windows renames no file another handle holds open, and Python opens files
|
|
# without FILE_SHARE_DELETE: a locked partial copy is closed before its rename.
|
|
_NO_RENAME_WHILE_OPEN = os.name == "nt"
|
|
# Seconds a closed partial copy waits for a sharing violation to clear: a
|
|
# second run closing it again, or a scanner reading the new file.
|
|
RELEASE_WAIT = 30.0
|
|
_SHARING_VIOLATIONS = (32, 33)
|
|
|
|
|
|
def _replace_released(source: Path, dest: Path, expected: dict[str, str]) -> None:
|
|
"""Rename a verified copy whose lock was just released.
|
|
|
|
Another run may open the copy in that moment, which refuses the rename
|
|
with a sharing violation until it lets go. When the copy is gone, that
|
|
run placed the same file and removed it: the destination is checked
|
|
against the manifest's hashes before it counts as installed.
|
|
"""
|
|
deadline = time.monotonic() + RELEASE_WAIT
|
|
while True:
|
|
try:
|
|
os.replace(source, dest)
|
|
return
|
|
except FileNotFoundError:
|
|
if expected and _is_file(dest) and _digest_file(dest, tuple(expected)) == expected:
|
|
return
|
|
raise
|
|
except PermissionError as exc:
|
|
if (getattr(exc, "winerror", None) not in _SHARING_VIOLATIONS
|
|
or time.monotonic() >= deadline):
|
|
raise
|
|
time.sleep(0.2)
|
|
|
|
|
|
def _drop_full_partial(dest: Path, expected_size: int) -> None:
|
|
"""Remove a complete partial copy this run left alone.
|
|
|
|
A copy at full size that nothing holds is what a run stopped between its
|
|
check and its rename leaves behind. The destination now holds the
|
|
verified file, so the copy is only clutter. One another run holds is
|
|
left to it.
|
|
"""
|
|
path = dest.parent / f".{dest.name}.part"
|
|
if not _is_file(path):
|
|
return
|
|
try:
|
|
handle = _claim_partial(path)
|
|
except OSError:
|
|
return
|
|
if handle is None:
|
|
return
|
|
with handle:
|
|
complete = os.fstat(handle.fileno()).st_size >= expected_size
|
|
if complete:
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass # another run opened it meanwhile and finishes with it
|
|
else:
|
|
try:
|
|
if path.stat().st_size == 0:
|
|
path.unlink()
|
|
except OSError:
|
|
pass # created by the claim and taken since by another run
|
|
|
|
|
|
def _partial_size(path: Path) -> int:
|
|
"""Bytes already held by a partial copy. Anything but a plain file is dropped."""
|
|
try:
|
|
info = path.lstat()
|
|
except FileNotFoundError:
|
|
return 0
|
|
if stat.S_ISREG(info.st_mode):
|
|
return info.st_size
|
|
path.unlink()
|
|
return 0
|
|
|
|
|
|
def _claim_partial(path: Path) -> BinaryIO | None:
|
|
"""The partial copy, opened and locked for this run, or None when
|
|
another run holds it. A link planted at its name is not followed."""
|
|
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
|
handle = os.fdopen(os.open(path, flags, 0o644), "r+b")
|
|
try:
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
handle.close()
|
|
return None
|
|
# Opened and locked are two steps: in between, the run that held the
|
|
# partial may have renamed it into place and released it. The lock then
|
|
# sits on the installed BIOS, which looks like a finished partial and
|
|
# would be truncated. Only the file still under the partial's name is ours.
|
|
try:
|
|
ours = os.path.samestat(os.fstat(handle.fileno()), os.lstat(path))
|
|
except OSError:
|
|
ours = False
|
|
if not ours:
|
|
handle.close()
|
|
return None
|
|
return handle
|
|
|
|
|
|
class _Unclosed:
|
|
"""The locked partial copy, written through without closing it."""
|
|
|
|
def __init__(self, handle: BinaryIO) -> None:
|
|
self._handle = handle
|
|
|
|
def __enter__(self) -> BinaryIO:
|
|
return self._handle
|
|
|
|
def __exit__(self, *_exc: object) -> None:
|
|
self._handle.flush()
|
|
|
|
def write(self, data: bytes) -> int:
|
|
return self._handle.write(data)
|
|
|
|
|
|
class _Corrupt(Exception):
|
|
"""The bytes arrived but are not the file the manifest names."""
|
|
|
|
|
|
def _retry_delay(attempt: int, retry_after: str | None) -> float:
|
|
"""Seconds before the next attempt: what the server asks, else 1, 2, 4."""
|
|
if retry_after:
|
|
try:
|
|
return min(max(float(retry_after), 0.0), 60.0)
|
|
except ValueError:
|
|
pass
|
|
return float(min(2 ** (attempt - 1), 8))
|
|
|
|
|
|
def _locations(paths: list[Path]) -> str:
|
|
"""Where the run wrote, naming every destination when it served several."""
|
|
return ", ".join(str(p) for p in paths) if paths else "nowhere"
|
|
|
|
|
|
# A write refused by the file system: retrying cannot change it, and the
|
|
# summary names the two ways out.
|
|
NOT_WRITABLE = "folder not writable"
|
|
|
|
|
|
def _write_refused(exc: OSError) -> bool:
|
|
# A sharing violation is a file another process holds open, which a
|
|
# later attempt can find released: no refusal of the folder.
|
|
if getattr(exc, "winerror", None) in _SHARING_VIOLATIONS:
|
|
return False
|
|
return isinstance(exc, PermissionError) or exc.errno == errno.EROFS
|
|
|
|
|
|
# A file already at the destination, marked read-only, whose content differs,
|
|
# and whose attribute could not be cleared: replacing it is refused while its
|
|
# folder stays writable.
|
|
READ_ONLY = "file in place is read-only"
|
|
# A file in place whose content differs, which could not be moved aside: it
|
|
# is left as it is rather than lost.
|
|
NOT_KEPT = "file in place could not be kept aside"
|
|
FOLDER_IN_PLACE = "a folder sits where this file goes: move it away, then run again"
|
|
|
|
|
|
def _clear_read_only(dest: Path) -> None:
|
|
"""Let the verified copy replace a read-only file the user already has.
|
|
|
|
Windows refuses to replace a file carrying the read-only attribute
|
|
(WinError 5) even in a writable folder. The file is about to be replaced
|
|
by the copy the manifest names, so its attribute is cleared first.
|
|
Elsewhere a rename only needs the folder to be writable.
|
|
"""
|
|
if os.name != "nt":
|
|
return
|
|
try:
|
|
if dest.is_file() and not os.access(dest, os.W_OK):
|
|
os.chmod(dest, stat.S_IREAD | stat.S_IWRITE)
|
|
except OSError:
|
|
pass # the replace that follows reports the refusal with its reason
|
|
|
|
|
|
def _read_only_file(dest: Path) -> bool:
|
|
return _is_file(dest) and not os.access(dest, os.W_OK) and _folder_writable(dest.parent)
|
|
|
|
|
|
def _folder_writable(path: Path) -> bool:
|
|
"""Whether a file can be created under path, read from its nearest
|
|
existing folder: a BIOS folder not created yet is as writable as the
|
|
folder it would go in."""
|
|
probe = path
|
|
while not probe.exists() and probe.parent != probe:
|
|
probe = probe.parent
|
|
return os.access(probe, os.W_OK)
|
|
|
|
|
|
# Options that take a value, as a hint gives them back.
|
|
_VALUED_OPTIONS = (
|
|
"--system", "--core", "--emulator", "--region", "--target", "--cores-dir", "--save-dir",
|
|
"--from-pack", "--from", "--manifest",
|
|
)
|
|
|
|
|
|
class Invocation:
|
|
"""How this run was started, so that a hint gives the command back whole.
|
|
|
|
A one-liner user has no install.py to run: install.sh and install.ps1 run
|
|
a temporary copy and say so in RETROBIOS_BOOTSTRAP. And a hint that
|
|
dropped --system turned a retry of 2 files into one of 8069.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.os_type = ""
|
|
self.options: list[tuple[str, str | None]] = []
|
|
# Per platform, what was chosen at the questions.
|
|
self.chosen: dict[str, list[tuple[str, str | None]]] = {}
|
|
|
|
def record(self, args: argparse.Namespace) -> None:
|
|
"""Keep every option that decides what a run installs or reports."""
|
|
options: list[tuple[str, str | None]] = []
|
|
for flag in _VALUED_OPTIONS:
|
|
value = getattr(args, flag[2:].replace("-", "_"), None)
|
|
values = value if isinstance(value, list) else [value] if value else []
|
|
options += [(flag, str(v)) for v in values]
|
|
for flag, on in (
|
|
("--every-board", args.every_board),
|
|
("--installed-cores", args.installed_cores and not args.cores_dir),
|
|
("--no-game-data", args.no_game_data),
|
|
("--declared-only", args.declared_only),
|
|
("--standalone-copies", args.standalone_copies),
|
|
("--offline", getattr(args, "offline", False)),
|
|
("--check", args.check),
|
|
("--dry-run", args.dry_run),
|
|
):
|
|
if on:
|
|
options.append((flag, None))
|
|
self.options = options
|
|
|
|
def powershell(self) -> bool:
|
|
return os.environ.get("RETROBIOS_BOOTSTRAP") == "ps1" or self.os_type == "windows"
|
|
|
|
def launcher(self, elevated: bool = False) -> str:
|
|
"""The command without its options, as the user typed it."""
|
|
bootstrap = os.environ.get("RETROBIOS_BOOTSTRAP", "")
|
|
if bootstrap == "ps1":
|
|
return f"&([scriptblock]::Create((irm {BOOTSTRAP_URL}.ps1)))"
|
|
if bootstrap == "sh":
|
|
return f"curl -fsSL {BOOTSTRAP_URL}.sh | {'sudo ' if elevated else ''}sh -s --"
|
|
python = "python" if self.os_type == "windows" else "python3"
|
|
return f"{'sudo ' if elevated else ''}{python} install.py"
|
|
|
|
def word(self, value: str) -> str:
|
|
if self.powershell():
|
|
# A comma makes an array and @ a splat in PowerShell arguments.
|
|
if re.fullmatch(r"[A-Za-z0-9_./:\\-]+", value):
|
|
return value
|
|
return "'" + value.replace("'", "''") + "'"
|
|
return shlex.quote(value)
|
|
|
|
def command(
|
|
self, plat: str, dest: Path | str | None, elevated: bool = False,
|
|
without: tuple[str, ...] = (),
|
|
) -> str:
|
|
"""The full command for plat and dest, a str dest being a placeholder."""
|
|
words = [self.launcher(elevated)]
|
|
if plat:
|
|
words += ["--platform", plat]
|
|
if isinstance(dest, Path):
|
|
words += ["--dest", self.word(str(dest.absolute()))]
|
|
elif dest:
|
|
words += ["--dest", dest]
|
|
for flag, value in [*self.options, *self.chosen.get(plat, [])]:
|
|
if flag in without:
|
|
continue
|
|
words += [flag] if value is None else [flag, self.word(value)]
|
|
return " ".join(words)
|
|
|
|
|
|
_INVOCATION = Invocation()
|
|
|
|
|
|
def _on_read_only_mount(path: Path) -> bool:
|
|
"""Whether the file system holding path, or the folder it would go in,
|
|
is mounted read-only (a card Linux remounts read-only after an error)."""
|
|
if not hasattr(os, "statvfs"):
|
|
return False
|
|
probe = path
|
|
while not _held(probe) and probe.parent != probe:
|
|
probe = probe.parent
|
|
try:
|
|
return bool(os.statvfs(probe).f_flag & os.ST_RDONLY)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _write_advice(path: Path, assumed: bool, plat: str = "") -> str:
|
|
"""What to do about a folder this run could not write.
|
|
|
|
sudo fixes a root-owned BIOS folder on the console itself. On another
|
|
machine it would create that folder on the wrong disk: a platform named
|
|
but not found here is a card or share mounted elsewhere, named by --dest.
|
|
Windows has no sudo, and Termux reaches shared storage once
|
|
termux-setup-storage has run. A read-only mount refuses root as well,
|
|
so sudo is never offered for it.
|
|
"""
|
|
if _on_read_only_mount(path):
|
|
again = f"\n {_INVOCATION.command(plat, path)}" if plat else ""
|
|
return (f" {path} is on a file system mounted read-only, which no user can "
|
|
"write, sudo included. Mount it read-write again (a card or drive "
|
|
"the system switched to read-only after an error needs a check "
|
|
"first: fsck, or chkdsk on Windows), then run the same command "
|
|
f"again.{again}")
|
|
if assumed:
|
|
return (f" {path} is where the platform keeps its BIOS on its own system, "
|
|
"and it was not found here. If this machine prepares its card or "
|
|
"share, run it again with --dest pointing at that folder:\n"
|
|
f" {_INVOCATION.command(plat, '<that folder>')}")
|
|
if plat and _INVOCATION.os_type == "windows":
|
|
return (" This user cannot write there. Run it again from a PowerShell window "
|
|
"opened as administrator:\n"
|
|
f" {_INVOCATION.command(plat, path)}\n"
|
|
" Or choose another folder with --dest.")
|
|
if plat and _INVOCATION.os_type == "android":
|
|
return (" This user cannot write there. Run termux-setup-storage once to give "
|
|
"Termux the shared storage, then run it again:\n"
|
|
f" {_INVOCATION.command(plat, path)}\n"
|
|
" Or choose another folder with --dest.")
|
|
if plat:
|
|
# sudo resets HOME to root's on Debian, Ubuntu, Arch and SteamOS:
|
|
# the same command detected nothing there, fell back to a folder
|
|
# under /root and reported success.
|
|
return (" This user cannot write there. Run it again with sudo, naming the "
|
|
"folder so root does not look in its own home:\n"
|
|
f" {_INVOCATION.command(plat, path, elevated=True)}\n"
|
|
" Or choose another folder with --dest.")
|
|
if _INVOCATION.os_type == "windows":
|
|
return (" This user cannot write there: run the same command from a PowerShell "
|
|
"window opened as administrator, or choose another folder with --dest.")
|
|
return (" This user cannot write there: run the same command with sudo, "
|
|
"or choose another folder with --dest.")
|
|
|
|
|
|
def _plural(count: int, word: str) -> str:
|
|
"""Count and noun, so a run of one file does not read as machine output."""
|
|
return f"{count} {word}" if count == 1 else f"{count} {word}s"
|
|
|
|
|
|
class OfflineReason(str):
|
|
"""A file not fetched because the run was told to make no request."""
|
|
|
|
|
|
class NetworkReason(str):
|
|
"""A failure the connection caused: cut, timed out, refused by a server
|
|
or a proxy. The summary names the network instead of the folder."""
|
|
|
|
|
|
def _transfer_failure(exc: BaseException) -> bool:
|
|
"""Whether a download error came from the connection, not the disk."""
|
|
# urlopen raises URLError for the name lookup, the connection and TLS.
|
|
# A cut read surfaces as a timeout (socket.timeout is no TimeoutError
|
|
# before 3.10), a reset, an SSL error or a size that does not add up.
|
|
ssl_error = getattr(sys.modules.get("ssl"), "SSLError", ())
|
|
if isinstance(exc, (urllib.error.URLError, http.client.HTTPException, ValueError,
|
|
ConnectionError, TimeoutError, socket.timeout)):
|
|
return True
|
|
if ssl_error and isinstance(exc, ssl_error):
|
|
return True
|
|
return isinstance(exc, OSError) and exc.errno in (
|
|
errno.ECONNRESET, errno.ECONNREFUSED, errno.ECONNABORTED, errno.ETIMEDOUT,
|
|
errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN, errno.EPIPE,
|
|
)
|
|
|
|
|
|
NETWORK_FAILURE_ADVICE = (
|
|
" The transfers were cut or refused on the way: check the connection, and "
|
|
"HTTPS_PROXY if this network needs a proxy.\n"
|
|
f" More help: {TROUBLESHOOTING_PAGE}")
|
|
|
|
|
|
def _report_outcome(
|
|
paths: list[Path],
|
|
downloaded: int,
|
|
up_to_date: int,
|
|
errors: int,
|
|
omitted: int,
|
|
interrupted: bool,
|
|
failures: list[tuple[str, str]] | None = None,
|
|
transferred: int = 0,
|
|
seconds: float = 0.0,
|
|
declined: int = 0,
|
|
assumed: set[Path] | None = None,
|
|
omitted_entries: list[dict] | None = None,
|
|
platform_of: dict[Path, str] | None = None,
|
|
hints: list[str] | None = None,
|
|
sources: str = "",
|
|
) -> None:
|
|
"""Close the run on what happened, and leave with a status that says so.
|
|
|
|
A run that installed nothing used to end on the word Done and a zero exit,
|
|
which reads as success to a person and to a script alike.
|
|
"""
|
|
if transferred and seconds > 0:
|
|
verb = "copied from the pack" if LOCAL.pack is not None else "downloaded"
|
|
print(f"\n{format_size(transferred)} {verb} in "
|
|
f"{format_duration(seconds)} ({format_size(int(transferred / seconds))}/s).")
|
|
reasons = {reason for _dest, reason in failures or []}
|
|
if failures and len(failures) > 1 and len(reasons) == 1 and reasons != {""}:
|
|
# One cause, said once: a read-only folder listed ten of its files
|
|
# and "... and 266 more" above the advice that fixes them all.
|
|
print(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}), "
|
|
f"first {failures[0][0]}")
|
|
else:
|
|
for dest, reason in (failures or [])[:10]:
|
|
print(f" failed: {dest}" + (f" ({reason})" if reason else ""))
|
|
if failures and len(failures) > 10:
|
|
print(f" ... and {len(failures) - 10} more")
|
|
if declined and not downloaded and not errors and not interrupted:
|
|
print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, "
|
|
"as answered.")
|
|
print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.")
|
|
return
|
|
kept = _kept_lines()
|
|
if interrupted:
|
|
print(f"\nStopped. {_plural(downloaded, 'file')} installed.")
|
|
for line in kept:
|
|
print(line)
|
|
print("Run the same command again to carry on from here. Nothing is lost.")
|
|
sys.exit(130)
|
|
|
|
# A standalone copy fails by absolute path, in an emulator's folder
|
|
# rather than the BIOS folder: that folder is where the write went.
|
|
elsewhere = sorted({Path(dest).parent for dest, _reason in failures or []
|
|
if Path(dest).is_absolute()})
|
|
refused = any(reason == NOT_WRITABLE for dest, reason in failures or []
|
|
if not Path(dest).is_absolute())
|
|
advice = [
|
|
_write_advice(path, path in (assumed or set()), (platform_of or {}).get(path, ""))
|
|
for path in paths
|
|
] if refused else []
|
|
advice += [_write_advice(folder, False) for folder in sorted({
|
|
Path(dest).parent for dest, reason in failures or []
|
|
if Path(dest).is_absolute() and reason == NOT_WRITABLE
|
|
})]
|
|
if any(reason == READ_ONLY for _dest, reason in failures or []):
|
|
advice.append(" Files marked read-only are in the way: clear that attribute "
|
|
"(or delete them), then run again.")
|
|
if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []):
|
|
advice.append(NETWORK_FAILURE_ADVICE)
|
|
if any(isinstance(reason, OfflineReason) for _dest, reason in failures or []):
|
|
advice.append(" These files come from the network, which --offline does not "
|
|
"use: run without --offline, or name a pack with --from-pack.")
|
|
paths = paths + [folder for folder in elsewhere if folder not in paths]
|
|
|
|
if errors and not downloaded:
|
|
print(f"\nNothing was installed. {_plural(errors, 'file')} failed.")
|
|
if failures and all(isinstance(r, (NetworkReason, OfflineReason))
|
|
for _d, r in failures):
|
|
# The folder was never the problem: naming it as the place being
|
|
# written to sent people looking at their disk.
|
|
print(f"Destination: {_locations(paths)}")
|
|
else:
|
|
print(f"Location that was being written to: {_locations(paths)}")
|
|
for line in kept + advice:
|
|
print(line)
|
|
print("Run the same command again once that is resolved. "
|
|
"Files already installed are kept.")
|
|
sys.exit(2)
|
|
|
|
if errors:
|
|
print(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, "
|
|
f"{_plural(errors, 'file')} failed.")
|
|
if sources:
|
|
print(f" {sources}")
|
|
print(f"Location: {_locations(paths)}")
|
|
for line in kept + advice:
|
|
print(line)
|
|
print("Run the same command again to retry only those.")
|
|
sys.exit(1)
|
|
|
|
print(f"\nDone. {_plural(downloaded, 'file')} installed, "
|
|
f"{_plural(up_to_date, 'file')} already up to date.")
|
|
if sources:
|
|
print(f" {sources}")
|
|
print(f"Location: {_locations(paths)}")
|
|
for line in kept:
|
|
print(line)
|
|
if omitted:
|
|
for line in _omission_lines(omitted, omitted_entries or []):
|
|
print(line)
|
|
for line in hints or []:
|
|
print(line)
|
|
print("To check this later, run the same command with --check.")
|
|
|
|
|
|
def _omission_lines(count: int, entries: list[dict]) -> list[str]:
|
|
"""What was skipped, the files an emulator cannot start without first.
|
|
|
|
"395 file are not in the collection yet" named nothing and gave one
|
|
reason for all, while the manifest says which file and why.
|
|
"""
|
|
one = count == 1
|
|
lines = [f"{_plural(count, 'file')} {'was' if one else 'were'} not installed: "
|
|
f"the collection does not hold {'it' if one else 'them'}, or its copy "
|
|
f"fails {'its' if one else 'their'} check."]
|
|
lines += _needed_lines([e for e in entries if e.get("required")],
|
|
"Needed to start an emulator")
|
|
return lines
|
|
|
|
|
|
def _absent_required(omitted: list[dict], bios_path: Path) -> list[dict]:
|
|
"""Required entries the manifest leaves out that the user has not placed."""
|
|
absent = []
|
|
for entry in omitted:
|
|
dest = entry.get("dest") or entry.get("name", "")
|
|
if not entry.get("required", True) or not dest:
|
|
continue
|
|
try:
|
|
placed = _is_file(_entry_path(bios_path, {**entry, "dest": dest}))
|
|
except ValueError:
|
|
# A path climbing out of the folder is never one we read, and one
|
|
# below a save directory this run does not know is not looked at.
|
|
placed = False
|
|
if not placed:
|
|
absent.append(entry)
|
|
return absent
|
|
|
|
|
|
def _needed_lines(entries: list[dict], heading: str, every: bool = False) -> list[str]:
|
|
"""Required files the collection lacks, by system with the emulators reading them.
|
|
|
|
A bare list (DARK.GOB, Data.rsdk, ... and 69 more) left a Batocera user
|
|
unable to tell whether their PlayStation was concerned.
|
|
"""
|
|
by_system: dict[str, tuple[set[str], set[str]]] = {}
|
|
for entry in entries:
|
|
system = str(entry.get("system") or ", ".join(entry_systems(entry)) or OTHER_SYSTEMS)
|
|
names, cores = by_system.setdefault(system, (set(), set()))
|
|
names.add(str(entry.get("dest") or entry.get("name")))
|
|
cores.update(str(core) for core in entry.get("cores") or [])
|
|
if not by_system:
|
|
return []
|
|
count = sum(len(names) for names, _cores in by_system.values())
|
|
lines = [f" {heading} ({_plural(count, 'file')}):"]
|
|
systems = sorted(by_system)
|
|
for system in systems if every else systems[:8]:
|
|
names, cores = by_system[system]
|
|
readers = f" ({', '.join(sorted(cores))})" if cores else ""
|
|
lines.append(f" {system}{readers}: {', '.join(sorted(names))}")
|
|
if not every and len(systems) > 8:
|
|
lines.append(f" ... and {len(systems) - 8} more systems (--check --verbose lists them all)")
|
|
lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/")
|
|
return lines
|
|
|
|
|
|
# Asked before, in an interactive run: past this size a download is worth a
|
|
# second thought, and --system, --core or --region can make it smaller.
|
|
CONFIRM_FROM_BYTES = 1024 * 1024 * 1024
|
|
|
|
|
|
def installer_id() -> str:
|
|
"""The first twelve hex digits of this file's SHA-256, as the bootstraps pin it."""
|
|
try:
|
|
return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]
|
|
except (OSError, NameError):
|
|
return "unknown"
|
|
|
|
|
|
# A --dry-run plan: file names per system, the systems shown when the
|
|
# listing is taller than the screen, and the lines around the listing.
|
|
PLAN_FILES_PER_SYSTEM = 8
|
|
PLAN_LARGEST_SYSTEMS = 10
|
|
PLAN_FRAME_LINES = 6
|
|
|
|
# Answers that stop: every menu before this one taught q as quit.
|
|
_REFUSALS = ("n", "q", "quit", "exit", "stop", "abort")
|
|
|
|
|
|
def _confirm(prompt: str) -> bool:
|
|
"""Yes unless the answer refuses: n, no, or one of the quit words."""
|
|
answer = _ask(prompt).strip().lower()
|
|
return not (answer.startswith("n") or answer in _REFUSALS)
|
|
|
|
|
|
def _print_plan(
|
|
to_download: list[dict], bios_path: Path, term: Terminal,
|
|
trees: list[tuple[dict, dict[str, dict]]] = (),
|
|
kept: dict[Path, list[int]] | None = None,
|
|
copied: list[str] = (),
|
|
verbose: bool = False,
|
|
) -> None:
|
|
"""What a real run would fetch: the totals first, then by system, and
|
|
nothing written.
|
|
|
|
The data trees count in the total and come before the closing line:
|
|
printed after it, they left 2636 RetroArch files out of the figure a
|
|
user plans an SD card from. A listing taller than the screen pushed the
|
|
total out of sight (599 lines for RetroArch): it gives way to the
|
|
largest systems, and --verbose lists every file.
|
|
"""
|
|
total = sum(f.get("size", 0) for f in to_download)
|
|
trees = [(entry, needed) for entry, needed in trees if needed]
|
|
tree_files = sum(len(needed) for _entry, needed in trees)
|
|
tree_size = sum(meta.get("size", 0) for _entry, needed in trees for meta in needed.values())
|
|
parts = []
|
|
if to_download:
|
|
parts.append(f"{_plural(len(to_download), 'file')} ({format_size(total)})")
|
|
if trees:
|
|
parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_size)}, "
|
|
f"extracted from {_plural(len(trees), 'archive')})")
|
|
if not parts and not copied:
|
|
print("\n All files up to date. A real run would download nothing.")
|
|
return
|
|
if parts:
|
|
print(f"\nWould download {' and '.join(parts)} to {bios_path}:")
|
|
for line in copied:
|
|
print(line)
|
|
if not parts:
|
|
print("\n Nothing to download.")
|
|
by_system: dict[str, list[dict]] = {}
|
|
for entry in to_download:
|
|
system = ", ".join(entry_systems(entry)) or "other"
|
|
by_system.setdefault(system, []).append(entry)
|
|
shown = None if verbose else PLAN_FILES_PER_SYSTEM
|
|
listing: list[str] = []
|
|
for system in sorted(by_system):
|
|
entries = sorted(by_system[system], key=lambda e: e["dest"])
|
|
listing.append(f" {term.bold(system)} {_sized(entries)}")
|
|
listing += [f" {entry['dest']} {term.dim(format_size(entry.get('size', 0)))}"
|
|
for entry in entries[:shown]]
|
|
if shown is not None and len(entries) > shown:
|
|
listing.append(f" ... and {len(entries) - shown} more")
|
|
screen = shutil.get_terminal_size((80, 24)).lines
|
|
if verbose or len(listing) + len(trees) + PLAN_FRAME_LINES <= screen:
|
|
for line in listing:
|
|
print(line)
|
|
else:
|
|
largest = sorted(by_system, key=lambda s: (-_bytes(by_system[s]), s))
|
|
print(f" {_plural(len(largest), 'system')}, the largest first:")
|
|
for system in largest[:PLAN_LARGEST_SYSTEMS]:
|
|
print(f" {term.bold(system)} {_sized(by_system[system])}")
|
|
if len(largest) > PLAN_LARGEST_SYSTEMS:
|
|
print(f" ... and {len(largest) - PLAN_LARGEST_SYSTEMS} more systems: "
|
|
"--verbose lists every system and its files.")
|
|
for entry, needed in trees:
|
|
source = LOCAL.pack.name if LOCAL.pack is not None else entry["url"]
|
|
print(f" {term.bold(entry['ref'] + ' data')} {_sized(list(needed.values()))}, "
|
|
f"from {source}")
|
|
for folder, (count, size) in sorted((kept or {}).items()):
|
|
print(f"\n Would keep {_plural(count, 'file')} you have ({format_size(size)}), "
|
|
f"whose contents differ, in {folder}")
|
|
print("\n Nothing was written. Run without --dry-run to install.")
|
|
|
|
|
|
def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int = 2) -> None:
|
|
"""Stop before writing when the volume cannot hold the download.
|
|
|
|
Reported against the nearest existing ancestor, since the destination
|
|
may not exist yet. The run ends with code: 2 when nothing was installed,
|
|
the status a script reads as "nothing installed", 1 after an earlier
|
|
platform of the run installed files.
|
|
"""
|
|
probe = bios_path
|
|
while not probe.exists() and probe != probe.parent:
|
|
probe = probe.parent
|
|
try:
|
|
free = shutil.disk_usage(probe).free
|
|
except OSError:
|
|
return
|
|
if free >= needed:
|
|
return
|
|
print(f"\nNot enough space on the drive holding {bios_path}.", file=sys.stderr)
|
|
print(f" Needed: {format_size(needed)}. Free: {format_size(free)}.",
|
|
file=sys.stderr)
|
|
if not plat:
|
|
print(f" Free up {format_size(needed - free)}, or use --dest to install "
|
|
"to another drive.", file=sys.stderr)
|
|
sys.exit(code)
|
|
# --dest alone used to mean RetroArch's list: the platform is named, so
|
|
# a Batocera install moved to a USB drive stays Batocera's.
|
|
print(f" Free up {format_size(needed - free)}, or install to another drive:\n"
|
|
f" {_INVOCATION.command(plat, '<folder on another drive>')}",
|
|
file=sys.stderr)
|
|
sys.exit(code)
|
|
|
|
|
|
def download_files(
|
|
files: list[dict], bios_path: Path, jobs: int = 8, verbose: bool = False,
|
|
term: Terminal | None = None, progress: Progress | None = None,
|
|
copies: dict[int, LocalCopy] | None = None,
|
|
) -> tuple[list[tuple[str, str]], bool]:
|
|
"""Download files in parallel.
|
|
|
|
Returns the destinations that failed, each with its reason, and whether
|
|
the run was interrupted. progress.installed counts the files written.
|
|
Every future is submitted up front, so a bare KeyboardInterrupt would
|
|
let the pool drain the queue in full: the stop flag is what makes Ctrl+C
|
|
end the run rather than only stop the reporting.
|
|
"""
|
|
failed: list[tuple[str, str]] = []
|
|
interrupted = False
|
|
_stop.clear()
|
|
_writing.set()
|
|
term = term or Terminal()
|
|
if progress is None:
|
|
progress = Progress(len(files), sum(f.get("size", 0) for f in files), term)
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
future_map = {
|
|
pool.submit(_download_one, f, bios_path, verbose, progress,
|
|
(copies or {}).get(id(f))): f
|
|
for f in files
|
|
}
|
|
refused = False
|
|
refused_events = 0
|
|
try:
|
|
for future in concurrent.futures.as_completed(future_map):
|
|
dest, success, reason = future.result()
|
|
if refused:
|
|
# The folder they all go in refused the first: each
|
|
# would fail alike, counted without a line of its own,
|
|
# though a program reading the events still gets one.
|
|
if not success:
|
|
failed.append((dest, NOT_WRITABLE))
|
|
refused_events += 1
|
|
EVENTS.emit(
|
|
"downloaded", dest=dest, ok=False, reason=NOT_WRITABLE,
|
|
size=future_map[future].get("size", 0),
|
|
files_done=progress.files + refused_events,
|
|
files_total=progress.total_files, bytes_done=progress.bytes,
|
|
bytes_total=progress.expected_bytes,
|
|
)
|
|
continue
|
|
if _stop.is_set():
|
|
continue
|
|
progress.done(dest, success, reason)
|
|
EVENTS.emit(
|
|
"downloaded", dest=dest, ok=success, reason=reason,
|
|
size=future_map[future].get("size", 0), files_done=progress.files,
|
|
files_total=progress.total_files, bytes_done=progress.bytes,
|
|
bytes_total=progress.expected_bytes,
|
|
)
|
|
if not success:
|
|
failed.append((dest, reason))
|
|
if reason == NOT_WRITABLE and not _folder_writable(bios_path):
|
|
# A read-only card or share: hundreds of identical
|
|
# FAILED lines buried the one cause and its advice.
|
|
refused = True
|
|
_stop.set()
|
|
except KeyboardInterrupt:
|
|
interrupted = True
|
|
_stop.set()
|
|
progress.finish()
|
|
print("\n Stopping. Files already installed are kept.")
|
|
# The pool has run every future by now: a file that landed after Ctrl+C
|
|
# is on disk and counts.
|
|
progress.installed = sum(
|
|
1 for future in future_map
|
|
if not future.cancelled() and future.exception() is None and future.result()[1]
|
|
)
|
|
progress.finish()
|
|
progress.emit_transfer()
|
|
return failed, interrupted
|
|
|
|
|
|
def _fetch_archive(url: str, directory: Path) -> Path:
|
|
"""A data directory's source archive, in *directory*, retried like files."""
|
|
_network_allowed(url)
|
|
reason: Exception | None = None
|
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
handle = tempfile.NamedTemporaryFile(dir=directory, suffix=".archive", delete=False)
|
|
try:
|
|
with handle, urllib.request.urlopen(url, timeout=60) as resp:
|
|
received = 0
|
|
while True:
|
|
if _stop.is_set():
|
|
raise _Interrupted()
|
|
chunk = resp.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
received += len(chunk)
|
|
if received > MAX_DATA_ARCHIVE_SIZE:
|
|
raise ValueError("archive exceeds the size limit")
|
|
handle.write(chunk)
|
|
return Path(handle.name)
|
|
except urllib.error.HTTPError as exc:
|
|
Path(handle.name).unlink(missing_ok=True)
|
|
if exc.code < 500 and exc.code != 429:
|
|
raise
|
|
reason = exc
|
|
except (urllib.error.URLError, OSError, http.client.HTTPException) as exc:
|
|
Path(handle.name).unlink(missing_ok=True)
|
|
reason = exc if isinstance(exc, OSError) else ConnectionError(repr(exc))
|
|
except BaseException:
|
|
Path(handle.name).unlink(missing_ok=True)
|
|
raise
|
|
if attempt < MAX_RETRIES:
|
|
time.sleep(_retry_delay(attempt, None))
|
|
raise reason if reason else OSError("download failed")
|
|
|
|
|
|
def _archive_members(
|
|
archive: Path, entry: dict
|
|
) -> Iterator[tuple[str, int, Callable[[], BinaryIO]]]:
|
|
"""(path in the tree, declared size, opener) for each file the archive
|
|
maps into it.
|
|
|
|
The rules refresh_data_dirs applies to build the cache: a ZIP drops
|
|
strip_components leading parts, a tarball keeps what sits under
|
|
source_path, and a first part named in exclude is left out.
|
|
"""
|
|
exclude = set(entry.get("exclude") or [])
|
|
if entry["type"] == "zip":
|
|
strip = entry.get("strip_components", 0)
|
|
with zipfile.ZipFile(archive) as zf:
|
|
for info in zf.infolist():
|
|
if info.is_dir():
|
|
continue
|
|
parts = info.filename.split("/")
|
|
if ".." in parts or info.filename.startswith("/"):
|
|
continue
|
|
if strip:
|
|
if len(parts) <= strip:
|
|
continue
|
|
parts = parts[strip:]
|
|
if parts[0] in exclude:
|
|
continue
|
|
yield "/".join(parts), info.file_size, (lambda info=info: zf.open(info))
|
|
return
|
|
prefix = entry["source_path"].rstrip("/") + "/"
|
|
with tarfile.open(archive, "r:gz") as tf:
|
|
for member in tf:
|
|
if not member.isfile() or not member.name.startswith(prefix):
|
|
continue
|
|
rel = member.name[len(prefix):]
|
|
if not rel or ".." in rel.split("/") or rel.split("/")[0] in exclude:
|
|
continue
|
|
yield rel, member.size, (lambda member=member: tf.extractfile(member))
|
|
|
|
|
|
def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, dict], int]:
|
|
"""(tree root, files missing or wrong, files already right)."""
|
|
root = _destination_path(bios_path, entry["dest"]) if entry.get("dest") else bios_path.resolve()
|
|
needed: dict[str, dict] = {}
|
|
present = 0
|
|
for rel, meta in entry["files"].items():
|
|
target = _destination_path(root, rel)
|
|
try:
|
|
held = (target.is_file() and target.stat().st_size == meta["size"]
|
|
and _sha1_file(target) == meta["sha1"])
|
|
except OSError:
|
|
held = False
|
|
if held:
|
|
present += 1
|
|
else:
|
|
needed[rel] = meta
|
|
return root, needed, present
|
|
|
|
|
|
def install_data_directory(entry: dict, bios_path: Path) -> dict:
|
|
"""Rebuild one data tree from its source archive, file by file verified.
|
|
|
|
Returns counts: present, installed, changed (the source no longer holds
|
|
the recorded bytes), absent (not in the archive), error, the reason
|
|
the archive could not be read, and not_kept, the (path, reason) of each
|
|
file in place that differs and could not be moved aside, left as it is.
|
|
"""
|
|
root, needed, present = data_directory_state(entry, bios_path)
|
|
counts = {"present": present, "installed": 0, "changed": 0, "absent": 0, "error": "",
|
|
"not_kept": []}
|
|
if not needed:
|
|
return counts
|
|
_writing.set()
|
|
try:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=root) as scratch:
|
|
if LOCAL.pack is not None:
|
|
# The pack holds the tree where it extracts, already mapped.
|
|
members = LOCAL.pack.tree(entry.get("dest", ""))
|
|
else:
|
|
archive = _fetch_archive(entry["url"], Path(scratch))
|
|
members = _archive_members(archive, entry)
|
|
for rel, declared, opener in members:
|
|
meta = needed.pop(rel, None)
|
|
if meta is None:
|
|
continue
|
|
# Read no more than the list records: the archive's own size
|
|
# field is checked first, and the stream is cut at the
|
|
# expected size whatever the member claims, so a member that
|
|
# inflates without end costs one byte past it.
|
|
if declared != meta["size"]:
|
|
counts["changed"] += 1
|
|
continue
|
|
target = _destination_path(root, rel)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
outcome = _install_data_member(
|
|
opener, meta, target, bios_path,
|
|
f"{entry['dest']}/{rel}" if entry.get("dest") else rel,
|
|
rel, counts["not_kept"])
|
|
if outcome:
|
|
counts[outcome] += 1
|
|
except _Offline as exc:
|
|
# Kept apart, so the advice is the offline one, not the network's.
|
|
counts["error"] = OfflineReason(str(exc))
|
|
except (urllib.error.URLError, OSError, ValueError, tarfile.TarError) as exc:
|
|
counts["error"] = str(getattr(exc, "reason", None) or exc)
|
|
except _MEMBER_ERRORS as exc:
|
|
# A damaged, encrypted or unsupported member, in words.
|
|
counts["error"] = unreadable_words(exc)
|
|
counts["absent"] = len(needed)
|
|
return counts
|
|
|
|
|
|
def _install_data_member(
|
|
opener: Callable[[], ContextManager[BinaryIO]], meta: dict, target: Path,
|
|
bios_path: Path, slot: str, rel: str, not_kept: list[tuple[str, str]],
|
|
) -> str:
|
|
"""Write one member of a data tree's archive at target once its bytes
|
|
are the recorded ones: "installed", "changed" when they are not, or ""
|
|
when the file in place could not be moved aside (named in not_kept).
|
|
A read or write error is raised, and leaves no .part behind."""
|
|
scratch: Path | None = None
|
|
try:
|
|
digest = hashlib.sha1()
|
|
size = 0
|
|
with opener() as src, tempfile.NamedTemporaryFile(
|
|
dir=target.parent, prefix=f".{target.name}.", suffix=".part", delete=False,
|
|
) as out:
|
|
scratch = Path(out.name)
|
|
while size <= meta["size"]:
|
|
chunk = src.read(min(1024 * 1024, meta["size"] + 1 - size))
|
|
if not chunk:
|
|
break
|
|
digest.update(chunk)
|
|
size += len(chunk)
|
|
out.write(chunk)
|
|
if digest.hexdigest() != meta["sha1"] or size != meta["size"]:
|
|
return "changed"
|
|
try:
|
|
KEPT.keep(*_kept_slot(bios_path, slot), target, {"sha1": meta["sha1"]})
|
|
except _NotKept as exc:
|
|
not_kept.append((rel, str(exc)))
|
|
return ""
|
|
os.replace(scratch, target)
|
|
scratch = None
|
|
return "installed"
|
|
finally:
|
|
if scratch is not None:
|
|
scratch.unlink(missing_ok=True)
|
|
|
|
|
|
def select_data_directories(manifest: dict, files: list[dict], narrowed: bool) -> list[dict]:
|
|
"""The data trees this install keeps: all, or those of a kept system."""
|
|
entries = manifest.get("data_directories") or []
|
|
if not narrowed:
|
|
return list(entries)
|
|
kept = {system for f in files for system in entry_systems(f)}
|
|
return [e for e in entries if kept & set(e.get("systems") or [])]
|
|
|
|
|
|
def _copy_targets(
|
|
entry: dict, os_type: str, extra_dirs: dict[str, Path] | None = None,
|
|
) -> list[Path]:
|
|
"""The emulator folders a standalone copy entry names on this OS.
|
|
|
|
WSL falls back to the Linux ones, and extra_dirs adds the folder a
|
|
frontend such as LaunchBox keeps the emulator in.
|
|
"""
|
|
targets = entry.get("targets", {}).get(os_type, [])
|
|
if not targets and os_type == "wsl":
|
|
targets = entry.get("targets", {}).get("linux", [])
|
|
target_dirs = [Path(os.path.expandvars(os.path.expanduser(t))) for t in targets]
|
|
extra_root = (extra_dirs or {}).get(entry.get("emulator", ""))
|
|
if extra_root is not None:
|
|
subdir = PurePosixPath(entry.get("file", "")).parent
|
|
extra = extra_root if str(subdir) == "." else extra_root / subdir
|
|
if extra not in target_dirs:
|
|
target_dirs.append(extra)
|
|
return target_dirs
|
|
|
|
|
|
def standalone_folders_here(
|
|
manifest: dict, os_type: str, extra_dirs: dict[str, Path] | None = None,
|
|
) -> list[Path]:
|
|
"""The emulator folders of the manifest's standalone copies that exist
|
|
on this machine: the ones --standalone-copies would write to."""
|
|
found: list[Path] = []
|
|
for entry in manifest.get("standalone_copies", []):
|
|
if "note" in entry:
|
|
continue
|
|
for folder in _copy_targets(entry, os_type, extra_dirs):
|
|
if folder not in found and folder.is_dir():
|
|
found.append(folder)
|
|
return found
|
|
|
|
|
|
# The file lists a machine with standalone emulators and no platform is
|
|
# served from: EmuDeck's holds the Switch keys, RetroArch's the PPSSPP atlas
|
|
# and the Flycast flash, and each copy entry names its folders.
|
|
STANDALONE_SOURCES = ("emudeck", "retroarch")
|
|
|
|
|
|
def standalone_plan(
|
|
manifests: list[dict], os_type: str, extra_dirs: dict[str, Path] | None = None,
|
|
) -> list[tuple[Path, list[dict]]]:
|
|
"""What each emulator folder found here receives, by its own file name.
|
|
|
|
The standalone copies of the manifests, read as do_standalone_copies
|
|
reads them, but taken from the file lists rather than from a platform
|
|
tree already installed: an ES-DE user with DuckStation and PCSX2 and no
|
|
RetroArch had no tree to copy from. The first list that holds a name for
|
|
a folder serves it.
|
|
"""
|
|
from fnmatch import fnmatch # noqa: PLC0415
|
|
|
|
plan: dict[Path, dict[str, dict]] = {}
|
|
for manifest in manifests:
|
|
files = manifest.get("files", [])
|
|
by_dest = {entry["dest"]: entry for entry in files}
|
|
for copy in manifest.get("standalone_copies", []):
|
|
if "note" in copy:
|
|
continue
|
|
if "pattern" in copy:
|
|
sources = [entry for entry in files
|
|
if fnmatch(PurePosixPath(entry["dest"]).name, copy["pattern"])]
|
|
else:
|
|
held = by_dest.get(copy.get("source") or copy["file"])
|
|
sources = [held] if held else []
|
|
for folder in _copy_targets(copy, os_type, extra_dirs):
|
|
if not sources or not folder.is_dir():
|
|
continue
|
|
chosen = plan.setdefault(folder, {})
|
|
for entry in sources:
|
|
name = PurePosixPath(entry["dest"]).name
|
|
chosen.setdefault(name, {**entry, "dest": name})
|
|
return [(folder, list(entries.values())) for folder, entries in plan.items() if entries]
|
|
|
|
|
|
def _install_standalone_folders(
|
|
args: argparse.Namespace, os_type: str, term: Terminal, interactive: bool,
|
|
) -> bool:
|
|
"""Fill the standalone emulator folders found here, with no platform.
|
|
|
|
False when this machine has none, the run then going on to the platform
|
|
question or hint.
|
|
"""
|
|
manifests = []
|
|
for plat in STANDALONE_SOURCES:
|
|
manifests.append(fetch_manifest(plat))
|
|
lb_root = launchbox_root(os_type)
|
|
plan = standalone_plan(manifests, os_type,
|
|
launchbox_bios_dirs(lb_root) if lb_root else None)
|
|
if not plan:
|
|
return False
|
|
print(" Found standalone emulator folders:")
|
|
for folder, entries in plan:
|
|
print(f" {folder} {_sized(entries)}")
|
|
if interactive and not (args.check or args.dry_run or args.yes) and not _confirm(
|
|
"\nInstall the BIOS files these emulators read into their folders? [Y/n] "
|
|
):
|
|
return False
|
|
downloaded = up_to_date_count = errors = 0
|
|
pending = 0
|
|
failures: list[tuple[str, str]] = []
|
|
transferred, seconds = 0, 0.0
|
|
interrupted = False
|
|
for folder, entries in plan:
|
|
print(f"\n{folder}:")
|
|
to_download, up_to_date, mismatched = check_local(entries, folder, jobs=args.jobs)
|
|
to_download.extend(mismatched)
|
|
up_to_date_count += len(up_to_date)
|
|
if args.check:
|
|
for entry in mismatched:
|
|
print(f" wrong hash: {entry['dest']}")
|
|
wrong = {id(entry) for entry in mismatched}
|
|
missing = [entry for entry in to_download if id(entry) not in wrong]
|
|
for entry in missing[:10] if not args.verbose else missing:
|
|
print(f" missing: {entry['dest']}")
|
|
if not args.verbose and len(missing) > 10:
|
|
print(f" ... and {len(missing) - 10} more missing (--verbose lists them all)")
|
|
if not to_download:
|
|
print(" All files up to date.")
|
|
pending += len(to_download)
|
|
continue
|
|
if args.dry_run:
|
|
_print_plan(to_download, folder, term, kept=plan_kept(mismatched, folder),
|
|
verbose=args.verbose)
|
|
continue
|
|
if not to_download:
|
|
print(" All files up to date.")
|
|
continue
|
|
size = sum(entry.get("size", 0) for entry in to_download)
|
|
_check_free_space(folder, size, code=1 if downloaded else 2)
|
|
progress = Progress(len(to_download), size, term)
|
|
failed, interrupted = download_files(
|
|
to_download, folder, jobs=args.jobs, verbose=args.verbose,
|
|
term=term, progress=progress,
|
|
)
|
|
downloaded += progress.installed
|
|
errors += len(failed)
|
|
failures.extend((str(folder / dest), reason) for dest, reason in failed)
|
|
transferred += progress.transferred()
|
|
seconds += progress.elapsed()
|
|
if interrupted:
|
|
break
|
|
if args.check:
|
|
if pending:
|
|
print("\n Run the same command without --check to fetch them; "
|
|
"a file with a wrong hash is replaced, and the one in place "
|
|
f"kept in {BACKUP_DIR} beside its folder.")
|
|
sys.exit(1)
|
|
return True
|
|
if args.dry_run:
|
|
return True
|
|
_report_outcome(
|
|
[folder for folder, _entries in plan], downloaded, up_to_date_count, errors,
|
|
0, interrupted, failures=failures, transferred=transferred, seconds=seconds,
|
|
)
|
|
return True
|
|
|
|
|
|
def do_standalone_copies(
|
|
manifest: dict, bios_path: Path, os_type: str,
|
|
extra_dirs: dict[str, Path] | None = None,
|
|
failures: list[tuple[str, str]] | None = None,
|
|
) -> tuple[int, int, int]:
|
|
"""Copy BIOS files to standalone emulator directories.
|
|
|
|
Supports:
|
|
- file: single file copy
|
|
- pattern: glob match (e.g. "scph*.bin")
|
|
- note: informational message when detect path exists
|
|
- WSL fallback to linux targets
|
|
|
|
extra_dirs maps the emulator id of an entry to a root that replaces the
|
|
default per-OS locations, for setups such as LaunchBox that keep their
|
|
emulators outside them. The layout below the root is the same.
|
|
|
|
Returns (copied, skipped, failed): skipped is a target directory that
|
|
does not exist, failed a copy the system refused (permission, disk
|
|
full, read-only mount) or a name leading out of the folder, which used
|
|
to be counted as "dir not found". A link already at a file's name is
|
|
replaced by the file, never written through.
|
|
Each failure is also appended to failures, by absolute path, for the
|
|
summary to name.
|
|
"""
|
|
from fnmatch import fnmatch
|
|
|
|
copies = manifest.get("standalone_copies", [])
|
|
if not copies:
|
|
return 0, 0, 0
|
|
|
|
copied = 0
|
|
skipped = 0
|
|
failed = 0
|
|
|
|
for entry in copies:
|
|
# Note entries: print message if emulator detected
|
|
if "note" in entry:
|
|
detect_paths = entry.get("detect", {}).get(os_type, [])
|
|
if not detect_paths and os_type == "wsl":
|
|
detect_paths = entry.get("detect", {}).get("linux", [])
|
|
for dp in detect_paths:
|
|
expanded = Path(os.path.expandvars(os.path.expanduser(dp)))
|
|
if expanded.is_dir():
|
|
print(f" {entry['note']}")
|
|
break
|
|
continue
|
|
|
|
if "pattern" in entry:
|
|
# A kept file is never a source, when the backup had to go
|
|
# inside the BIOS folder.
|
|
sources = [
|
|
f for f in bios_path.rglob("*")
|
|
if fnmatch(f.name, entry["pattern"]) and f.is_file()
|
|
and BACKUP_DIR not in f.relative_to(bios_path).parts
|
|
]
|
|
else:
|
|
# `source` is where this platform's manifest put the file, and `file`
|
|
# names it as the registry does for every platform, and laid out
|
|
# that way it was missing wherever the pack places it elsewhere.
|
|
src = _destination_path(bios_path, entry.get("source") or entry["file"])
|
|
sources = [src] if src.exists() else []
|
|
|
|
if not sources:
|
|
continue
|
|
|
|
for target_dir in _copy_targets(entry, os_type, extra_dirs):
|
|
if not target_dir.is_dir():
|
|
skipped += len(sources)
|
|
continue
|
|
resolved_dir = target_dir.resolve()
|
|
for src in sources:
|
|
dest = target_dir / src.name
|
|
linked = dest.is_symlink()
|
|
# A crafted source name would climb out of the directory.
|
|
if not linked and not _within(dest, resolved_dir):
|
|
print(f" ERROR: {dest}: {OUTSIDE_TARGET}")
|
|
failed += 1
|
|
if failures is not None:
|
|
failures.append((str(dest), OUTSIDE_TARGET))
|
|
continue
|
|
try:
|
|
KEPT.keep(resolved_dir, PurePosixPath(src.name), dest, {}, src)
|
|
except _NotKept as exc:
|
|
print(f" ERROR: {dest}: {NOT_KEPT} ({exc})")
|
|
failed += 1
|
|
if failures is not None:
|
|
failures.append((str(dest), f"{NOT_KEPT} ({exc})"))
|
|
continue
|
|
try:
|
|
if linked:
|
|
# Writing through a link already at the name would
|
|
# leave the folder the user opted into. The link
|
|
# itself gives way to the file.
|
|
_replace_with_copy(src, dest)
|
|
print(f" {dest}: link replaced by the file")
|
|
else:
|
|
shutil.copy2(src, dest)
|
|
copied += 1
|
|
except OSError as exc:
|
|
print(f" ERROR: {dest}: {exc.strerror or exc}")
|
|
failed += 1
|
|
if failures is not None:
|
|
reason = NOT_WRITABLE if _write_refused(exc) else str(
|
|
exc.strerror or exc)
|
|
failures.append((str(dest), reason))
|
|
|
|
return copied, skipped, failed
|
|
|
|
|
|
OUTSIDE_TARGET = "name leads outside the emulator folder"
|
|
|
|
|
|
def _replace_with_copy(src: Path, dest: Path) -> None:
|
|
"""Put a copy of src at dest's name without following what is there."""
|
|
fd, scratch = tempfile.mkstemp(dir=dest.parent, prefix=f".{dest.name}.", suffix=".part")
|
|
os.close(fd)
|
|
try:
|
|
shutil.copy2(src, scratch)
|
|
os.replace(scratch, dest)
|
|
except OSError:
|
|
if os.path.lexists(scratch):
|
|
os.unlink(scratch)
|
|
raise
|
|
|
|
|
|
def format_size(n: int) -> str:
|
|
"""Human-readable file size."""
|
|
if n < 1024:
|
|
return f"{n} B"
|
|
if n < 1024 * 1024:
|
|
return f"{n / 1024:.1f} KB"
|
|
if n < 1024 * 1024 * 1024:
|
|
return f"{n / (1024 * 1024):.1f} MB"
|
|
return f"{n / (1024 * 1024 * 1024):.1f} GB"
|
|
|
|
|
|
def _choose_platform(title: str, default: str = "") -> str:
|
|
"""One platform from the numbered list, Enter taking the default."""
|
|
print(f"\n{title}")
|
|
for i, name in enumerate(AVAILABLE_PLATFORMS, 1):
|
|
print(f" {i}) {display_name(name)}")
|
|
print(" q) quit")
|
|
print()
|
|
prompt = f"> [Enter for {display_name(default)}] " if default else "> "
|
|
while True:
|
|
choice = _ask(prompt)
|
|
if not choice and default:
|
|
return default
|
|
if choice.lower() == "q":
|
|
_quit()
|
|
if choice.isdigit() and 1 <= int(choice) <= len(AVAILABLE_PLATFORMS):
|
|
return AVAILABLE_PLATFORMS[int(choice) - 1]
|
|
key = _name_key(choice)
|
|
for plat in AVAILABLE_PLATFORMS:
|
|
if key and key in (_name_key(plat), _name_key(display_name(plat))):
|
|
return plat
|
|
print(f" Type a number from 1 to {len(AVAILABLE_PLATFORMS)}, a platform name, "
|
|
"or q to quit.")
|
|
|
|
|
|
def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]:
|
|
"""Ask for a platform and a BIOS directory when nothing was detected."""
|
|
plat = _choose_platform("No supported platform found here. Choose the one to "
|
|
"install for:", "retroarch")
|
|
default_dest = _default_dest(os_type, plat)
|
|
raw = ""
|
|
while not raw:
|
|
raw = _ask(
|
|
f"BIOS directory [{default_dest}]: " if default_dest
|
|
else "BIOS directory (no default on this system): "
|
|
)
|
|
if default_dest is not None:
|
|
break
|
|
if raw:
|
|
return [(plat, _typed_folder(raw))]
|
|
state = "" if default_dest.exists() else ". It does not exist yet and will be created"
|
|
print(f" {display_name(plat)}'s default BIOS folder: {default_dest}{state}.")
|
|
return [(plat, default_dest)]
|
|
|
|
|
|
def _typed_folder(raw: str) -> Path:
|
|
"""A folder typed at a question, read as a shell reads an argument.
|
|
|
|
Explorer's "Copy as path" puts quotes around it, and users quote a path
|
|
with spaces: kept, they sent the files into a folder named `"` under
|
|
the current directory, and the run said Done.
|
|
"""
|
|
text = raw.strip()
|
|
if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
|
|
text = text[1:-1].strip()
|
|
return Path(os.path.expandvars(os.path.expanduser(text)))
|
|
|
|
|
|
# Platforms with no install location of their own: the BIOS folder sits
|
|
# beside the executable, wherever the archive was extracted.
|
|
PORTABLE_ONLY = {
|
|
"bizhawk": "BizHawk reads the Firmware folder beside EmuHawk, and none was found.",
|
|
}
|
|
|
|
|
|
def _default_dest(os_type: str, platform_name: str) -> Path | None:
|
|
"""Fall back path for a platform named on the command line but not found,
|
|
or None where no folder on this OS is one the platform reads.
|
|
|
|
A home directory is meaningless for RetroArch on Android, where Termux
|
|
owns the home and the frontend reads from shared storage. ~/bios is read
|
|
by no platform, RetroBat lives on Windows only, and a Batocera or Recalbox
|
|
path on Windows resolved to the root of the current drive: 8.7 GB under
|
|
C:\\userdata\\bios, reported done.
|
|
"""
|
|
if os_type == "android" and platform_name == "retroarch":
|
|
return _android_storage_root() / "RetroArch" / "system"
|
|
if platform_name in PORTABLE_ONLY:
|
|
return None
|
|
own = _os_default_dests(os_type).get(platform_name)
|
|
if own is not None:
|
|
return own
|
|
if os_type == "windows":
|
|
return None
|
|
if platform_name == "retrobat":
|
|
return None
|
|
return DEFAULT_DESTS.get(platform_name)
|
|
|
|
|
|
def _manual_usage_hint(os_type: str, listing: str = "") -> list[str]:
|
|
"""How to name the platform and directory when nothing is detected.
|
|
|
|
In the form the run was started with: a one-liner user has no
|
|
install.py, and `python3 install.py` failed with "can't open file".
|
|
"""
|
|
example = {
|
|
"windows": "C:\\RetroArch-Win64\\system",
|
|
"android": "/storage/emulated/0/RetroArch/system",
|
|
}.get(os_type, "~/.config/retroarch/system")
|
|
hint = Invocation()
|
|
hint.os_type, hint.options = os_type, _INVOCATION.options
|
|
if listing:
|
|
# A listing reads a platform's list and writes nothing: --dest is
|
|
# not needed, and a rebuilt install command dropped the listing
|
|
# and started a 6.5 GB download.
|
|
return [
|
|
" Name the platform whose list to print, for example:",
|
|
f" {hint.command('retroarch', None)} {listing}",
|
|
f" The platforms: {hint.launcher()} --list-platforms",
|
|
]
|
|
return [
|
|
" Use --platform and --dest to specify manually, for example:",
|
|
f" {hint.command('retroarch', example)}",
|
|
f" Where each platform keeps its BIOS: {INSTALLER_PAGE}",
|
|
]
|
|
|
|
|
|
def _prompt_platform_choice(
|
|
platforms: list[tuple[str, Path]], title: str = "Install for:",
|
|
) -> list[tuple[str, Path]]:
|
|
"""Prompt user to choose among detected platforms."""
|
|
print(f"\n{title}")
|
|
for i, (name, path) in enumerate(platforms, 1):
|
|
system = detected_os(name, path)
|
|
label = f"{system}, {display_name(name)} files" if system and system != display_name(name) \
|
|
else display_name(name)
|
|
print(f" {i}) {label} ({path})")
|
|
last = len(platforms) + (1 if len(platforms) > 1 else 0)
|
|
if len(platforms) > 1:
|
|
print(f" {last}) All")
|
|
print(" q) quit")
|
|
print()
|
|
|
|
while True:
|
|
choice = _ask("> ").lower()
|
|
if choice == "q":
|
|
_quit()
|
|
idx = int(choice) if choice.isdigit() else 0
|
|
if 1 <= idx <= len(platforms):
|
|
return [platforms[idx - 1]]
|
|
if idx == last and len(platforms) > 1:
|
|
return platforms
|
|
# A wrong answer used to bring back a bare prompt, and nothing said
|
|
# how to leave it.
|
|
print(f" Type a number from 1 to {last}, or q to quit.")
|
|
|
|
|
|
def _ask(prompt: str) -> str:
|
|
"""An answer, or a stop: Ctrl+D at a question is Ctrl+C.
|
|
|
|
Both used to leave with 0, so `install.sh && retroarch` went on with no
|
|
BIOS. They now end where Ctrl+C ends everywhere else, on 130.
|
|
"""
|
|
try:
|
|
return input(prompt).strip()
|
|
except EOFError:
|
|
print()
|
|
raise KeyboardInterrupt from None
|
|
|
|
|
|
def _quit() -> None:
|
|
"""q at a question ends the run where Ctrl+C does, on 130: with 0,
|
|
`install.sh && retroarch` started RetroArch on no BIOS."""
|
|
sys.exit(130)
|
|
|
|
|
|
def _sized(files: list[dict]) -> str:
|
|
return f"{_plural(len(files), 'file')}, {format_size(sum(f.get('size', 0) for f in files))}"
|
|
|
|
|
|
# A core whose files are all the platform's own. "pcsx_rearmed 0 files,
|
|
# 0 B" read as a core that gets nothing.
|
|
NO_CORE_FILES = "no extra file: it reads the platform's own, always kept"
|
|
|
|
|
|
def _core_sized(files: list[dict]) -> str:
|
|
return _sized(files) if files else NO_CORE_FILES
|
|
|
|
|
|
def _system_sized(files: list[dict]) -> str:
|
|
"""A system's files, those other systems share counted apart.
|
|
|
|
`3do 1704 files, 349.0 MB` read as the 3DO BIOS: 1689 of them are the
|
|
MAME hash and plugin files six systems carry.
|
|
"""
|
|
shared = [f for f in files if len(entry_systems(f)) > 1]
|
|
if not shared:
|
|
return _sized(files)
|
|
if len(shared) == len(files):
|
|
return f"{_sized(shared)}, all shared with other systems"
|
|
own = [f for f in files if len(entry_systems(f)) <= 1]
|
|
return f"{_sized(own)}, and {_sized(shared)} shared with other systems"
|
|
|
|
|
|
def _pick(
|
|
title: str, rows: list[tuple[str, list[dict]]], ordered: bool = False,
|
|
sizer: Callable[[list[dict]], str] = _sized,
|
|
) -> list[str]:
|
|
"""Numbered list of names. An empty answer keeps every one.
|
|
|
|
sizer says what each name holds.
|
|
"""
|
|
if not rows:
|
|
return []
|
|
print(f"\n{title}")
|
|
width = len(str(len(rows)))
|
|
name_width = min(max(len(name) for name, _files in rows), 32)
|
|
for index, (name, files) in enumerate(rows, 1):
|
|
print(f" {index:>{width}}) {name:<{name_width}} {sizer(files)}")
|
|
hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5"
|
|
while True:
|
|
text = _ask(f"Numbers ({hint}), Enter for all, q to quit: ")
|
|
if not text:
|
|
return []
|
|
if text.lower() == "q":
|
|
_quit()
|
|
try:
|
|
chosen = parse_selection(text, len(rows), ordered=ordered)
|
|
except ValueError as exc:
|
|
print(f" {exc}")
|
|
continue
|
|
if chosen:
|
|
return [rows[number - 1][0] for number in chosen]
|
|
|
|
|
|
INSTALLED_ONLY = "installed"
|
|
|
|
|
|
def _prompt_custom_selection(
|
|
plat: str, files: list[dict], omitted: list[dict],
|
|
installed: tuple[list[dict], list[dict]] | None = None,
|
|
name: str = "",
|
|
) -> tuple[list[str], list[str], list[str]] | str | None:
|
|
"""Offer to narrow the install by system, core and region.
|
|
|
|
Enter installs everything, the default most runs want. Returns None
|
|
then, INSTALLED_ONLY for what `installed` (the files the cores found
|
|
installed read) holds, or the three choices. Each empty list keeps
|
|
everything on its axis.
|
|
"""
|
|
print(f"\n{name or display_name(plat)}: {_sized(files)}.")
|
|
print(" Enter) install everything")
|
|
if installed is not None:
|
|
print(f" i) only what the installed cores read: {_sized(installed[0])}")
|
|
print(" c) choose systems, cores and regions")
|
|
print(" q) quit")
|
|
while True:
|
|
answer = _ask("> ").lower()
|
|
if answer == "":
|
|
return None
|
|
if answer == "q":
|
|
_quit()
|
|
if answer == "i" and installed is not None:
|
|
return INSTALLED_ONLY
|
|
if answer == "c":
|
|
break
|
|
keys = "Enter, i, c or q" if installed is not None else "Enter, c or q"
|
|
print(f" Type {keys}.")
|
|
|
|
choices = available_choices(files, omitted)
|
|
if OTHER_SYSTEMS in choices["systems"]:
|
|
print(f"\n'{OTHER_SYSTEMS}' holds what cores load for systems this platform")
|
|
print("does not list, such as game engines and extra computers.")
|
|
systems = _pick("Systems:", [
|
|
(name, [f for f in files if _chosen(f, {name})])
|
|
for name in choices["systems"]
|
|
], sizer=_system_sized)
|
|
kept, kept_omitted = narrow(files, omitted, systems, [], [])
|
|
|
|
cores: list[str] = []
|
|
core_names = available_choices(kept, kept_omitted)["cores"]
|
|
if core_names:
|
|
print("\nThe platform's own files for these systems are always kept;")
|
|
print("the extra files each core loads can be narrowed.")
|
|
cores = _pick("Cores:", [
|
|
(name, _filter_by_target(
|
|
[f for f in kept if f.get("cores") is not None], [name]))
|
|
for name in core_names
|
|
], sizer=_core_sized)
|
|
kept, kept_omitted = narrow(kept, kept_omitted, [], cores, [])
|
|
|
|
regions: list[str] = []
|
|
region_names = available_choices(kept, kept_omitted)["regions"]
|
|
if region_names:
|
|
print("\nSome systems have one BIOS per region. Keeping only the")
|
|
print("preferred region removes the others where a match exists.")
|
|
regions = _pick("Regions:", [
|
|
(name, [f for f in kept if name in (f.get("regions") or [])])
|
|
for name in region_names
|
|
], ordered=True)
|
|
return systems, cores, regions
|
|
|
|
|
|
def _same_choice_hint(plat: str, systems: list[str], cores: list[str], regions: list[str]) -> str:
|
|
"""The options that repeat a choice made at the prompts."""
|
|
def quoted(value: str) -> str:
|
|
return f'"{value}"' if any(c in value for c in " ;&|") else value
|
|
|
|
parts = [f"--platform {plat}"]
|
|
parts += [f"--system {quoted(name)}" for name in systems]
|
|
parts += [f"--core {quoted(name)}" for name in cores]
|
|
if regions:
|
|
parts.append(f"--region {','.join(regions)}")
|
|
return " ".join(parts)
|
|
|
|
|
|
def system_aliases(manifest: dict | None) -> dict[str, str]:
|
|
"""The platform's own names for its systems, then the titles the site
|
|
and the installer print (SNK Neo Geo CD), each with the system id.
|
|
|
|
A title two systems share names neither, and the platform's own name
|
|
wins over a title spelled the same.
|
|
"""
|
|
aliases = {
|
|
alias: system
|
|
for system, names in ((manifest or {}).get("system_names") or {}).items()
|
|
for alias in names
|
|
}
|
|
taken = {_name_key(alias) for alias in aliases}
|
|
titles = (manifest or {}).get("system_titles") or {}
|
|
counts: dict[str, int] = {}
|
|
for title in titles.values():
|
|
counts[_name_key(title)] = counts.get(_name_key(title), 0) + 1
|
|
for system, title in titles.items():
|
|
key = _name_key(title)
|
|
if key and counts[key] == 1 and key not in taken:
|
|
aliases[title] = system
|
|
return aliases
|
|
|
|
|
|
def _system_label(name: str, manifest: dict | None) -> str:
|
|
"""A system id with the names the platform gives it: nintendo-gba (gba)."""
|
|
names = ((manifest or {}).get("system_names") or {}).get(name)
|
|
return f"{name} ({', '.join(names)})" if names else name
|
|
|
|
|
|
def _print_choices(
|
|
plat: str, files: list[dict], omitted: list[dict], systems: bool,
|
|
manifest: dict | None = None,
|
|
) -> None:
|
|
"""What --system or --core accepts for a platform, with what each holds."""
|
|
choices = available_choices(files, omitted)
|
|
names = choices["systems"] if systems else choices["cores"]
|
|
if not names:
|
|
print(f" {plat} records no {'systems' if systems else 'cores'}")
|
|
return
|
|
labels = {name: _system_label(name, manifest) if systems else name for name in names}
|
|
width = min(max(len(label) for label in labels.values()), 40)
|
|
for name in names:
|
|
held = (
|
|
[f for f in files if _chosen(f, {name})]
|
|
if systems
|
|
else _filter_by_target([f for f in files if f.get("cores") is not None], [name])
|
|
)
|
|
print(f" {labels[name]:<{width}} "
|
|
f"{_system_sized(held) if systems else _core_sized(held)}")
|
|
|
|
|
|
def emulator_names(manifest: dict, files: list[dict], omitted: list[dict]) -> dict[str, str]:
|
|
"""Every name --emulator accepts, with the profile each one stands for.
|
|
|
|
A profile answers to its own name and to the names its core is built
|
|
under (core_names: beetle_psx and mednafen_psx). A core named by a file
|
|
and by no profile stands for itself.
|
|
"""
|
|
names = {core: core for core in available_choices(files, omitted)["cores"]}
|
|
for profile, aliases in (manifest.get("core_names") or {}).items():
|
|
names[profile] = profile
|
|
for alias in aliases:
|
|
names.setdefault(alias, profile)
|
|
return names
|
|
|
|
|
|
def _print_emulators(plat: str, manifest: dict, files: list[dict], omitted: list[dict]) -> None:
|
|
"""What --emulator accepts for a platform, one name per line."""
|
|
names = emulator_names(manifest, files, omitted)
|
|
if not names:
|
|
print(f" {plat} records no emulators")
|
|
return
|
|
width = min(max(len(name) for name in names), 40)
|
|
for name in sorted(names):
|
|
profile = names[name]
|
|
print(f" {name:<{width}} {'' if profile == name else 'same as ' + profile}".rstrip())
|
|
|
|
|
|
# RomM 5.4 places each platform's firmware by `filesystem.structure.firmware`
|
|
# in config.yml, "bios/{platform}" by default, relative to the library
|
|
# (rommapp/romm 5.4.0 68f71259, backend/config/config_manager.py:58,101-109,
|
|
# 205-225,564-568). The library and config.yml both sit under the RomM base
|
|
# folder (config/__init__.py:46-48, config_manager.py:264-265). A library
|
|
# laid out as {platform}/roms makes RomM stop until config.yml declares its
|
|
# templates, and RomM suggests firmware: "{platform}/bios" there
|
|
# (config_manager.py:1105-1124).
|
|
|
|
|
|
def parse_romm_firmware(template: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
"""The literal folders before and after {platform}, as RomM parses them."""
|
|
sections = [section for section in template.strip().split("/") if section]
|
|
if not sections:
|
|
raise ValueError("the RomM firmware template is empty")
|
|
|
|
def macro(section: str) -> str | None:
|
|
if section.startswith("{") and section.endswith("}"):
|
|
return section[1:-1].strip()
|
|
return None
|
|
|
|
if any(section in (".", "..") for section in sections):
|
|
raise ValueError(f"the RomM firmware template {template!r} climbs out of the library")
|
|
platform_at = [i for i, section in enumerate(sections) if macro(section) == "platform"]
|
|
if len(platform_at) != 1:
|
|
raise ValueError(f"the RomM firmware template {template!r} needs one '{{platform}}'")
|
|
before, after = sections[:platform_at[0]], sections[platform_at[0] + 1:]
|
|
if any(macro(section) is not None for section in before + after):
|
|
raise ValueError(f"the RomM firmware template {template!r} takes literal folder "
|
|
"names around '{platform}'")
|
|
return tuple(before), tuple(after)
|
|
|
|
|
|
def _romm_configured_firmware(config: Path) -> str | None:
|
|
"""filesystem.structure.firmware from RomM's config.yml, read without a
|
|
YAML library: only that nested scalar is needed."""
|
|
try:
|
|
lines = config.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
return None
|
|
path: list[tuple[int, str]] = []
|
|
for line in lines:
|
|
stripped = line.split(" #", 1)[0].rstrip()
|
|
if not stripped.strip() or stripped.lstrip().startswith(("#", "-")):
|
|
continue
|
|
indent = len(stripped) - len(stripped.lstrip())
|
|
key, sep, value = stripped.strip().partition(":")
|
|
if not sep:
|
|
continue
|
|
while path and path[-1][0] >= indent:
|
|
path.pop()
|
|
keys = [name for _indent, name in path] + [key.strip().strip("'\"")]
|
|
if keys == ["filesystem", "structure", "firmware"] and value.strip():
|
|
return value.strip().strip("'\"")
|
|
path.append((indent, key.strip().strip("'\"")))
|
|
return None
|
|
|
|
|
|
def romm_firmware_layout(
|
|
bios_path: Path, template: str | None
|
|
) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
|
|
"""Where RomM reads each platform's firmware, relative to its library.
|
|
|
|
bios_path is the folder the default template names, so its parent is the
|
|
library. The template comes from --romm-firmware, else from config.yml
|
|
beside the library. None keeps the pack's own bios/<platform> layout.
|
|
"""
|
|
library = bios_path.parent
|
|
if template is None:
|
|
template = _romm_configured_firmware(library.parent / "config" / "config.yml")
|
|
if template is not None:
|
|
return parse_romm_firmware(template)
|
|
platform_first = not (library / "roms").is_dir() and any(
|
|
path.is_dir() for path in library.glob("*/roms")
|
|
)
|
|
if platform_first:
|
|
raise ValueError(
|
|
f"{library} keeps its roms inside each platform folder, and RomM then "
|
|
"reads firmware where filesystem.structure.firmware in its config.yml "
|
|
"says, which was not found here. RomM suggests \"{platform}/bios\" for "
|
|
"this layout: run again with --romm-firmware \"{platform}/bios\", or "
|
|
"with the value config.yml holds."
|
|
)
|
|
return None
|
|
|
|
|
|
def romm_relayout(
|
|
files: list[dict], bios_path: Path,
|
|
before: tuple[str, ...], after: tuple[str, ...],
|
|
) -> tuple[list[dict], Path]:
|
|
"""The manifest's bios/<platform>/<file> entries under RomM's template,
|
|
with the library as the root they are relative to."""
|
|
if (before, after) == (("bios",), ()):
|
|
return files, bios_path
|
|
moved = []
|
|
for entry in files:
|
|
slug, _, rest = str(entry["dest"]).partition("/")
|
|
if not rest:
|
|
# Firmware of no platform: RomM lists none, the default place stays.
|
|
moved.append({**entry, "dest": f"bios/{slug}"})
|
|
continue
|
|
moved.append({**entry, "dest": "/".join((*before, slug, *after, rest))})
|
|
return moved, bios_path.parent
|
|
|
|
|
|
def _older_list_advice() -> str:
|
|
"""How to reach a file list that records what an option needs.
|
|
|
|
A list without the fields comes from an older release. Pinned with
|
|
RETROBIOS_REF, as the docs suggest for reproducibility, "a newer one"
|
|
meant unsetting the variable, and nothing said so.
|
|
"""
|
|
if RELEASE_REF != DEFAULT_RELEASE_REF:
|
|
return (f" RETROBIOS_REF={RELEASE_REF} selects file lists published before "
|
|
"this option existed: unset RETROBIOS_REF to use the current ones, "
|
|
"or run without the option.")
|
|
return (" The file list comes from an older release: run the one-line "
|
|
"installer again to fetch the current one.")
|
|
|
|
|
|
def _narrow_to_emulators(
|
|
names: list[str], manifest: dict, plat: str,
|
|
files: list[dict], omitted: list[dict],
|
|
) -> tuple[list[dict], list[dict]]:
|
|
"""Keep what the named emulators read, as the pack built for them.
|
|
|
|
An emulator page offers this as one command, so a name the platform
|
|
does not run is refused rather than read as nothing to install.
|
|
"""
|
|
if "system_cores" not in manifest:
|
|
print(f"Error: the {plat} file list does not say which emulators read "
|
|
"which systems, which --emulator needs.", file=sys.stderr)
|
|
print(_older_list_advice(), file=sys.stderr)
|
|
sys.exit(1)
|
|
known = sorted(emulator_names(manifest, files, omitted))
|
|
try:
|
|
chosen = resolve_choices(names, known, "emulator")
|
|
except ValueError as exc:
|
|
print(f"Error: {plat}: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
before = len(files)
|
|
files, omitted = narrow_to_cores(manifest, chosen, files, omitted)
|
|
# A whole-platform install keeps the platform's files of a system no
|
|
# core is mapped to. Here only the named emulators' systems are wanted.
|
|
cores = designated_cores(manifest, chosen)
|
|
reached = {
|
|
system for system, readers in (manifest.get("system_cores") or {}).items()
|
|
if cores.intersection(readers)
|
|
}
|
|
|
|
def theirs(entry: dict) -> bool:
|
|
return entry.get("cores") is not None or bool(reached.intersection(entry_systems(entry)))
|
|
|
|
files = [entry for entry in files if theirs(entry)]
|
|
omitted = [entry for entry in omitted if theirs(entry)]
|
|
print(f" Narrowed {before} -> {len(files)} files to {', '.join(chosen)}")
|
|
return files, omitted
|
|
|
|
|
|
def _requested_narrowing(
|
|
args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict],
|
|
manifest: dict | None = None,
|
|
) -> tuple[list[str], list[str], list[str]] | None:
|
|
"""The systems, cores and regions named on the command line, checked."""
|
|
if not (args.system or args.core or args.region):
|
|
return None
|
|
if not records_selection(files):
|
|
# Without the fields, a region filter would match nothing and install
|
|
# everything: refused rather than ignored.
|
|
print(
|
|
f"Error: the {plat} file list records no systems or regions, so "
|
|
"--system, --core and --region cannot narrow it.",
|
|
file=sys.stderr,
|
|
)
|
|
print(_older_list_advice(), file=sys.stderr)
|
|
sys.exit(1)
|
|
choices = available_choices(files, omitted)
|
|
try:
|
|
systems = resolve_choices(
|
|
args.system or [], choices["systems"], "system", system_aliases(manifest),
|
|
key_file_holders(files, omitted),
|
|
)
|
|
cores = resolve_choices(args.core or [], choices["cores"], "core")
|
|
regions = resolve_regions(args.region or [])
|
|
except ValueError as exc:
|
|
print(f"Error: {plat}: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return systems, cores, regions
|
|
|
|
|
|
# A pack too large for one release file is published as ZIP parts
|
|
# (scripts/split_pack.py), each holding at its root a marker that names the
|
|
# pack, the part and every part with its size. Extracted with the files,
|
|
# the markers say which part a folder lacks.
|
|
_PART_FILE = re.compile(r"^(?P<stem>.+_BIOS_Pack)\.part(?P<index>\d+)of(?P<count>\d+)\.zip$")
|
|
_PART_MARKER = re.compile(r"^(?P<stem>.+_BIOS_Pack)\.part(?P<index>\d+)of(?P<count>\d+)\.txt$")
|
|
MAX_MARKER_BYTES = 64 * 1024
|
|
MAX_PARTS = 999
|
|
|
|
|
|
class PackPart(NamedTuple):
|
|
"""What one part's marker says, and the folder it was found in."""
|
|
|
|
pack: str
|
|
index: int
|
|
count: int
|
|
parts: tuple[tuple[str, int], ...]
|
|
folder: Path
|
|
|
|
|
|
def read_part_marker(path: Path) -> PackPart | None:
|
|
"""The marker at path, None when it is not one split_pack wrote."""
|
|
match = _PART_MARKER.match(path.name)
|
|
if match is None:
|
|
return None
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
text = handle.read(MAX_MARKER_BYTES).decode("utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
return None
|
|
pack = ""
|
|
index = count = 0
|
|
parts: list[tuple[str, int]] = []
|
|
for line in text.splitlines():
|
|
key, _sep, value = line.partition(":")
|
|
value = value.strip()
|
|
if key == "pack":
|
|
pack = value
|
|
elif key == "part":
|
|
numbers = value.split(" of ")
|
|
if len(numbers) == 2 and all(n.isdigit() for n in numbers):
|
|
index, count = int(numbers[0]), int(numbers[1])
|
|
elif key == "file":
|
|
size, _space, name = value.partition(" ")
|
|
if size.isdigit() and _PART_FILE.match(name.strip()):
|
|
parts.append((name.strip(), int(size)))
|
|
if (
|
|
not pack.endswith("_BIOS_Pack.zip")
|
|
or not 1 <= index <= count <= MAX_PARTS
|
|
or len(parts) != count
|
|
or (index, count) != (int(match["index"]), int(match["count"]))
|
|
):
|
|
return None
|
|
return PackPart(pack, index, count, tuple(parts), path.parent)
|
|
|
|
|
|
def find_part_markers(root: Path) -> list[PackPart]:
|
|
"""Markers in root and in the folders right below it.
|
|
|
|
A part extracted into a folder named after itself (the default of
|
|
several archive tools) leaves its marker one level down.
|
|
"""
|
|
found: list[PackPart] = []
|
|
try:
|
|
entries = sorted(root.iterdir())
|
|
except OSError:
|
|
return found
|
|
for entry in entries:
|
|
if entry.is_file():
|
|
marker = read_part_marker(entry)
|
|
if marker:
|
|
found.append(marker)
|
|
elif entry.is_dir() and not entry.is_symlink() and entry.name != BACKUP_DIR:
|
|
try:
|
|
below = sorted(entry.iterdir())
|
|
except OSError:
|
|
continue
|
|
for child in below:
|
|
marker = read_part_marker(child) if child.is_file() else None
|
|
if marker:
|
|
found.append(marker)
|
|
return found
|
|
|
|
|
|
def split_pack_lines(root: Path) -> list[str]:
|
|
"""What --check says about the parts of a pack extracted into root: one
|
|
missing, or one extracted into a folder of its own."""
|
|
by_pack: dict[str, list[PackPart]] = {}
|
|
for marker in find_part_markers(root):
|
|
by_pack.setdefault(marker.pack, []).append(marker)
|
|
lines: list[str] = []
|
|
for pack, markers in sorted(by_pack.items()):
|
|
listed = max(markers, key=lambda m: m.count)
|
|
here = {m.index for m in markers if m.folder == root and m.count == listed.count}
|
|
missing = [n for n in range(1, listed.count + 1) if n not in here]
|
|
deep = sorted({m.folder for m in markers if m.folder != root})
|
|
if not missing and not deep:
|
|
continue
|
|
lines.append(f"\n {pack} comes in {listed.count} parts, all needed.")
|
|
for folder in deep:
|
|
names = sorted(m.index for m in markers if m.folder == folder)
|
|
which = ", ".join(str(n) for n in names)
|
|
lines.append(f" {'Parts' if len(names) > 1 else 'Part'} {which} went into "
|
|
f"{folder}, a folder of its own: move what it holds up into {root}.")
|
|
absent = [n for n in missing
|
|
if not any(m.index == n and m.folder != root for m in markers)]
|
|
for number in absent:
|
|
name, size = listed.parts[number - 1]
|
|
lines.append(f" missing: part {number}, {name} ({format_size(size)})")
|
|
if absent:
|
|
lines.append(f" Download {'it' if len(absent) == 1 else 'them'} and extract "
|
|
f"into {root}, beside the other parts.")
|
|
return lines
|
|
|
|
|
|
class PackSource:
|
|
"""A pack already downloaded, whole or in parts, read in place of the network.
|
|
|
|
Every file it gives is checked against the hashes the file list records,
|
|
as a download is.
|
|
"""
|
|
|
|
def __init__(self, paths: list[Path]) -> None:
|
|
self.files = self._resolve(paths)
|
|
first = _PART_FILE.match(self.files[0].name)
|
|
self.name = f"{first['stem']}.zip" if first else self.files[0].name
|
|
self.archives: list[zipfile.ZipFile] = []
|
|
self.members: dict[str, tuple[zipfile.ZipFile, zipfile.ZipInfo]] = {}
|
|
try:
|
|
for path in self.files:
|
|
archive = zipfile.ZipFile(path)
|
|
self.archives.append(archive)
|
|
for info in archive.infolist():
|
|
if not info.is_dir():
|
|
self.members.setdefault(info.filename, (archive, info))
|
|
except (OSError, zipfile.BadZipFile) as exc:
|
|
self.close()
|
|
raise ValueError(f"{path.name} cannot be read as a ZIP ({exc}). A download "
|
|
"cut short gives this: download it again.") from exc
|
|
self.listed = self._pack_list()
|
|
self.by_sha1 = {f["sha1"]: f["path"] for f in self.listed}
|
|
|
|
@staticmethod
|
|
def _resolve(paths: list[Path]) -> list[Path]:
|
|
"""The archives of one pack: a whole ZIP, or every one of its parts."""
|
|
given: list[Path] = []
|
|
for path in paths:
|
|
if path.is_dir():
|
|
given += sorted(p for p in path.iterdir()
|
|
if p.is_file() and p.name.endswith(".zip")
|
|
and "_BIOS_Pack" in p.name)
|
|
elif path.is_file():
|
|
given.append(path)
|
|
else:
|
|
raise ValueError(f"{path} does not exist")
|
|
if not given:
|
|
raise ValueError(f"no pack ZIP in {', '.join(map(str, paths))}")
|
|
packs: dict[str, list[Path]] = {}
|
|
for path in given:
|
|
part = _PART_FILE.match(path.name)
|
|
packs.setdefault(f"{part['stem']}.zip" if part else path.name, []).append(path)
|
|
if len(packs) > 1:
|
|
raise ValueError(f"these are several packs ({', '.join(sorted(packs))}): "
|
|
"name the one to install from")
|
|
name, chosen = next(iter(packs.items()))
|
|
whole = [p for p in chosen if not _PART_FILE.match(p.name)]
|
|
if whole:
|
|
return whole[:1]
|
|
count = int(_PART_FILE.match(chosen[0].name)["count"])
|
|
if count > MAX_PARTS or any(
|
|
int(_PART_FILE.match(p.name)["count"]) != count for p in chosen
|
|
):
|
|
raise ValueError(f"the parts of {name} disagree on how many there are")
|
|
stem = name[: -len(".zip")]
|
|
folders = list(dict.fromkeys(p.parent for p in chosen))
|
|
found: list[Path] = []
|
|
missing: list[str] = []
|
|
for number in range(1, count + 1):
|
|
part_name = f"{stem}.part{number}of{count}.zip"
|
|
place = next((folder / part_name for folder in folders
|
|
if (folder / part_name).is_file()), None)
|
|
if place is None:
|
|
missing.append(part_name)
|
|
else:
|
|
found.append(place)
|
|
if missing:
|
|
where = ", ".join(map(str, folders))
|
|
raise ValueError(f"{name} comes in {count} parts and {len(missing)} "
|
|
f"{'is' if len(missing) == 1 else 'are'} not in {where}: "
|
|
f"{', '.join(missing)}")
|
|
return found
|
|
|
|
def _pack_list(self) -> list[dict]:
|
|
"""The file list the pack carries, every file with its SHA-1 and size."""
|
|
held = self.members.get("manifest.json")
|
|
if held is None:
|
|
raise ValueError(f"{self.name} holds no manifest.json. Name the file list "
|
|
"with --manifest.")
|
|
archive, info = held
|
|
if info.file_size > MAX_MANIFEST_BYTES:
|
|
raise ValueError(f"the manifest.json of {self.name} exceeds "
|
|
f"{MAX_MANIFEST_BYTES} bytes")
|
|
try:
|
|
data = json.loads(archive.read(info).decode("utf-8"))
|
|
except (OSError, zipfile.BadZipFile, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValueError(f"the manifest.json of {self.name} cannot be read: {exc}") from exc
|
|
files = data.get("files") if isinstance(data, dict) else None
|
|
if not isinstance(files, list):
|
|
raise ValueError(f"the manifest.json of {self.name} lists no files")
|
|
listed = []
|
|
for item in files:
|
|
if (
|
|
isinstance(item, dict)
|
|
and isinstance(item.get("path"), str)
|
|
and isinstance(item.get("sha1"), str)
|
|
and _SHA1_RE.fullmatch(item["sha1"].lower())
|
|
and isinstance(item.get("size"), int)
|
|
):
|
|
listed.append({"path": item["path"], "sha1": item["sha1"].lower(),
|
|
"size": item["size"]})
|
|
return listed
|
|
|
|
def platforms(self) -> list[str]:
|
|
"""The platforms the pack's name spells, several for a grouped pack."""
|
|
stem = "_" + self.name[: -len("_BIOS_Pack.zip")] + "_"
|
|
return [plat for plat in AVAILABLE_PLATFORMS
|
|
if f"_{PLATFORM_NAMES[plat].replace(' ', '_')}_" in stem]
|
|
|
|
def manifest(self, plat: str) -> dict:
|
|
"""A file list for plat built from the pack's own: the same files at
|
|
the same places, as extracting the pack would leave them."""
|
|
return {
|
|
"manifest_version": 2,
|
|
"platform": plat,
|
|
"files": [
|
|
{"dest": f["path"], "sha1": f["sha1"], "size": f["size"],
|
|
"repo_path": f"bios/{f['path']}"}
|
|
for f in self.listed
|
|
],
|
|
}
|
|
|
|
def find(self, entry: dict) -> tuple[zipfile.ZipFile, zipfile.ZipInfo] | None:
|
|
"""The member holding an entry's bytes: by its SHA-1 in the pack's list,
|
|
else at its destination. The size has to agree either way."""
|
|
sha1 = str(entry.get("sha1", "")).lower()
|
|
for name in (self.by_sha1.get(sha1), entry.get("dest")):
|
|
held = self.members.get(name) if isinstance(name, str) else None
|
|
if held is not None and held[1].file_size == entry.get("size"):
|
|
return held
|
|
return None
|
|
|
|
def tree(self, prefix: str) -> Iterator[tuple[str, int, Callable[[], BinaryIO]]]:
|
|
"""(path in the tree, size, opener) of every member below prefix."""
|
|
lead = f"{prefix.rstrip('/')}/" if prefix else ""
|
|
for name, (archive, info) in self.members.items():
|
|
if name.startswith(lead):
|
|
yield name[len(lead):], info.file_size, (
|
|
lambda archive=archive, info=info: archive.open(info))
|
|
|
|
def close(self) -> None:
|
|
for archive in self.archives:
|
|
archive.close()
|
|
self.archives = []
|
|
|
|
|
|
class LocalSources:
|
|
"""Where this run reads instead of the network: a file list, a pack, the
|
|
folders it copies BIOS files from."""
|
|
|
|
def __init__(self) -> None:
|
|
self.manifest: Path | None = None
|
|
self.pack: PackSource | None = None
|
|
# The folders named with --from, read once for every platform.
|
|
self.folders: list[FolderIndex] = []
|
|
# Every folder index of the run still in use, closed at its end.
|
|
self.indexes: weakref.WeakSet[FolderIndex] = weakref.WeakSet()
|
|
|
|
@property
|
|
def offline(self) -> bool:
|
|
"""No network request in this run: a pack is read, or --offline."""
|
|
return self.pack is not None or offline_requested()
|
|
|
|
def reset(self) -> None:
|
|
if self.pack is not None:
|
|
self.pack.close()
|
|
self.close_indexes()
|
|
self.manifest = None
|
|
self.pack = None
|
|
self.folders = []
|
|
|
|
def close_indexes(self) -> None:
|
|
for index in list(self.indexes):
|
|
index.close()
|
|
self.indexes = weakref.WeakSet()
|
|
|
|
|
|
LOCAL = LocalSources()
|
|
NOT_IN_PACK = "not in the pack"
|
|
|
|
|
|
class _Offline(OSError):
|
|
"""A network request in a run told to make none."""
|
|
|
|
|
|
# --offline, or this variable set to anything but 0, which every run the
|
|
# full-screen interface starts inherits. RETROBIOS_OFFLINE stays the build
|
|
# scripts' own: CI and the test suite set it for every process they start.
|
|
OFFLINE_VARIABLE = "RETROBIOS_INSTALL_OFFLINE"
|
|
OFFLINE_WORDS = "this run makes no network request (--offline)"
|
|
|
|
|
|
def offline_requested() -> bool:
|
|
return os.environ.get(OFFLINE_VARIABLE, "") not in ("", "0")
|
|
|
|
|
|
def _network_allowed(what: str) -> None:
|
|
if LOCAL.pack is not None:
|
|
raise _Offline(f"{what}: this run reads a pack and makes no network request")
|
|
if offline_requested():
|
|
raise _Offline(f"{what}: {OFFLINE_WORDS}")
|
|
|
|
|
|
def read_local_manifest(path: Path, plat: str) -> dict:
|
|
"""A file list saved on disk, checked as a downloaded one is."""
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
raw = handle.read(MAX_MANIFEST_BYTES + 1)
|
|
except OSError as exc:
|
|
print(f" Cannot read the file list {path}: {exc.strerror or exc}.", file=sys.stderr)
|
|
sys.exit(1)
|
|
try:
|
|
if len(raw) > MAX_MANIFEST_BYTES:
|
|
raise ValueError(f"it exceeds {MAX_MANIFEST_BYTES} bytes")
|
|
try:
|
|
data = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValueError(f"not JSON ({exc})") from exc
|
|
return _validate_manifest(data, plat)
|
|
except ValueError as exc:
|
|
EVENTS.emit("error", stage="manifest", reason="invalid", platform=plat,
|
|
message=f"the file list is not valid: {exc}")
|
|
print(f" The file list {path} is not valid for {plat}: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def local_manifest_platform(path: Path) -> str | None:
|
|
"""The platform a saved file list names, None when it names none."""
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
data = json.loads(handle.read(MAX_MANIFEST_BYTES).decode("utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
return None
|
|
plat = data.get("platform") if isinstance(data, dict) else None
|
|
return plat if plat in AVAILABLE_PLATFORMS else None
|
|
|
|
|
|
def _extract_from_pack(
|
|
f: dict, bios_path: Path, dest: Path, progress: Progress | None,
|
|
) -> tuple[str, bool, str]:
|
|
"""Copy one file out of the pack, checked like a download."""
|
|
pack = LOCAL.pack
|
|
held = pack.find(f) if pack is not None else None
|
|
if held is None:
|
|
return f["dest"], False, NOT_IN_PACK
|
|
archive, info = held
|
|
return _write_verified(f, bios_path, dest, lambda: archive.open(info), progress,
|
|
"the pack", named=f"{info.filename} inside {archive.filename}")
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _open_copy(copy: LocalCopy) -> Iterator[BinaryIO]:
|
|
"""The bytes of a copy found on this machine, opened for reading only."""
|
|
if not copy.member:
|
|
with open(copy.path, "rb") as handle:
|
|
yield handle
|
|
return
|
|
with copy.index.open_member(copy.path, copy.member) as handle:
|
|
yield handle
|
|
|
|
|
|
def _copy_local(
|
|
f: dict, bios_path: Path, dest: Path, copy: LocalCopy, progress: Progress | None,
|
|
) -> tuple[str, bool, str]:
|
|
"""Copy one file found on this machine to its place, checked like a
|
|
download. Its bytes count as already here, not in the download speed."""
|
|
return _write_verified(f, bios_path, dest, lambda: _open_copy(copy), progress,
|
|
f"the copy in {copy.index.label}", local=True,
|
|
named=copy.named())
|
|
|
|
|
|
class _SourceUnreadable(Exception):
|
|
"""The bytes to write could not be read: the source's fault, never the
|
|
folder's, so it falls back like any copy that failed."""
|
|
|
|
|
|
def unreadable_words(exc: BaseException) -> str:
|
|
"""Why a file or a ZIP member could not be read, in words."""
|
|
if isinstance(exc, RuntimeError) and "encrypted" in str(exc):
|
|
return "it is encrypted, a password is needed"
|
|
if isinstance(exc, NotImplementedError):
|
|
return "it is packed with a compression method this installer cannot read"
|
|
if isinstance(exc, (zipfile.BadZipFile, zlib.error, EOFError)):
|
|
return "it is damaged"
|
|
if isinstance(exc, OSError) and exc.strerror:
|
|
return exc.strerror
|
|
return str(exc) or type(exc).__name__
|
|
|
|
|
|
def _write_verified(
|
|
f: dict, bios_path: Path, dest: Path,
|
|
source: Callable[[], ContextManager[BinaryIO]], progress: Progress | None,
|
|
held_in: str, local: bool = False, named: str = "",
|
|
) -> tuple[str, bool, str]:
|
|
"""Write the bytes source opens at dest once they match the list's size
|
|
and hashes, the file in place kept aside first when it differs.
|
|
|
|
The source is opened before the temporary file, and its read errors
|
|
are told apart from the write errors: a copy that could not be read
|
|
was reported as a folder not writable. named is the source in the
|
|
reason then, held_in when empty."""
|
|
expected = {name: f[name].lower() for name in ("sha256", "sha1") if f.get(name)}
|
|
tmp_path: Path | None = None
|
|
counted = 0
|
|
|
|
def unreadable(exc: BaseException) -> _SourceUnreadable:
|
|
return _SourceUnreadable(f"{named or held_in} could not be read: "
|
|
f"{unreadable_words(exc)}")
|
|
|
|
try:
|
|
with contextlib.ExitStack() as stack:
|
|
try:
|
|
src = stack.enter_context(source())
|
|
except _MEMBER_ERRORS as exc:
|
|
raise unreadable(exc) from exc
|
|
out = stack.enter_context(tempfile.NamedTemporaryFile(
|
|
mode="wb", dir=dest.parent, prefix=f".{dest.name}.", suffix=".part",
|
|
delete=False,
|
|
))
|
|
tmp_path = Path(out.name)
|
|
size = 0
|
|
while True:
|
|
if _stop.is_set():
|
|
raise _Interrupted()
|
|
try:
|
|
chunk = src.read(min(1024 * 1024, f["size"] + 1 - size))
|
|
except _MEMBER_ERRORS as exc:
|
|
raise unreadable(exc) from exc
|
|
if not chunk:
|
|
break
|
|
size += len(chunk)
|
|
if size > f["size"]:
|
|
raise ValueError(f"{held_in} holds more bytes than the list records")
|
|
out.write(chunk)
|
|
if progress is not None:
|
|
progress.add(len(chunk), resumed=local, network=False)
|
|
counted += len(chunk)
|
|
if size != f["size"]:
|
|
raise ValueError(f"{held_in} holds {size} bytes, the list records {f['size']}")
|
|
actual = _digest_file(tmp_path, tuple(expected))
|
|
for name, label in (("sha256", "SHA-256"), ("sha1", "SHA-1")):
|
|
if name in expected and actual[name] != expected[name]:
|
|
raise ValueError(f"{label} mismatch in {held_in}")
|
|
os.chmod(tmp_path, (0o777 if f.get("executable") else 0o666) & ~_UMASK)
|
|
_clear_read_only(dest)
|
|
KEPT.keep(*_kept_slot(*_entry_place(bios_path, f)), dest, expected, tmp_path)
|
|
os.replace(tmp_path, dest)
|
|
tmp_path = None
|
|
return f["dest"], True, ""
|
|
except _Interrupted:
|
|
return f["dest"], False, "stopped"
|
|
except _NotKept as exc:
|
|
return f["dest"], False, f"{NOT_KEPT} ({exc})"
|
|
except (_SourceUnreadable, ValueError, *_MEMBER_ERRORS) as exc:
|
|
if progress is not None and counted:
|
|
progress.add(-counted, resumed=local, network=False)
|
|
if isinstance(exc, OSError) and _write_refused(exc):
|
|
return f["dest"], False, READ_ONLY if _read_only_file(dest) else NOT_WRITABLE
|
|
return f["dest"], False, str(exc)
|
|
finally:
|
|
if tmp_path is not None and tmp_path.exists():
|
|
tmp_path.unlink()
|
|
|
|
|
|
# Files --check reads to tell why a file is refused: past these, the folder
|
|
# is not one a person filled by hand, and walking it costs more than it tells.
|
|
MAX_SCANNED_FILES = 200_000
|
|
_ARCHIVE_SUFFIXES = (".zip", ".7z", ".rar")
|
|
# Cores named on the line that says who opens a file, before "and N more".
|
|
SHOWN_CORES = 4
|
|
|
|
|
|
# Folders a system writes on a drive for itself. No BIOS file a person
|
|
# copied is in them, and a Mac's Spotlight index alone holds thousands.
|
|
SYSTEM_FOLDERS = frozenset(name.casefold() for name in (
|
|
".Spotlight-V100", ".Trashes", ".fseventsd", ".TemporaryItems",
|
|
".DocumentRevisions-V100", "System Volume Information", "$RECYCLE.BIN",
|
|
))
|
|
# A Mac copying to a FAT or exFAT card writes ._name beside every file: its
|
|
# Finder data, 4 KB, never the file.
|
|
APPLE_DOUBLE = "._"
|
|
# Folders the kernel makes up on the fly: endless, and never a copy.
|
|
PSEUDO_FOLDERS = frozenset() if os.name == "nt" else frozenset(("/proc", "/sys", "/dev"))
|
|
# FILE_ATTRIBUTE_REPARSE_POINT: a junction, a mount point or a link on Windows.
|
|
_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
# Errors a member of someone's ZIP can raise on reading: damaged, encrypted
|
|
# (RuntimeError), or packed with a method zipfile lacks.
|
|
_MEMBER_ERRORS = (OSError, EOFError, RuntimeError, NotImplementedError,
|
|
zipfile.BadZipFile, zlib.error)
|
|
|
|
|
|
# C0 and C1 controls, DEL and lone surrogates: a file, folder or core name
|
|
# holding ESC would drive the terminal (clear it, or set the clipboard with
|
|
# OSC 52) instead of being shown.
|
|
_CONTROLS = re.compile("[\x00-\x1f\x7f-\x9f\ud800-\udfff]")
|
|
|
|
|
|
def screen_text(text: str) -> str:
|
|
"""Text as a terminal shows it, every control character a "?"."""
|
|
return _CONTROLS.sub("?", text)
|
|
|
|
|
|
def shown_lines(lines: list[str]) -> list[str]:
|
|
"""Lines for the terminal, a blank line opening one kept as it is: the
|
|
paths in them come from folders anyone may have filled."""
|
|
return [("\n" + screen_text(line[1:])) if line.startswith("\n") else screen_text(line)
|
|
for line in lines]
|
|
|
|
|
|
class LocalCopy(NamedTuple):
|
|
"""A file found on this machine with the contents an entry wants."""
|
|
index: FolderIndex
|
|
path: Path
|
|
# The member holding the bytes when path is a ZIP, else "".
|
|
member: str = ""
|
|
# In the BIOS folder at a place no entry of the list names: a mistake
|
|
# the copy fixes. A copy from another entry's place is no mistake.
|
|
misplaced: bool = False
|
|
|
|
def where(self) -> str:
|
|
"""The copy as the folder it was found in names it."""
|
|
rel = self.index.relative(self.path)
|
|
return screen_text(f"{rel}, inside as {self.member}" if self.member else rel)
|
|
|
|
def named(self) -> str:
|
|
"""The copy by its full path, the member first when it is one."""
|
|
return screen_text(f"{self.member} inside {self.path}" if self.member
|
|
else str(self.path))
|
|
|
|
|
|
# The archives a FolderIndex keeps open at once.
|
|
OPEN_ARCHIVES = 16
|
|
|
|
|
|
class FolderIndex:
|
|
"""The files under a folder, by name and by size, hashed on demand.
|
|
|
|
The folder is only read. A link is followed only where it leads to a
|
|
file inside the folder, so an old card holding a link to the home
|
|
folder is not walked through it. A ZIP named as the root is read as a
|
|
folder holding that one archive.
|
|
"""
|
|
|
|
def __init__(self, root: Path, label: str = "") -> None:
|
|
self.label = label or str(root)
|
|
self.root = root = root.resolve()
|
|
self.by_name: dict[str, list[Path]] = {}
|
|
self.by_size: dict[int, list[Path]] = {}
|
|
self.size: dict[Path, int] = {}
|
|
self.archives: list[Path] = []
|
|
# Name a ._ file stands beside (casefolded) -> the ._ files.
|
|
self.apple_double: dict[str, list[Path]] = {}
|
|
LOCAL.indexes.add(self)
|
|
# The list's destinations, casefolded (an archive among them is not
|
|
# unpacked for its members) and as the list writes them.
|
|
self.sealed: set[str] = set()
|
|
self.listed: set[str] = set()
|
|
self.truncated = False
|
|
self.copied = 0
|
|
self.fixed = 0
|
|
self._lock = threading.Lock()
|
|
self._sha1: dict[Path, str] = {}
|
|
self._digests: dict[tuple[Path, str, str], str] = {}
|
|
self._members: dict[Path, list[str]] = {}
|
|
self._member_sizes: dict[int, list[tuple[Path, str]]] | None = None
|
|
# (size, casefolded name) -> the files and the ZIP members, built
|
|
# the first time a copy is looked for.
|
|
self._loose_named: dict[tuple[int, str], list[Path]] | None = None
|
|
self._member_named: dict[int, dict[str, list[tuple[Path, str]]]] = {}
|
|
self._scans: dict[tuple[str, int, str], _HashScan] = {}
|
|
self._member_size: dict[tuple[Path, str], int] = {}
|
|
# The archives read so far, open, the last read at the end: a ZIP
|
|
# was opened again, its directory parsed again, for each member.
|
|
self._zips: dict[Path, zipfile.ZipFile] = {}
|
|
self._zip_lock = threading.Lock()
|
|
# The places this run writes, resolved. An archive among them is
|
|
# never kept open: Windows refuses to move aside or replace a file
|
|
# a process holds, and a --from folder can hold the BIOS folder.
|
|
self._written: set[Path] = set()
|
|
# (path, member or "") -> (its size or None when unknown, why) of each
|
|
# file, member or folder below root that could not be read.
|
|
self.unreadable: dict[tuple[Path, str], tuple[int | None, str]] = {}
|
|
# Folders below root on another drive, not entered.
|
|
self.other_drives: list[Path] = []
|
|
self.files = 0
|
|
if root.is_file():
|
|
if root.name.casefold().endswith(".zip"):
|
|
self.archives.append(root)
|
|
self.files = 1
|
|
return
|
|
seen = 0
|
|
try:
|
|
device = os.stat(root).st_dev
|
|
except OSError as exc:
|
|
self._unread(root, "", None, exc)
|
|
return
|
|
|
|
def unlisted(exc: OSError) -> None:
|
|
self._unread(Path(exc.filename or root), "", None, exc)
|
|
|
|
for folder, dirs, names in os.walk(root, onerror=unlisted):
|
|
dirs[:] = sorted(d for d in dirs
|
|
if d != BACKUP_DIR and d.casefold() not in SYSTEM_FOLDERS
|
|
and self._enters(Path(folder) / d, device))
|
|
for name in sorted(names):
|
|
path = Path(folder) / name
|
|
if name.startswith(APPLE_DOUBLE):
|
|
self.apple_double.setdefault(name[2:].casefold(), []).append(path)
|
|
continue
|
|
if name.startswith(".") and name.endswith(".part"):
|
|
continue # a download of this installer, not finished
|
|
try:
|
|
if path.is_symlink() and not _inside(path, root):
|
|
continue
|
|
if not path.is_file():
|
|
continue
|
|
size = path.stat().st_size
|
|
except OSError as exc:
|
|
self._unread(path, "", None, exc)
|
|
continue
|
|
self.by_name.setdefault(name.casefold(), []).append(path)
|
|
self.by_size.setdefault(size, []).append(path)
|
|
self.size[path] = size
|
|
if name.casefold().endswith(_ARCHIVE_SUFFIXES):
|
|
self.archives.append(path)
|
|
seen += 1
|
|
self.files = seen
|
|
if seen >= MAX_SCANNED_FILES:
|
|
self.truncated = True
|
|
return
|
|
|
|
def _enters(self, path: Path, device: int) -> bool:
|
|
"""Whether the walk goes into a folder: not a pseudo file system, a
|
|
Windows junction or reparse point (a link the walk cannot tell from
|
|
a folder, which led out of the folder named), nor another drive
|
|
mounted below the root, which is named instead."""
|
|
if str(path) in PSEUDO_FOLDERS:
|
|
return False
|
|
try:
|
|
held = os.lstat(path)
|
|
except OSError as exc:
|
|
self._unread(path, "", None, exc)
|
|
return False
|
|
if getattr(held, "st_file_attributes", 0) & _REPARSE_POINT:
|
|
return False
|
|
if held.st_dev != device:
|
|
self.other_drives.append(path)
|
|
return False
|
|
return True
|
|
|
|
def _archive(self, path: Path) -> zipfile.ZipFile:
|
|
"""The archive at path, opened once. Called with _zip_lock held.
|
|
|
|
A few stay open, the least recently read closed first: a card of
|
|
MAME sets would otherwise hold a descriptor per archive. Closing
|
|
one leaves a member being read open until it is closed itself.
|
|
An archive this run writes is never read this way (see writes).
|
|
"""
|
|
archive = self._zips.pop(path, None)
|
|
if archive is None:
|
|
archive = zipfile.ZipFile(path)
|
|
while len(self._zips) >= OPEN_ARCHIVES:
|
|
self._zips.pop(next(iter(self._zips))).close()
|
|
self._zips[path] = archive
|
|
return archive
|
|
|
|
def writes(self, paths: Collection[Path]) -> None:
|
|
"""Note the places this run writes, resolved, and close the archives
|
|
among them: the next read of one opens it and closes it at once."""
|
|
with self._zip_lock:
|
|
self._written.update(paths)
|
|
for held in [held for held in self._zips if self._is_written(held)]:
|
|
self._zips.pop(held).close()
|
|
|
|
def _is_written(self, path: Path) -> bool:
|
|
return bool(self._written) and (
|
|
path in self._written or Path(os.path.realpath(path)) in self._written)
|
|
|
|
def infolist(self, path: Path) -> list[zipfile.ZipInfo]:
|
|
with self._zip_lock:
|
|
if self._is_written(path):
|
|
with zipfile.ZipFile(path) as archive:
|
|
return archive.infolist()
|
|
return self._archive(path).infolist()
|
|
|
|
@contextlib.contextmanager
|
|
def open_member(self, path: Path, member: str) -> Iterator[BinaryIO]:
|
|
"""A member of an archive under the folder, read from any thread."""
|
|
with self._zip_lock:
|
|
if self._is_written(path):
|
|
archive = zipfile.ZipFile(path)
|
|
try:
|
|
handle = archive.open(member)
|
|
finally:
|
|
# The member holds the file until it is closed itself.
|
|
archive.close()
|
|
else:
|
|
handle = self._archive(path).open(member)
|
|
try:
|
|
yield handle
|
|
finally:
|
|
# The archive counts its open members: the count moves under
|
|
# the lock, as when the member was opened.
|
|
with self._zip_lock:
|
|
handle.close()
|
|
|
|
def open_archives(self) -> int:
|
|
with self._zip_lock:
|
|
return len(self._zips)
|
|
|
|
def close(self) -> None:
|
|
"""Close the archives the index holds open."""
|
|
with self._zip_lock:
|
|
for archive in self._zips.values():
|
|
archive.close()
|
|
self._zips.clear()
|
|
|
|
def seed(self, path: Path, sha1: str) -> None:
|
|
"""A SHA-1 already known, so the file is not read again."""
|
|
self._sha1[path] = sha1
|
|
|
|
def _unread(self, path: Path, member: str, size: int | None, exc: BaseException) -> None:
|
|
"""Note a file, a member or a folder that could not be read: a copy
|
|
the run needs may be the one, and it is named instead of dropped."""
|
|
self.unreadable.setdefault((path, member), (size, unreadable_words(exc)))
|
|
|
|
def sha1(self, path: Path) -> str:
|
|
if path not in self._sha1:
|
|
try:
|
|
self._sha1[path] = _sha1_file(path)
|
|
except OSError as exc:
|
|
self._unread(path, "", self.size.get(path), exc)
|
|
self._sha1[path] = ""
|
|
return self._sha1[path]
|
|
|
|
def digest(self, path: Path, algorithm: str, member: str = "") -> str:
|
|
"""The file's (or the member's) digest, read once, "" when unreadable."""
|
|
if algorithm == "sha1" and not member:
|
|
return self.sha1(path)
|
|
key = (path, member, algorithm)
|
|
if key not in self._digests:
|
|
try:
|
|
if member:
|
|
with self.open_member(path, member) as handle:
|
|
value = _digest_handle(handle, (algorithm,))[algorithm]
|
|
else:
|
|
value = _digest_file(path, (algorithm,))[algorithm]
|
|
except _MEMBER_ERRORS as exc:
|
|
size = self._member_size.get((path, member)) if member else self.size.get(path)
|
|
self._unread(path, member, size, exc)
|
|
value = ""
|
|
self._digests[key] = value
|
|
return self._digests[key]
|
|
|
|
def unreadable_for(self, sizes: Collection[int]) -> list[tuple[Path, str, str]]:
|
|
"""(path, member, why) of what could not be read and may hold a file
|
|
of one of sizes: its size is one of them, or unknown (a folder, an
|
|
archive whose list of members could not be read)."""
|
|
return sorted((path, member, why)
|
|
for (path, member), (size, why) in self.unreadable.items()
|
|
if size is None or size in sizes)
|
|
|
|
def _scan(self, kind: str, size: int, algorithm: str) -> _HashScan:
|
|
"""The files (kind "loose") or ZIP members ("member") of one size,
|
|
hashed in the order the folder was read, each read once."""
|
|
key = (kind, size, algorithm)
|
|
if key not in self._scans:
|
|
if kind == "loose":
|
|
self._scans[key] = _HashScan(
|
|
self.by_size.get(size, []), lambda path: self.digest(path, algorithm))
|
|
else:
|
|
self._scans[key] = _HashScan(
|
|
self._members_of_size(size),
|
|
lambda item: self.digest(item[0], algorithm, item[1]))
|
|
return self._scans[key]
|
|
|
|
def first_holding(self, sizes: set[int], sha1s: set[str]) -> Path | None:
|
|
"""The first file whose contents are one of sha1s, the smallest size
|
|
first, read only when the size fits."""
|
|
for size in sorted(sizes):
|
|
path = self._scan("loose", size, "sha1").first(sha1s)
|
|
if path is not None:
|
|
return path
|
|
return None
|
|
|
|
def zip_members(self, path: Path) -> list[str]:
|
|
if path not in self._members:
|
|
try:
|
|
# Read once and closed: the archive may be a file of the
|
|
# list, which Windows will not replace while it is open.
|
|
with zipfile.ZipFile(path) as archive:
|
|
self._members[path] = [n.rsplit("/", 1)[-1].casefold()
|
|
for n in archive.namelist()]
|
|
except (OSError, zipfile.BadZipFile):
|
|
self._members[path] = []
|
|
return self._members[path]
|
|
|
|
def _members_of_size(self, size: int) -> list[tuple[Path, str]]:
|
|
"""(archive, member) of every ZIP member of that size, the
|
|
archives' directories read once, the first time one is asked."""
|
|
if self._member_sizes is None:
|
|
self._member_sizes = {}
|
|
for path in self.archives:
|
|
if (not path.name.casefold().endswith(".zip")
|
|
or self.relative(path).casefold() in self.sealed):
|
|
continue
|
|
try:
|
|
infos = self.infolist(path)
|
|
except OSError as exc:
|
|
# A ZIP that will not open may hold anything: named. A
|
|
# damaged one is a download cut short, not a copy.
|
|
self._unread(path, "", None, exc)
|
|
continue
|
|
except zipfile.BadZipFile:
|
|
continue
|
|
for info in infos:
|
|
if not info.is_dir():
|
|
self._member_sizes.setdefault(info.file_size, []).append(
|
|
(path, info.filename))
|
|
self._member_size[(path, info.filename)] = info.file_size
|
|
return self._member_sizes.get(size, [])
|
|
|
|
def find(self, entry: dict, skip: Collection[Path] = ()) -> LocalCopy | None:
|
|
"""A copy of the entry's own contents: its size first, then its hash,
|
|
loose files before ZIP members, a file of the same name first."""
|
|
size = entry.get("size")
|
|
if not isinstance(size, int) or isinstance(size, bool):
|
|
return None
|
|
algorithm = "sha1" if entry.get("sha1") else "sha256"
|
|
wanted = str(entry.get(algorithm) or "").lower()
|
|
if not wanted:
|
|
return None
|
|
name = str(entry.get("dest", "")).rsplit("/", 1)[-1].casefold()
|
|
if self._loose_named is None:
|
|
self._loose_named = {}
|
|
for path, held in self.size.items():
|
|
self._loose_named.setdefault((held, path.name.casefold()), []).append(path)
|
|
for path in self._loose_named.get((size, name), []):
|
|
if not _being_written(path, skip) and self.digest(path, algorithm) == wanted:
|
|
return LocalCopy(self, path)
|
|
path = self._scan("loose", size, algorithm).first({wanted}, skip)
|
|
if path is not None:
|
|
return LocalCopy(self, path)
|
|
if size not in self._member_named:
|
|
named = self._member_named[size] = {}
|
|
for item in self._members_of_size(size):
|
|
named.setdefault(item[1].rsplit("/", 1)[-1].casefold(), []).append(item)
|
|
for archive, member in self._member_named[size].get(name, []):
|
|
if (not _being_written(archive, skip)
|
|
and self.digest(archive, algorithm, member) == wanted):
|
|
return LocalCopy(self, archive, member)
|
|
held = self._scan("member", size, algorithm).first({wanted}, skip)
|
|
if held is not None:
|
|
return LocalCopy(self, held[0], held[1])
|
|
return None
|
|
|
|
def count_copy(self, misplaced: bool = False) -> None:
|
|
with self._lock:
|
|
if misplaced:
|
|
self.fixed += 1
|
|
else:
|
|
self.copied += 1
|
|
|
|
def relative(self, path: Path) -> str:
|
|
try:
|
|
rel = path.relative_to(self.root).as_posix()
|
|
except ValueError:
|
|
return str(path)
|
|
return rel if rel != "." else path.name
|
|
|
|
|
|
def _being_written(path: Path, skip: Collection[Path]) -> bool:
|
|
"""Whether a file, or the archive holding a member, is one this run
|
|
writes: its bytes are being replaced, so they are no source."""
|
|
return bool(skip) and (path in skip or Path(os.path.realpath(path)) in skip)
|
|
|
|
|
|
class _HashScan:
|
|
"""Candidates of one size, hashed in order and each once, so finding
|
|
the first that holds a digest never reads the earlier ones again: 3,000
|
|
same-sized files and 3,000 entries of that size cost 3,000 reads, not
|
|
nine million comparisons."""
|
|
|
|
def __init__(self, items: list, digest: Callable[[Any], str]) -> None:
|
|
self.items = items
|
|
self.digest = digest
|
|
self.read = 0
|
|
self.at: dict[str, list[int]] = {}
|
|
|
|
def first(self, wanted: Collection[str], skip: Collection[Path] = ()) -> Any:
|
|
"""The first candidate, in the folder's order, whose digest is one of
|
|
wanted and that skip does not name, or None."""
|
|
best = None
|
|
for value in wanted:
|
|
for position in self.at.get(value, ()):
|
|
if not _being_written(self._holder(position), skip):
|
|
best = position if best is None else min(best, position)
|
|
break
|
|
if best is not None:
|
|
return self.items[best]
|
|
while self.read < len(self.items):
|
|
position = self.read
|
|
self.read += 1
|
|
value = self.digest(self.items[position])
|
|
self.at.setdefault(value, []).append(position)
|
|
if value in wanted and not _being_written(self._holder(position), skip):
|
|
return self.items[position]
|
|
return None
|
|
|
|
def _holder(self, position: int) -> Path:
|
|
item = self.items[position]
|
|
return item[0] if isinstance(item, tuple) else item
|
|
|
|
|
|
class _ZippedLookup:
|
|
"""The archives of a folder not listed as files to install, by the
|
|
folder holding them: each one's names read once, so telling that a
|
|
missing file is still zipped costs two lookups and never a walk."""
|
|
|
|
def __init__(self, index: FolderIndex, known: set[str]) -> None:
|
|
self.index = index
|
|
self._by_parent: dict[Path, list[tuple[int, Path]]] = {}
|
|
for position, path in enumerate(index.archives):
|
|
if index.relative(path).casefold() not in known:
|
|
self._by_parent.setdefault(path.parent, []).append((position, path))
|
|
# folder -> (member name -> first archive, stem -> first archive).
|
|
self._maps: dict[Path, tuple[dict[str, tuple[int, Path]],
|
|
dict[str, tuple[int, Path]]]] = {}
|
|
|
|
def _folder(self, folder: Path) -> tuple[dict[str, tuple[int, Path]],
|
|
dict[str, tuple[int, Path]]]:
|
|
if folder not in self._maps:
|
|
members: dict[str, tuple[int, Path]] = {}
|
|
stems: dict[str, tuple[int, Path]] = {}
|
|
for position, path in self._by_parent.get(folder, []):
|
|
lowered = path.name.casefold()
|
|
if lowered.endswith(".zip"):
|
|
for member in self.index.zip_members(path):
|
|
members.setdefault(member, (position, path))
|
|
stems.setdefault(lowered.rsplit(".", 1)[0], (position, path))
|
|
self._maps[folder] = (members, stems)
|
|
return self._maps[folder]
|
|
|
|
def reason(self, name: str, folders: Collection[Path]) -> str:
|
|
"""Why name is missing when an archive in folders holds it or carries
|
|
its name: the first such archive in the order the folder was read,
|
|
and one holding it before one carrying its name."""
|
|
lowered = name.casefold()
|
|
stem = lowered.rsplit(".", 1)[0]
|
|
best: tuple[int, int, Path] | None = None
|
|
for folder in folders:
|
|
members, stems = self._folder(folder)
|
|
for kind, held in ((0, members.get(lowered)), (1, stems.get(stem))):
|
|
if held is not None and (best is None or (held[0], kind) < best[:2]):
|
|
best = (held[0], kind, held[1])
|
|
if best is None:
|
|
return ""
|
|
where = self.index.relative(best[2])
|
|
if best[1] == 0:
|
|
return f"still zipped: it is inside {where}"
|
|
return f"still zipped: {where} needs extracting"
|
|
|
|
|
|
def _inside(path: Path, root: Path) -> bool:
|
|
"""Whether a link at path leads to a place under root."""
|
|
try:
|
|
Path(os.path.realpath(path)).relative_to(root)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _copies(entry: dict) -> list[dict]:
|
|
"""Every copy the list knows for an entry: its own, other cores', the
|
|
platform's declared one."""
|
|
return [entry, *entry.get("alternatives", []), *entry.get("slot_choices", []),
|
|
*entry.get("declared_choices", [])]
|
|
|
|
|
|
def readers(entry: dict, manifest: dict) -> list[str]:
|
|
"""The cores that open an entry: as its copies name them, else every core
|
|
the platform runs for its systems."""
|
|
named = sorted({core for copy in _copies(entry) for core in copy.get("cores") or []})
|
|
if named:
|
|
return named
|
|
system_cores = manifest.get("system_cores") or {}
|
|
return sorted({core for system in entry_systems(entry)
|
|
for core in system_cores.get(system, [])})
|
|
|
|
|
|
def opened_line(entry: dict, manifest: dict, bios_path: Path) -> str:
|
|
"""Where the emulators read an entry, and which ones."""
|
|
try:
|
|
where = _entry_path(bios_path, entry)
|
|
except ValueError:
|
|
where = bios_path / str(entry.get("dest"))
|
|
cores = readers(entry, manifest)
|
|
if not cores:
|
|
return f"opened at {where}"
|
|
shown = ", ".join(cores[:SHOWN_CORES])
|
|
if len(cores) > SHOWN_CORES:
|
|
shown += f" and {len(cores) - SHOWN_CORES} more"
|
|
return f"opened at {where} by {shown}"
|
|
|
|
|
|
def _known_destinations(files: list[dict]) -> set[str]:
|
|
return {str(f.get("dest", "")).casefold() for f in files}
|
|
|
|
|
|
def _content_owners(files: list[dict]) -> dict[str, list[dict]]:
|
|
"""SHA-1 -> the entries one of whose copies holds it, in the list's order."""
|
|
owners: dict[str, list[dict]] = {}
|
|
for other in files:
|
|
for sha1 in dict.fromkeys(c.get("sha1") for c in _copies(other) if c.get("sha1")):
|
|
owners.setdefault(sha1, []).append(other)
|
|
return owners
|
|
|
|
|
|
def _content_owner(
|
|
sha1: str, entry: dict, files: list[dict],
|
|
owners: dict[str, list[dict]] | None = None,
|
|
) -> str:
|
|
"""Why a file holding sha1 is not the one entry wants, if the list knows."""
|
|
for copy in entry.get("alternatives", []) + entry.get("slot_choices", []):
|
|
if copy.get("sha1") == sha1 and copy.get("cores"):
|
|
return (f"another revision: the copy {', '.join(copy['cores'])} "
|
|
"reads, not the one this list installs here")
|
|
for copy in entry.get("declared_choices", []):
|
|
if copy.get("sha1") == sha1:
|
|
return ("another revision: the one the platform's own list declares, "
|
|
"not the one the cores read")
|
|
if owners is None:
|
|
owners = _content_owners(files)
|
|
for other in owners.get(sha1, []):
|
|
if other is entry or other.get("dest") == entry.get("dest"):
|
|
continue
|
|
regions = [r for r in other.get("regions") or [] if r != WORLD_REGION]
|
|
if regions:
|
|
return (f"another region: the contents of {other['dest']} "
|
|
f"({', '.join(regions)})")
|
|
return f"another file: the contents of {other['dest']}"
|
|
return ""
|
|
|
|
|
|
def _place_reason(
|
|
entry: dict, path: Path, index: FolderIndex, expected: Path, known: set[str],
|
|
contents: str = "",
|
|
) -> tuple[str, str]:
|
|
"""(reason, folder too deep) for an entry's file found at path in the
|
|
BIOS folder, or ("", "") when that place says nothing. contents is
|
|
", with other contents" for a file of the right name and wrong bytes."""
|
|
dest = str(entry.get("dest", ""))
|
|
name = dest.rsplit("/", 1)[-1]
|
|
rel = index.relative(path)
|
|
# A dest below the save directory is not below this folder: a copy
|
|
# here under the same tail is in the wrong place, not too deep.
|
|
below = entry.get("root") != "save"
|
|
if below and rel.endswith("/" + dest) and path.name == name:
|
|
extra = rel[: -len(dest) - 1]
|
|
top = index.root.name
|
|
if extra.casefold() == top.casefold():
|
|
return (f"nested folder: it is at {top}/{rel}{contents}, a "
|
|
f"{top} folder inside {top}"), extra
|
|
depth = extra.count("/") + 1
|
|
levels = "one folder" if depth == 1 else f"{depth} folders"
|
|
return f"{levels} too deep: it is at {rel}{contents}", extra
|
|
if path.name.casefold() == name.casefold():
|
|
if path.parent == expected.parent and path.name != name:
|
|
return (f"case of the name: the folder holds {path.name}{contents}, "
|
|
f"the cores open {name}"), ""
|
|
return f"wrong folder: it is at {rel}{contents}", ""
|
|
if contents:
|
|
return "", ""
|
|
if rel.casefold() in known:
|
|
return f"its contents are at {rel}: copy that file to {dest}", ""
|
|
return f"a name no core opens: its contents are at {rel}, rename it {name}", ""
|
|
|
|
|
|
def _copy_reason(entry: dict, copy: LocalCopy, bios_path: Path, known: set[str]) -> str:
|
|
"""Why a copy found in the BIOS folder is not at the entry's place."""
|
|
if not copy.misplaced:
|
|
# np2kai/2608_bd.wav and np2/2608_bd.wav hold the same bytes. Told
|
|
# "wrong folder", a user moved np2's copy and broke that core.
|
|
return f"the same file is at {copy.where()}, where the list installs it too"
|
|
if copy.member:
|
|
return f"still zipped: it is inside {copy.index.relative(copy.path)}"
|
|
try:
|
|
copy.path.relative_to(copy.index.root)
|
|
except ValueError:
|
|
return f"one folder too high: it is at {copy.path}"
|
|
try:
|
|
expected = _entry_path(bios_path, entry)
|
|
except ValueError:
|
|
return ""
|
|
return _place_reason(entry, copy.path, copy.index, expected, known)[0]
|
|
|
|
|
|
def _found_elsewhere(
|
|
entry: dict, index: FolderIndex, bios_path: Path, files: list[dict],
|
|
known: set[str], zipped: _ZippedLookup | None = None,
|
|
) -> tuple[str, str]:
|
|
"""(reason, folder too deep) for an entry missing at its place."""
|
|
dest = str(entry.get("dest", ""))
|
|
if entry.get("case_variant"):
|
|
# A pack is read on Windows and macOS too, where two names that
|
|
# differ only by case are one file: it carries one of the pair. Moving
|
|
# the other copy here would take it from the core that opens it.
|
|
return ("a second name the pack leaves out: Windows and macOS see it "
|
|
"as the same file. Run the command without --check to add it"), ""
|
|
name = dest.rsplit("/", 1)[-1]
|
|
sizes = {copy.get("size") for copy in _copies(entry) if isinstance(copy.get("size"), int)}
|
|
sha1s = {copy.get("sha1") for copy in _copies(entry) if copy.get("sha1")}
|
|
try:
|
|
expected = _entry_path(bios_path, entry)
|
|
except ValueError:
|
|
return "", ""
|
|
same_name = [p for p in index.by_name.get(name.casefold(), []) if p != expected]
|
|
matching = [p for p in same_name if index.size[p] in sizes and index.sha1(p) in sha1s]
|
|
held = set(matching)
|
|
for path in matching or same_name:
|
|
contents = "" if path in held else ", with other contents"
|
|
found = _place_reason(entry, path, index, expected, known, contents)
|
|
if found[0]:
|
|
return found
|
|
above = index.root.parent / dest
|
|
if index.root.parent != index.root and above.is_file():
|
|
return f"one folder too high: it is at {above}", ""
|
|
holder = index.first_holding(sizes, sha1s)
|
|
if holder is not None:
|
|
return _place_reason(entry, holder, index, expected, known)
|
|
if name.casefold().endswith(_ARCHIVE_SUFFIXES):
|
|
return "", ""
|
|
if zipped is None:
|
|
zipped = _ZippedLookup(index, known)
|
|
reason = zipped.reason(name, {expected.parent, index.root})
|
|
if reason:
|
|
return reason, ""
|
|
for path in index.apple_double.get(name.casefold(), []):
|
|
if path.parent == expected.parent:
|
|
return (f"only a Mac's ._ file: {path.name} holds Finder data, not the "
|
|
f"file itself. Copy {name} again"), ""
|
|
return "", ""
|
|
|
|
|
|
def _wrong_contents(
|
|
entry: dict, path: Path, index: FolderIndex, files: list[dict],
|
|
owners: dict[str, list[dict]] | None = None,
|
|
) -> str:
|
|
"""Why the file at an entry's place is not the one the list installs."""
|
|
try:
|
|
if path.is_dir():
|
|
return FOLDER_IN_PLACE
|
|
size = path.stat().st_size
|
|
with open(path, "rb") as handle:
|
|
head = handle.read(4)
|
|
except OSError as exc:
|
|
return f"could not be read ({exc.strerror or exc}): its contents are unknown"
|
|
sha1 = index.sha1(path)
|
|
owner = _content_owner(sha1, entry, files, owners)
|
|
if owner:
|
|
return owner
|
|
if head == b"PK\x03\x04" and not str(entry.get("dest", "")).casefold().endswith(".zip"):
|
|
return "still zipped: this file is a ZIP archive, extract what it holds"
|
|
expected = {copy.get("size") for copy in _copies(entry) if isinstance(copy.get("size"), int)}
|
|
if size not in expected:
|
|
want = " or ".join(f"{s:,}" for s in sorted(expected))
|
|
return f"wrong size: {size:,} bytes, the list expects {want}"
|
|
return "same size, other contents: another dump, or a damaged copy"
|
|
|
|
|
|
class Diagnosis(NamedTuple):
|
|
reasons: dict[int, str]
|
|
deep: dict[str, int]
|
|
|
|
|
|
def diagnose(
|
|
missing: list[dict], mismatched: list[dict], files: list[dict], bios_path: Path,
|
|
verified: list[dict] | None = None, index: FolderIndex | None = None,
|
|
) -> Diagnosis:
|
|
"""The reason --check gives for each file it flags, from what the folder
|
|
holds and what the list records: id(entry) -> reason, and the folders
|
|
holding files too deep with how many. The files the check verified are
|
|
not read again: their SHA-1 is the one the list records."""
|
|
reasons: dict[int, str] = {}
|
|
deep: dict[str, int] = {}
|
|
if not bios_path.is_dir() or not (missing or mismatched):
|
|
return Diagnosis(reasons, deep)
|
|
if index is None:
|
|
index = bios_folder_index(bios_path, files, verified or [])
|
|
known = _known_destinations(files)
|
|
zipped = _ZippedLookup(index, known)
|
|
for entry in missing:
|
|
reason, extra = _found_elsewhere(entry, index, bios_path, files, known, zipped)
|
|
if reason:
|
|
reasons[id(entry)] = reason
|
|
if extra:
|
|
deep[extra] = deep.get(extra, 0) + 1
|
|
owners = _content_owners(files) if mismatched else {}
|
|
for entry in mismatched:
|
|
try:
|
|
path = _entry_path(bios_path, entry)
|
|
except ValueError:
|
|
continue
|
|
reason = _wrong_contents(entry, path, index, files, owners)
|
|
if reason:
|
|
reasons[id(entry)] = reason
|
|
return Diagnosis(reasons, deep)
|
|
|
|
|
|
def check_lines(
|
|
missing: list[dict], mismatched: list[dict], files: list[dict], manifest: dict,
|
|
bios_path: Path, every: bool, verified: list[dict] | None = None,
|
|
index: FolderIndex | None = None, copies: dict[int, LocalCopy] | None = None,
|
|
) -> list[str]:
|
|
"""The files --check flags, each with why and where its cores read it.
|
|
|
|
"missing: scph5501.bin" left a user with the file one folder too deep,
|
|
or still in its ZIP, to guess. A flagged file with a reason is always
|
|
listed, and so is one a folder named with --from holds. Plain missing
|
|
ones stop at ten unless every is set.
|
|
"""
|
|
found = diagnose(missing, mismatched, files, bios_path, verified, index)
|
|
copies = copies or {}
|
|
known = _known_destinations(files)
|
|
lines = []
|
|
for extra, count in sorted(found.deep.items(), key=lambda item: -item[1]):
|
|
if count >= 3:
|
|
lines.append(f" {_plural(count, 'file')} sit too deep, in "
|
|
f"{bios_path / extra}: move what it holds up into {bios_path}.")
|
|
lines += _check_copy_lines(missing + mismatched, copies, index)
|
|
explained = [e for e in missing if id(e) in found.reasons or id(e) in copies]
|
|
plain = [e for e in missing if id(e) not in found.reasons and id(e) not in copies]
|
|
shown = plain if every else plain[:10]
|
|
|
|
def block(label: str, entry: dict) -> list[str]:
|
|
game = " (game data)" if entry.get("category") == GAME_DATA else ""
|
|
rows = [f" {label}: {entry['dest']}{game}"]
|
|
if id(entry) in found.reasons:
|
|
rows.append(f" {found.reasons[id(entry)]}")
|
|
copy = copies.get(id(entry))
|
|
if copy is not None and copy.index is index and not copy.misplaced:
|
|
rows.append(f" a run without --check copies it from {copy.where()}")
|
|
elif copy is not None and copy.index is index:
|
|
where = _copy_reason(entry, copy, bios_path, known)
|
|
if where and where != found.reasons.get(id(entry)):
|
|
rows.append(f" the right file: {where}")
|
|
rows.append(" a run without --check copies it into place")
|
|
elif copy is not None:
|
|
rows.append(f" in {copy.index.label}: {copy.where()}")
|
|
rows.append(f" {opened_line(entry, manifest, bios_path)}")
|
|
return rows
|
|
|
|
for entry in mismatched:
|
|
lines += block("wrong hash", entry)
|
|
for entry in explained:
|
|
lines += block("missing", entry)
|
|
for entry in shown:
|
|
lines += block("missing", entry)
|
|
if len(shown) < len(plain):
|
|
lines.append(f" ... and {len(plain) - len(shown)} more missing "
|
|
"(--verbose lists them all)")
|
|
return shown_lines(lines)
|
|
|
|
|
|
def bios_folder_index(
|
|
bios_path: Path, files: list[dict], verified: list[dict],
|
|
) -> FolderIndex | None:
|
|
"""The BIOS folder read for files misplaced in it. The files the check
|
|
verified are not read again: their SHA-1 is the one the list records.
|
|
An archive the list installs is not unpacked for its members: a MAME
|
|
set holding a ROM a core also reads loose is that core's to open."""
|
|
if not bios_path.is_dir():
|
|
return None
|
|
index = FolderIndex(bios_path)
|
|
for entry in verified:
|
|
if entry.get("sha1"):
|
|
try:
|
|
index.seed(_entry_path(bios_path, entry), entry["sha1"].lower())
|
|
except ValueError:
|
|
continue
|
|
index.sealed = _known_destinations(files)
|
|
index.listed = {str(f.get("dest", "")) for f in files}
|
|
return index
|
|
|
|
|
|
def _above(entry: dict, index: FolderIndex) -> LocalCopy | None:
|
|
"""The entry's file at its place one folder above the BIOS folder."""
|
|
if entry.get("root") == "save" or index.root.parent == index.root:
|
|
return None
|
|
try:
|
|
above = index.root.parent / _safe_relative_path(entry.get("dest"), "dest")
|
|
if above.stat().st_size != entry.get("size") or not above.is_file():
|
|
return None
|
|
except (OSError, ValueError):
|
|
return None
|
|
algorithm = "sha1" if entry.get("sha1") else "sha256"
|
|
if index.digest(above, algorithm) != str(entry.get(algorithm, "")).lower():
|
|
return None
|
|
return LocalCopy(index, above)
|
|
|
|
|
|
def plan_local_copies(
|
|
entries: list[dict], bios_path: Path, bios_index: FolderIndex | None,
|
|
folders: list[FolderIndex],
|
|
) -> dict[int, LocalCopy]:
|
|
"""id(entry) -> where this machine already holds the entry's bytes: the
|
|
BIOS folder itself first, a file misplaced in it, then each folder named
|
|
with --from in turn. Every copy is found by size, then by hash."""
|
|
if not entries or (bios_index is None and not folders):
|
|
return {}
|
|
# A file this run replaces is no source: its place is being written.
|
|
written = set()
|
|
for entry in entries:
|
|
try:
|
|
written.add(Path(os.path.realpath(_entry_path(bios_path, entry))))
|
|
except ValueError:
|
|
continue
|
|
for index in ([bios_index] if bios_index is not None else []) + folders:
|
|
index.writes(written)
|
|
copies: dict[int, LocalCopy] = {}
|
|
for entry in entries:
|
|
copy = None
|
|
if bios_index is not None:
|
|
copy = bios_index.find(entry, written) or _above(entry, bios_index)
|
|
if copy is not None:
|
|
copy = copy._replace(misplaced=_misplaced(entry, copy, bios_index))
|
|
for index in folders:
|
|
if copy is not None:
|
|
break
|
|
copy = index.find(entry, written)
|
|
if copy is not None:
|
|
copies[id(entry)] = copy
|
|
return copies
|
|
|
|
|
|
def _misplaced(entry: dict, copy: LocalCopy, index: FolderIndex) -> bool:
|
|
"""Whether a copy in the BIOS folder sits where no other entry of the
|
|
list goes: GBA_BIOS.BIN for gba_bios.bin is a mistake, np2/2608_bd.wav
|
|
for np2kai/2608_bd.wav is the other core's file."""
|
|
if copy.member:
|
|
return True
|
|
rel = index.relative(copy.path)
|
|
if rel in index.listed:
|
|
return False
|
|
own = str(entry.get("dest", "")).casefold()
|
|
return rel.casefold() not in index.sealed or rel.casefold() == own
|
|
|
|
|
|
def _check_copy_lines(
|
|
entries: list[dict], copies: dict[int, LocalCopy], bios_index: FolderIndex | None,
|
|
) -> list[str]:
|
|
"""What --check says a run would copy instead of downloading."""
|
|
fixed = sum(1 for e in entries if id(e) in copies and copies[id(e)].misplaced)
|
|
lines = []
|
|
if fixed:
|
|
verb = "is" if fixed == 1 else "are"
|
|
them = "it" if fixed == 1 else "them"
|
|
lines.append(f" {fixed} of these files {verb} in the BIOS folder at the wrong place: "
|
|
f"a run without --check copies {them} where the cores open {them}, and "
|
|
f"leaves your {'copy' if fixed == 1 else 'copies'} as "
|
|
f"{'it is' if fixed == 1 else 'they are'}.")
|
|
for label, count in _counts_by_folder(entries, copies, bios_index):
|
|
them = "it" if count == 1 else "them"
|
|
lines.append(f" {label} holds {count} of these files: a run without --check "
|
|
f"copies {them}.")
|
|
return shown_lines(lines)
|
|
|
|
|
|
def _counts_by_folder(
|
|
entries: list[dict], copies: dict[int, LocalCopy], bios_index: FolderIndex | None,
|
|
) -> list[tuple[str, int]]:
|
|
"""(folder, files) for each --from folder that gives some of entries."""
|
|
counts: dict[str, int] = {}
|
|
for entry in entries:
|
|
copy = copies.get(id(entry))
|
|
if copy is not None and copy.index is not bios_index:
|
|
counts[copy.index.label] = counts.get(copy.index.label, 0) + 1
|
|
return list(counts.items())
|
|
|
|
|
|
def copy_plan_lines(
|
|
entries: list[dict], copies: dict[int, LocalCopy], bios_index: FolderIndex | None,
|
|
bios_path: Path, files: list[dict], every: bool, planned: bool,
|
|
) -> list[str]:
|
|
"""The files a run copies from this machine, by folder: the misplaced
|
|
ones with the reason each was not where its cores open it."""
|
|
known = _known_destinations(files)
|
|
# By --from folder, "" for the misplaced files and "=" for the ones the
|
|
# BIOS folder holds at another entry's place.
|
|
groups: dict[str, list[dict]] = {}
|
|
for entry in entries:
|
|
copy = copies.get(id(entry))
|
|
if copy is not None:
|
|
key = (copy.index.label if copy.index is not bios_index
|
|
else "" if copy.misplaced else "=")
|
|
groups.setdefault(key, []).append(entry)
|
|
lines = []
|
|
for key in sorted(groups, key=lambda k: (k != "", k != "=")):
|
|
chosen = groups[key]
|
|
sized = f"{_plural(len(chosen), 'file')} ({format_size(_bytes(chosen))})"
|
|
if key == "":
|
|
head = (f"Would fix {sized} misplaced in the BIOS folder, copied where the "
|
|
"cores open them:" if planned else
|
|
f"Fixing {sized} misplaced in the BIOS folder: copied where the cores "
|
|
"open them, your copies left as they are.")
|
|
elif key == "=":
|
|
head = (f"Would copy {sized} the BIOS folder already holds at another "
|
|
"place of the list:" if planned else
|
|
f"Copying {sized} the BIOS folder already holds at another place "
|
|
"of the list.")
|
|
else:
|
|
head = (f"Would copy {sized} from {key}:" if planned else
|
|
f"Copying {sized} from {key}, each one checked by hash.")
|
|
lines.append(f"\n {head}" if planned else f" {head}")
|
|
for entry in chosen if every else chosen[:10]:
|
|
copy = copies[id(entry)]
|
|
why = (_copy_reason(entry, copy, bios_path, known) if key == ""
|
|
else f"from {copy.where()}" if key == "=" else copy.where())
|
|
lines.append(f" {entry['dest']}: {why}" if why else f" {entry['dest']}")
|
|
if len(chosen) > 10 and not every:
|
|
lines.append(f" ... and {len(chosen) - 10} more (--verbose lists them all)")
|
|
return shown_lines(lines)
|
|
|
|
|
|
def emit_copies(
|
|
plat: str, entries: list[dict], copies: dict[int, LocalCopy],
|
|
bios_index: FolderIndex | None, bios_path: Path, files: list[dict],
|
|
) -> None:
|
|
"""One found event per file this machine holds, before it is copied."""
|
|
known = _known_destinations(files)
|
|
for entry in entries:
|
|
copy = copies.get(id(entry))
|
|
if copy is None:
|
|
continue
|
|
reason = (_copy_reason(entry, copy, bios_path, known)
|
|
if copy.index is bios_index else "")
|
|
EVENTS.emit("found", platform=plat, dest=entry["dest"], folder=copy.index.label,
|
|
path=str(copy.path), member=copy.member, reason=reason)
|
|
|
|
|
|
class Unreadable(NamedTuple):
|
|
"""A file, ZIP member or folder below a --from folder that could not be
|
|
read, and may be a file the run still needs."""
|
|
index: FolderIndex
|
|
path: Path
|
|
member: str
|
|
why: str
|
|
|
|
def line(self) -> str:
|
|
where = f"{self.member} inside {self.path}" if self.member else str(self.path)
|
|
return screen_text(f"{where} could not be read: {self.why}")
|
|
|
|
|
|
def unreadable_copies(
|
|
entries: list[dict], copies: dict[int, LocalCopy], folders: list[FolderIndex],
|
|
) -> list[Unreadable]:
|
|
"""What the --from folders hold that could not be read, of the size of
|
|
a file no copy was found for, or of no known size: dropped silently,
|
|
a file a user had read as one to download."""
|
|
sizes = {e["size"] for e in entries
|
|
if id(e) not in copies and isinstance(e.get("size"), int)}
|
|
if not sizes:
|
|
return []
|
|
return [Unreadable(index, *held) for index in folders
|
|
for held in index.unreadable_for(sizes)]
|
|
|
|
|
|
def unreadable_lines(found: list[Unreadable], every: bool) -> list[str]:
|
|
lines = [f" {item.line()}" for item in (found if every else found[:10])]
|
|
if len(found) > len(lines):
|
|
lines.append(f" ... and {len(found) - len(lines)} more could not be read "
|
|
"(--verbose lists them all)")
|
|
return shown_lines(lines)
|
|
|
|
|
|
def emit_unreadable(plat: str, found: list[Unreadable]) -> None:
|
|
for item in found:
|
|
EVENTS.emit("unreadable", platform=plat, folder=item.index.label,
|
|
path=str(item.path), member=item.member, reason=item.why)
|
|
|
|
|
|
def sources_line(
|
|
folders: list[tuple[str, int]], fixed: int, downloaded: int, missing: int,
|
|
fetched: str = "downloaded", shared: int = 0,
|
|
) -> str:
|
|
"""Where the files of a run that copied some came from, in words:
|
|
"38 files copied from E:\\, 4 downloaded, 2 still missing." """
|
|
parts = [(count, f"copied from {screen_text(label)}") for label, count in folders]
|
|
parts.append((fixed, "fixed from copies already in the BIOS folder"))
|
|
parts.append((shared, "copied from another place of the BIOS folder"))
|
|
if not any(count for count, _what in parts):
|
|
return ""
|
|
parts += [(downloaded, fetched), (missing, "still missing")]
|
|
words = []
|
|
for count, what in parts:
|
|
if count:
|
|
words.append(f"{_plural(count, 'file') if not words else count} {what}")
|
|
return ", ".join(words) + "."
|
|
|
|
|
|
# Below this a download is small enough that narrowing it is not worth a line.
|
|
BULK_HINT_FROM = 100 * 1024 * 1024
|
|
|
|
|
|
def _bytes(files: list[dict]) -> int:
|
|
return sum(f.get("size", 0) for f in files)
|
|
|
|
|
|
def bulk_hint(
|
|
to_download: list[dict], manifest: dict, systems_chosen: bool, cores_chosen: bool,
|
|
accepted: Collection[str] = (),
|
|
) -> list[str]:
|
|
"""Name what pulls most of a download, when narrowing saves over half of it.
|
|
|
|
`--system 3do` fetched 349 MB for RetroArch, and `--core opera` 12.5 MB:
|
|
the arcade cores that also read 3DO pulled the rest, and nothing said so.
|
|
accepted holds the names --core takes. Drawn from every core of the
|
|
system, the advice named ares, which loads no file of its own, and
|
|
`--core ares` was then refused.
|
|
"""
|
|
total = _bytes(to_download)
|
|
if total < BULK_HINT_FROM:
|
|
return []
|
|
systems = {s for entry in to_download for s in entry_systems(entry)}
|
|
if not cores_chosen and len(systems) == 1:
|
|
system = next(iter(systems))
|
|
candidates = [core for core in (manifest.get("system_cores") or {}).get(system) or []
|
|
if core in accepted]
|
|
by_core: dict[str, int] = {}
|
|
for entry in to_download:
|
|
for core in entry.get("cores") or []:
|
|
by_core[core] = by_core.get(core, 0) + entry.get("size", 0)
|
|
kept = {core: _bytes(_filter_by_target(to_download, [core])) for core in candidates}
|
|
if by_core and kept:
|
|
heavy = max(sorted(by_core), key=lambda core: by_core[core])
|
|
light = min(sorted(kept), key=lambda core: kept[core])
|
|
saved = total - kept[light]
|
|
if saved * 2 > total:
|
|
return [
|
|
f" {heavy} pulls {format_size(by_core[heavy])} of this "
|
|
f"{format_size(total)} download for {system}. --core {light} "
|
|
f"keeps {format_size(kept[light])} of it and saves "
|
|
f"{format_size(saved)} ({saved * 100 // total}%)."
|
|
]
|
|
if not systems_chosen and len(systems) > 1:
|
|
alone: dict[str, int] = {}
|
|
for entry in to_download:
|
|
own = entry_systems(entry)
|
|
if len(own) == 1:
|
|
alone[own[0]] = alone.get(own[0], 0) + entry.get("size", 0)
|
|
if alone:
|
|
heavy = max(sorted(alone), key=lambda system: alone[system])
|
|
if alone[heavy] * 2 > total:
|
|
return [
|
|
f" {heavy} pulls {format_size(alone[heavy])} of this "
|
|
f"{format_size(total)} download. --system naming the systems "
|
|
f"you play leaves it out and saves {format_size(alone[heavy])} "
|
|
f"({alone[heavy] * 100 // total}%), --list-systems names them."
|
|
]
|
|
return []
|
|
|
|
|
|
def _help_epilog() -> str:
|
|
"""Examples in the form the run was started with, and where the rest is."""
|
|
run = _INVOCATION.launcher()
|
|
card = "E:\\bios" if _INVOCATION.powershell() else "/media/SHARE/bios"
|
|
examples = (
|
|
("install for the platforms found here", run),
|
|
("prepare a card or a share for another device",
|
|
f"{run} --platform batocera --dest {card}"),
|
|
("only some systems (names: --list-systems)",
|
|
f"{run} --platform retroarch --system sony-playstation,nintendo-gba"),
|
|
("check what is installed, writing nothing", f"{run} --check"),
|
|
)
|
|
lines = ["examples:"]
|
|
for what, command in examples:
|
|
lines += [f" {what}", f" {command}"]
|
|
lines += ["", f"Every option, the environment variables and the exit codes: {INSTALLER_PAGE}"]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point."""
|
|
_INVOCATION.os_type = detect_os()
|
|
parser = argparse.ArgumentParser(
|
|
allow_abbrev=False,
|
|
description="Download missing BIOS files for retrogaming emulators. "
|
|
"For questions line by line, as a screen reader reads them, "
|
|
"add --plain.",
|
|
epilog=_help_epilog(),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--platform",
|
|
help="target platform (retroarch, batocera, emudeck, ...)",
|
|
)
|
|
parser.add_argument(
|
|
"--dest",
|
|
type=Path,
|
|
help="BIOS folder to install into. Without --platform, the file list is "
|
|
"the platform detected here, asked at a terminal, RetroArch's when "
|
|
"nothing is detected",
|
|
)
|
|
parser.add_argument(
|
|
"--target",
|
|
help="hardware target for core filtering (switch, rpi4, ...); "
|
|
"on Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system",
|
|
)
|
|
parser.add_argument(
|
|
"--every-board",
|
|
action="store_true",
|
|
help="install the files of every board, not only the detected one",
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="check existing files without downloading",
|
|
)
|
|
parser.add_argument(
|
|
"--list-platforms",
|
|
action="store_true",
|
|
help="list detected platforms and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--list-targets",
|
|
action="store_true",
|
|
help="list available targets for a platform and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--jobs", "-j",
|
|
type=int,
|
|
default=8,
|
|
help="parallel download threads (default: 8)",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", "-v",
|
|
action="store_true",
|
|
help="verbose output",
|
|
)
|
|
parser.add_argument(
|
|
"--standalone-copies",
|
|
action="store_true",
|
|
help="opt in to copies into detected standalone-emulator directories",
|
|
)
|
|
parser.add_argument(
|
|
"--emulator",
|
|
action="append",
|
|
metavar="NAME",
|
|
help="install what these emulators read on the platform, as a pack "
|
|
"built for them carries (names: --list-emulators)",
|
|
)
|
|
parser.add_argument(
|
|
"--installed-cores",
|
|
action="store_true",
|
|
help="install only what the cores already installed read "
|
|
"(RetroArch, Lakka, RetroPie)",
|
|
)
|
|
parser.add_argument(
|
|
"--save-dir",
|
|
type=Path,
|
|
metavar="PATH",
|
|
help="the frontend's save directory (RetroArch: savefile_directory), "
|
|
"for the files some cores read there (Azahar's keys and system "
|
|
"data), when it is not found",
|
|
)
|
|
parser.add_argument(
|
|
"--cores-dir",
|
|
type=Path,
|
|
metavar="PATH",
|
|
help="the folder RetroArch loads its cores from, when it is not found "
|
|
"(implies --installed-cores)",
|
|
)
|
|
parser.add_argument(
|
|
"--system",
|
|
action="append",
|
|
metavar="NAME",
|
|
help="install only these systems (repeat or comma-separate, see "
|
|
"--list-systems)",
|
|
)
|
|
parser.add_argument(
|
|
"--core",
|
|
action="append",
|
|
metavar="NAME",
|
|
help="keep only the extra files these cores load (see --list-cores)",
|
|
)
|
|
parser.add_argument(
|
|
"--region",
|
|
action="append",
|
|
metavar="LIST",
|
|
help="preferred regions, best first, e.g. us,eu,jp: keep one BIOS per "
|
|
"region where a system has several",
|
|
)
|
|
parser.add_argument(
|
|
"--no-game-data",
|
|
action="store_true",
|
|
help="leave out game data (files a game reads, not firmware) and "
|
|
"install the firmware only",
|
|
)
|
|
parser.add_argument(
|
|
"--declared-only",
|
|
action="store_true",
|
|
help="keep only the files the platform's own list names, not the extra "
|
|
"files cores load: --check then answers for the list the platform checks",
|
|
)
|
|
parser.add_argument(
|
|
"--list-systems",
|
|
action="store_true",
|
|
help="list a platform's systems with their file counts and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--list-cores",
|
|
action="store_true",
|
|
help="list the cores whose extra files a platform installs and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--romm-firmware",
|
|
metavar="TEMPLATE",
|
|
help="RomM: where each platform's firmware goes in the library, as "
|
|
"filesystem.structure.firmware in its config.yml (default: read "
|
|
"from config.yml, else bios/{platform})",
|
|
)
|
|
parser.add_argument(
|
|
"--list-emulators",
|
|
action="store_true",
|
|
help="list every name --emulator accepts for a platform and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--no-input",
|
|
action="store_true",
|
|
help="never ask a question and fail where an answer is needed",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="show what would be downloaded, and how much, without writing anything",
|
|
)
|
|
parser.add_argument(
|
|
"--yes", "-y",
|
|
action="store_true",
|
|
help="do not ask before a large download",
|
|
)
|
|
parser.add_argument(
|
|
"--quiet", "-q",
|
|
action="store_true",
|
|
help="print only errors and the final summary (implies --no-input)",
|
|
)
|
|
parser.add_argument(
|
|
"--events",
|
|
action="store_true",
|
|
help="write the run as JSON events on stdout, one per line, and the "
|
|
"usual text on stderr (implies --no-input)",
|
|
)
|
|
parser.add_argument(
|
|
"--restore",
|
|
type=Path,
|
|
metavar="FOLDER",
|
|
help=f"put back the files an earlier run replaced and kept in FOLDER "
|
|
f"(a dated folder inside {BACKUP_DIR})",
|
|
)
|
|
parser.add_argument(
|
|
"--from-pack",
|
|
action="append",
|
|
type=Path,
|
|
metavar="PATH",
|
|
help="install from a pack already downloaded: its ZIP, one of its parts "
|
|
"(the others are found beside it) or the folder holding them. "
|
|
"Makes no network request, and checks every file's hash",
|
|
)
|
|
parser.add_argument(
|
|
"--offline",
|
|
action="store_true",
|
|
help="make no network request: --check, --from, --from-pack and --manifest "
|
|
"work without one, and a file the run would download fails at once "
|
|
f"({OFFLINE_VARIABLE}=1 does the same)",
|
|
)
|
|
parser.add_argument(
|
|
"--from",
|
|
action="append",
|
|
type=Path,
|
|
metavar="FOLDER",
|
|
help="copy the BIOS files found in FOLDER (an old SD card, a backup, a "
|
|
"ZIP) instead of downloading them: every file recognised by its "
|
|
"hash, never moved. Repeat for several folders",
|
|
)
|
|
parser.add_argument(
|
|
"--manifest",
|
|
type=Path,
|
|
metavar="FILE",
|
|
help="read the file list from FILE (install/<platform>.json) instead "
|
|
"of downloading it",
|
|
)
|
|
parser.add_argument(
|
|
"--tui",
|
|
action="store_true",
|
|
help="open the full-screen interface, which a keyboard or a controller "
|
|
"drives. It opens by itself for an install at a terminal of 40x12 "
|
|
"or more",
|
|
)
|
|
parser.add_argument(
|
|
"--plain",
|
|
action="store_true",
|
|
help="ask the questions line by line instead of the full-screen "
|
|
"interface, as a screen reader reads them (RETROBIOS_PLAIN=1 or "
|
|
"ACCESSIBLE=1 do the same)",
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
action="version",
|
|
version=f"retrobios installer {installer_id()} (ref {RELEASE_REF})",
|
|
help="print the installer version and exit",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
if args.verbose:
|
|
_RAW_ERRORS.set()
|
|
if args.cores_dir:
|
|
args.installed_cores = True
|
|
_INVOCATION.record(args)
|
|
if os.environ.get(TUI_LIST_ENV):
|
|
# The full screen's own copy of the list, deleted when it closes: a
|
|
# command naming it fails once the screen is gone.
|
|
_INVOCATION.options = [option for option in _INVOCATION.options
|
|
if option[0] != "--manifest"]
|
|
if not 1 <= args.jobs <= 32:
|
|
parser.error("--jobs must be between 1 and 32")
|
|
if args.region:
|
|
try:
|
|
resolve_regions(args.region)
|
|
except ValueError as exc:
|
|
parser.error(str(exc))
|
|
for option, values in (("--system", args.system), ("--core", args.core)):
|
|
if values is not None and not any(
|
|
token.strip() for value in values for token in value.split(",")
|
|
):
|
|
parser.error(f"{option} needs at least one name")
|
|
if args.quiet and (
|
|
args.check or args.dry_run or args.list_platforms or args.list_targets
|
|
or args.list_systems or args.list_cores or args.list_emulators
|
|
):
|
|
parser.error("--quiet applies to an install, not to --check, --dry-run or a listing")
|
|
if args.tui and args.plain:
|
|
parser.error("--tui and --plain ask for two different interfaces")
|
|
if args.declared_only:
|
|
# Applied or refused, never ignored.
|
|
for given, reason in (
|
|
(args.tui, "the full-screen interface has no choice for --declared-only"),
|
|
(args.list_platforms or args.list_targets,
|
|
"--declared-only narrows a file list, not --list-platforms or --list-targets"),
|
|
(args.core or args.list_cores,
|
|
"--declared-only leaves out the files cores load, which --core and "
|
|
"--list-cores choose among"),
|
|
(args.list_emulators,
|
|
"--list-emulators names emulators, and --declared-only narrows files"),
|
|
(args.standalone_copies,
|
|
"--standalone-copies copies into standalone emulators' folders, outside "
|
|
"the platform's own list that --declared-only keeps"),
|
|
):
|
|
if given:
|
|
parser.error(reason)
|
|
if args.tui:
|
|
reason = tui_blocker(args)
|
|
if reason:
|
|
# The reason alone: fourteen lines of usage hid it.
|
|
print(f"{parser.prog}: error: --tui cannot open the full-screen interface "
|
|
f"here: {reason}", file=sys.stderr)
|
|
sys.exit(2)
|
|
if args.restore is not None:
|
|
# The folder says where each file goes: nothing else applies.
|
|
alone = ("platform", "dest", "target", "every_board", "check", "list_platforms",
|
|
"list_targets", "standalone_copies", "emulator", "installed_cores",
|
|
"cores_dir", "save_dir", "system", "core", "region", "no_game_data",
|
|
"declared_only", "list_systems",
|
|
"list_cores", "romm_firmware", "list_emulators", "events", "quiet",
|
|
"yes", "verbose", "from_pack", "manifest", "from")
|
|
given = [name for name in alone if getattr(args, name)]
|
|
if args.jobs != 8:
|
|
given.append("jobs")
|
|
if given:
|
|
parser.error("--restore puts files back from a folder and takes no "
|
|
f"--{given[0].replace('_', '-')}")
|
|
sys.exit(restore_kept(args.restore, args.dry_run))
|
|
if args.every_board and args.target:
|
|
parser.error("--every-board installs every board; --target names one")
|
|
if args.offline:
|
|
# The runs the full-screen interface starts read it from here.
|
|
os.environ[OFFLINE_VARIABLE] = "1"
|
|
if offline_requested() and (args.target or args.list_targets):
|
|
parser.error("--target and --list-targets read a core list from the network, "
|
|
"which --offline does not use")
|
|
install_bounded_opener()
|
|
if args.from_pack and args.target:
|
|
parser.error("--target reads a core list from the network, which --from-pack "
|
|
"does not use")
|
|
if getattr(args, "from") and (
|
|
args.list_platforms or args.list_targets or args.list_systems
|
|
or args.list_cores or args.list_emulators
|
|
):
|
|
parser.error("--from copies files into the BIOS folder, which a listing "
|
|
"does not write")
|
|
if (args.from_pack or args.manifest) and (args.list_platforms or args.list_targets):
|
|
parser.error("--from-pack and --manifest give a file list, which --list-platforms "
|
|
"and --list-targets do not read")
|
|
if args.no_game_data and (args.list_platforms or args.list_targets):
|
|
parser.error("--no-game-data narrows a file list, not --list-platforms "
|
|
"or --list-targets")
|
|
# The screens have no choice for --declared-only: the questions ask the
|
|
# rest, and the run keeps the platform's own list.
|
|
if args.tui or not (plain_requested(args) or args.declared_only or tui_blocker(args)):
|
|
sys.exit(run_tui(args))
|
|
if args.events:
|
|
# The events own stdout. The text a person reads goes to stderr, so a
|
|
# program never has to tell one from the other.
|
|
args.no_input = True
|
|
EVENTS.open(sys.stdout)
|
|
sys.stdout = sys.stderr
|
|
EVENTS.emit("start", schema=EVENT_SCHEMA, installer=installer_id(),
|
|
mode=_event_mode(args))
|
|
try:
|
|
_run(args)
|
|
except SystemExit as exc:
|
|
EVENTS.emit("end", code=_event_exit_code(exc.code))
|
|
raise
|
|
except KeyboardInterrupt:
|
|
EVENTS.emit("end", code=130)
|
|
raise
|
|
EVENTS.emit("end", code=0)
|
|
return
|
|
_run(args)
|
|
|
|
|
|
def _run(args: argparse.Namespace) -> None:
|
|
"""The run, its text quiet or not, the archives it read closed after."""
|
|
try:
|
|
_run_text(args)
|
|
finally:
|
|
LOCAL.close_indexes()
|
|
|
|
|
|
def _run_text(args: argparse.Namespace) -> None:
|
|
if not args.quiet:
|
|
_install(args, sys.stdout)
|
|
return
|
|
# Quiet: what a run prints is kept, and shown on stderr if the run fails,
|
|
# so an error printed on stdout is never lost.
|
|
captured = io.StringIO()
|
|
real_stdout = sys.stdout
|
|
sys.stdout = captured
|
|
try:
|
|
_install(args, real_stdout)
|
|
except SystemExit as exc:
|
|
if sys.stdout is captured:
|
|
sys.stdout = real_stdout
|
|
if exc.code not in (0, None):
|
|
sys.stderr.write(captured.getvalue())
|
|
raise
|
|
finally:
|
|
if sys.stdout is captured:
|
|
sys.stdout = real_stdout
|
|
|
|
|
|
def _open_local_sources(args: argparse.Namespace) -> None:
|
|
"""Take the file list and the pack this run was given, and the platform
|
|
they are for when none is named."""
|
|
LOCAL.reset()
|
|
_open_from_folders(getattr(args, "from", None) or [])
|
|
if args.manifest is not None:
|
|
LOCAL.manifest = args.manifest
|
|
named = local_manifest_platform(args.manifest)
|
|
if args.platform is None and named:
|
|
args.platform = named
|
|
print(f" File list: {args.manifest} ({display_name(named)})")
|
|
if not args.from_pack:
|
|
return
|
|
try:
|
|
LOCAL.pack = PackSource(args.from_pack)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
pack = LOCAL.pack
|
|
parts = len(pack.files)
|
|
print(f" Pack: {pack.name}"
|
|
+ (f", {parts} parts" if parts > 1 or _PART_FILE.match(pack.files[0].name) else ""))
|
|
spelled = pack.platforms()
|
|
if args.platform is not None:
|
|
plat = normalize_platform(args.platform)
|
|
if spelled and plat not in spelled:
|
|
print(f"Error: {pack.name} is a pack for "
|
|
f"{' and '.join(display_name(n) for n in spelled)}, not "
|
|
f"{display_name(plat)}.", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif len(spelled) == 1:
|
|
args.platform = spelled[0]
|
|
else:
|
|
among = (" (" + ", ".join(spelled) + ")") if spelled else ""
|
|
print(f"Error: name the platform {pack.name} is installed for with "
|
|
f"--platform{among}.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _system_folder(folder: Path) -> str:
|
|
"""Why a --from folder is one of the system's own, in words, or ""."""
|
|
try:
|
|
resolved = folder.resolve()
|
|
except OSError:
|
|
return ""
|
|
text = str(resolved)
|
|
if any(text == pseudo or text.startswith(pseudo + "/") for pseudo in PSEUDO_FOLDERS):
|
|
return "is a folder the system makes up, it holds no copy"
|
|
system_drive = os.environ.get("SystemDrive", "C:") if os.name == "nt" else ""
|
|
if resolved == Path(resolved.anchor) and (
|
|
os.name != "nt" or resolved.drive.casefold() == system_drive.casefold()):
|
|
return "is the root of the whole system"
|
|
return ""
|
|
|
|
|
|
def from_folder_problem(label: str) -> tuple[str, str] | None:
|
|
"""Why a folder named with --from cannot be read, without reading what
|
|
it holds: (reason, words), or None when it can.
|
|
|
|
reason is the one the error event carries: not_found, not_a_folder (a
|
|
file that is no ZIP), unreadable, or system (the root of the system,
|
|
/proc, /sys or /dev). words follow the folder as given: "E:\\old does
|
|
not exist". Cheap enough for a screen to call before indexing."""
|
|
folder = Path(os.path.expanduser(label))
|
|
zipped = folder.is_file() and folder.name.casefold().endswith(".zip")
|
|
if not (folder.is_dir() or zipped):
|
|
if folder.exists():
|
|
return "not_a_folder", "is neither a folder nor a ZIP"
|
|
return "not_found", "does not exist"
|
|
try:
|
|
if zipped:
|
|
with open(folder, "rb"):
|
|
pass
|
|
else:
|
|
with os.scandir(folder) as listing:
|
|
next(listing, None)
|
|
except OSError as exc:
|
|
return "unreadable", f"could not be read: {unreadable_words(exc)}"
|
|
system = "" if zipped else _system_folder(folder)
|
|
return ("system", system) if system else None
|
|
|
|
|
|
def _open_from_folders(folders: list[Path]) -> None:
|
|
"""Read each folder named with --from, or stop on one that cannot be."""
|
|
for folder in folders:
|
|
label = str(folder)
|
|
problem = from_folder_problem(label)
|
|
if problem is not None:
|
|
reason, what = problem
|
|
message = f"{label} {what}: --from names the folder to copy BIOS files from"
|
|
EVENTS.emit("error", stage="from", reason=reason, platform="", message=message)
|
|
print(f"Error: {screen_text(message)}.", file=sys.stderr)
|
|
sys.exit(1)
|
|
index = FolderIndex(Path(os.path.expanduser(label)), label)
|
|
LOCAL.folders.append(index)
|
|
print(f" Copying BIOS files from: {screen_text(label)}")
|
|
if index.truncated:
|
|
print(f" Read the first {MAX_SCANNED_FILES:,} files of {screen_text(label)} "
|
|
"only. Name the folder that holds the BIOS files to search it all.")
|
|
for drive in index.other_drives:
|
|
print(f" Did not go into {screen_text(str(drive))}: it is another drive. Name "
|
|
"it with --from to read it too.")
|
|
EVENTS.emit("scanned", folder=label, files=index.files, truncated=index.truncated,
|
|
other_drives=[str(drive) for drive in index.other_drives])
|
|
|
|
|
|
def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> None:
|
|
"""Say that a --dest folder does not exist before anything goes there.
|
|
|
|
A typo (~/.config/retroarh/system) was created with every missing
|
|
parent, filled, and reported done in a folder no platform reads, and
|
|
--check said only "0/7 present". One missing level is a BIOS folder not
|
|
made yet and is created. Several make a path that may be mistyped:
|
|
at a terminal the run asks first, and the answer defaults to no.
|
|
"""
|
|
if dest.exists():
|
|
return
|
|
nearest = dest.absolute()
|
|
while not nearest.exists() and nearest.parent != nearest:
|
|
nearest = nearest.parent
|
|
missing = len(dest.absolute().relative_to(nearest).parts)
|
|
print(f" {dest} does not exist. The nearest folder that does: {nearest}")
|
|
if missing > 1 and ask:
|
|
answer = _ask(f" Create {_plural(missing, 'folder')} under {nearest}? [y/N] ")
|
|
if not answer.strip().lower().startswith("y"):
|
|
print(f" Nothing installed. Check the folder {named}, then run "
|
|
"the command again.")
|
|
sys.exit(2)
|
|
|
|
|
|
def _no_core_found(plat: str, bios_path: Path, searched: list[Path]) -> None:
|
|
"""Refuse --installed-cores with no core, and say how to point at them.
|
|
|
|
With no retroarch.cfg naming the folder as its system_directory, and
|
|
none beside it, nothing names the cores folder: the run stopped on "no
|
|
known directory" with no way forward.
|
|
"""
|
|
where = (f"none in {', '.join(map(str, searched))}" if searched
|
|
else f"no retroarch.cfg was found for {bios_path} to name their folder")
|
|
print(f"Error: no installed core found for {display_name(plat)}: {where}.",
|
|
file=sys.stderr)
|
|
print(" Name the folder RetroArch loads cores from (libretro_directory in "
|
|
"retroarch.cfg, cores/ beside RetroArch by default):", file=sys.stderr)
|
|
command = _INVOCATION.command(plat, bios_path, without=("--installed-cores", "--cores-dir"))
|
|
print(f" {command} --cores-dir <cores folder>", file=sys.stderr)
|
|
print(" Or run it without --installed-cores to install every file.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def card_platform(root: Path) -> str | None:
|
|
"""The frontend whose card ``root`` is, from the release name it wrote."""
|
|
for name, marker, prefix in _CARD_RULES:
|
|
try:
|
|
with open(root / marker, encoding="utf-8", errors="replace") as handle:
|
|
first = handle.readline().strip()
|
|
except OSError:
|
|
continue
|
|
if first.startswith(prefix):
|
|
return name
|
|
return None
|
|
|
|
|
|
# A system that runs from its SD card names the card by where the device
|
|
# mounts it: on a computer the card's root stands in for that mount point,
|
|
# and its device rule then reads the same below the root.
|
|
_CARD_MOUNTS = ("/mnt/SDCARD/", "/mnt/mmc/")
|
|
|
|
# Systems whose card a computer sees as their data partition, by the file
|
|
# their own build writes there, paths below that partition. KNULLI's is
|
|
# /userdata: S12populateshare:15,105 copies datainit there, and every board's
|
|
# datainit/system/batocera.conf sets system.hostname=KNULLI (knulli-cfw
|
|
# distribution ab3a6809, board/batocera/*/fsoverlay/usr/share/batocera/).
|
|
# ROCKNIX's is /storage: userconfig-setup:12 copies /usr/config into
|
|
# /storage/.config, and its system.cfg sets root.password=rocknix (ROCKNIX
|
|
# distribution 8b70f693, projects/ROCKNIX/packages/rocknix/config/system/
|
|
# configs/system.cfg:152).
|
|
_SHARE_RULES: tuple[_OsRule, ...] = (
|
|
_OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_matches",
|
|
("system/batocera.conf", r"^system\.hostname=KNULLI\s*$"), "bios"),
|
|
_OsRule("rocknix", "ROCKNIX", "file_matches",
|
|
(".config/system/configs/system.cfg", r"^root\.password=rocknix\s*$"),
|
|
"roms/bios"),
|
|
)
|
|
|
|
|
|
def _card_rules() -> list[_OsRule]:
|
|
"""The device rules that name a card, their paths below its root."""
|
|
rules = list(_SHARE_RULES)
|
|
for rule in _EMBEDDED_RULES:
|
|
if rule.method != "file_exists" or not rule.manifest or not rule.bios:
|
|
continue
|
|
mount = next((m for m in _CARD_MOUNTS if rule.bios.startswith(m)
|
|
and all(value.startswith(m) for value in rule.values)), "")
|
|
if mount:
|
|
rules.append(rule._replace(
|
|
values=tuple(value[len(mount):] for value in rule.values),
|
|
bios=rule.bios[len(mount):], configs=()))
|
|
return rules
|
|
|
|
|
|
def _card_rule_matches(rule: _OsRule, root: Path) -> bool:
|
|
if rule.method == "file_matches":
|
|
path, pattern = rule.values
|
|
try:
|
|
text = (root / path).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return False
|
|
return re.search(pattern, text, re.MULTILINE) is not None
|
|
return all(_exists(root / value) for value in rule.values)
|
|
|
|
|
|
def card_system(root: Path) -> tuple[str, Path, str] | None:
|
|
"""(file list, BIOS folder, system name) of the card mounted at root.
|
|
|
|
None when nothing on it names a system, or when two systems that would
|
|
write to different folders both match: the card is then the user's to
|
|
name, never a guess.
|
|
"""
|
|
plat = card_platform(root)
|
|
if plat:
|
|
return plat, root, display_name(plat)
|
|
matched = _card_matches(root)
|
|
if len({(rule.manifest, rule.bios) for rule in matched}) != 1:
|
|
return None
|
|
rule = matched[0]
|
|
return rule.manifest, root / rule.bios, rule.name
|
|
|
|
|
|
def _card_matches(root: Path) -> list[_OsRule]:
|
|
"""The card rules root matches, a system another one is built on left
|
|
out for it."""
|
|
matched = [rule for rule in _card_rules() if _card_rule_matches(rule, root)]
|
|
bases = {rule.base for rule in matched if rule.base}
|
|
return [rule for rule in matched if rule.name not in bases]
|
|
|
|
|
|
def _ends_with(path: Path, relative: str) -> bool:
|
|
"""Whether path's last folders are relative's, compared folder by folder
|
|
and without case: NOTMUOS/bios does not end with MUOS/bios."""
|
|
tail = [part.casefold() for part in PurePosixPath(relative).parts]
|
|
parts = [part.casefold() for part in path.parts]
|
|
return len(parts) >= len(tail) and parts[len(parts) - len(tail):] == tail
|
|
|
|
|
|
def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]:
|
|
"""The file list and folder for a --dest that names a handheld's card.
|
|
|
|
The card's root is where the user points, its BIOS folder is below it:
|
|
`--platform muos --dest /media/me/SD1` wrote to the root, which muOS
|
|
never reads. A system named on the command line whose card this is not
|
|
is refused with the folder it reads.
|
|
"""
|
|
found = card_system(dest)
|
|
if found and (not plat or found[0] == plat) and found[1] != dest:
|
|
print(f" {found[2]} card: {display_name(found[0])} files, in {found[1]}")
|
|
return found[0], found[1]
|
|
# Only a system named by its own name: --platform retroarch or knulli
|
|
# says which list, not which card.
|
|
key = _name_key(named)
|
|
rule = next((rule for rule in _card_rules() if key and key == _name_key(rule.name)
|
|
and key != _name_key(display_name(rule.manifest))), None)
|
|
matched = [] if found else _card_matches(dest)
|
|
if rule is None or found:
|
|
if len({(match.manifest, match.bios) for match in matched}) > 1:
|
|
names = " and ".join(sorted({match.name for match in matched}))
|
|
print(f" {screen_text(str(dest))} holds the files of {names}: installing into "
|
|
"it as named. Name the card's BIOS folder with --dest to write there.")
|
|
return plat, dest
|
|
# Its folder on the card, or one its device reads (/run/muos/storage/bios).
|
|
own = {device.bios for device in _EMBEDDED_RULES if device.name == rule.name}
|
|
if dest.as_posix() in own or _ends_with(dest, rule.bios):
|
|
return plat, dest
|
|
if any(match.name == rule.name for match in matched):
|
|
# A card two systems claim, the one named among them.
|
|
print(f" {rule.name} card: {display_name(plat)} files, in {dest / rule.bios}")
|
|
return plat, dest / rule.bios
|
|
marker = rule.values[0].split("/")[0]
|
|
print(f"Error: {dest} is not a {rule.name} card: it holds no {marker}. "
|
|
f"{rule.name} reads its BIOS files from {rule.bios} on its card: name the card's "
|
|
f"root (the folder holding {marker}) or that folder: --dest <card>/{rule.bios}",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _check_card_root(plat: str, dest: Path) -> None:
|
|
"""Refuse a folder inside the card where its root is meant.
|
|
|
|
The file list writes Bios/<TAG>/ below --dest: given the card's Bios
|
|
folder, every file would land in Bios/Bios/<TAG>/, which no core reads.
|
|
"""
|
|
found = card_platform(dest)
|
|
if found is None:
|
|
for parent in list(dest.absolute().parents)[:2]:
|
|
holder = card_platform(parent)
|
|
if holder:
|
|
print(f"Error: {dest} is inside a {display_name(holder)} card. "
|
|
f"Name the card's root: --dest {parent}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return
|
|
if found != plat:
|
|
print(f" Note: this card holds {display_name(found)} ({CARD_MARKER}), and the "
|
|
f"{display_name(plat)} file list was asked for.")
|
|
|
|
|
|
def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str:
|
|
"""The file list for a --dest given without --platform.
|
|
|
|
A folder says where, not for whom, and a card is often prepared on a PC
|
|
for a console: --dest alone installed RetroArch's names and layout into
|
|
a Batocera share. The platforms detected here are the evidence. At a
|
|
terminal the user picks, Enter taking the one found. Otherwise the one
|
|
found is used, RetroArch when none is, and several found are refused.
|
|
"""
|
|
card = card_platform(dest)
|
|
if card:
|
|
print(f" File list: {display_name(card)} (the card's {CARD_MARKER}). "
|
|
f"Same choice without detection: --platform {card}")
|
|
return card
|
|
print("Detecting platform...")
|
|
found = list(dict.fromkeys(name for name, _path in detect_platforms(os_type)))
|
|
default = found[0] if len(found) == 1 else "" if found else "retroarch"
|
|
if interactive:
|
|
plat = _choose_platform(f"Which platform's files go to {dest}?", default)
|
|
print(f" File list: {display_name(plat)}. Same choice without questions: "
|
|
f"--platform {plat}")
|
|
return plat
|
|
if not default:
|
|
print(f"Error: {' and '.join(display_name(n) for n in found)} found here. "
|
|
f"Name the platform {dest} is for with --platform.", file=sys.stderr)
|
|
sys.exit(1)
|
|
why = "the platform found here" if found else "nothing was detected here"
|
|
print(f" File list: {display_name(default)} ({why}). For another platform's "
|
|
"list, add --platform (--list-platforms names them).")
|
|
return default
|
|
|
|
|
|
def _unknown_target(target: str, plat: str, targets: dict) -> None:
|
|
print(f"Error: unknown target '{target}' for {plat}.", file=sys.stderr)
|
|
if targets:
|
|
print(" " + did_you_mean([target], sorted(targets), "--list-targets"),
|
|
file=sys.stderr)
|
|
else:
|
|
print(f" {plat} publishes no target list", file=sys.stderr)
|
|
|
|
|
|
def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
|
"""One run of the installer, once the options are read."""
|
|
KEPT.reset()
|
|
term = Terminal(real_stdout, quiet=args.quiet)
|
|
interactive = sys.stdin.isatty() and not args.no_input and not args.quiet
|
|
listing = (args.list_targets or args.list_systems or args.list_cores
|
|
or args.list_emulators)
|
|
print(f"{term.bold('RetroBIOS')} {term.dim(installer_id())}\n")
|
|
|
|
os_type = detect_os()
|
|
if os_type == "wsl":
|
|
_enter_wsl()
|
|
_open_local_sources(args)
|
|
|
|
# Early exit for listing
|
|
if args.list_platforms:
|
|
print("Available platforms:")
|
|
for p in AVAILABLE_PLATFORMS:
|
|
print(f" {p}")
|
|
print("\nSystems recognized by their own files, and the file list each gets "
|
|
"(--platform takes either name):")
|
|
for line in _system_lines():
|
|
print(f" {line}")
|
|
detected = detect_platforms(os_type)
|
|
if detected:
|
|
print("\nDetected on this system:")
|
|
for name, path in detected:
|
|
system = detected_os(name, path)
|
|
print(f" {name}: {path}" + (f" ({system})" if system else ""))
|
|
else:
|
|
print("\nDetected on this system: none")
|
|
return
|
|
|
|
# Platform detection or override. A destination taken from the default
|
|
# table, the platform not being found here, is only a guess.
|
|
assumed_dests: set[Path] = set()
|
|
named = args.platform or ""
|
|
if args.platform:
|
|
args.platform = normalize_platform(args.platform)
|
|
if args.dest:
|
|
plat, args.dest = card_dest(named, args.platform or "", args.dest)
|
|
args.platform = plat or args.platform
|
|
if args.platform and args.dest:
|
|
if args.platform in CARD_PLATFORMS:
|
|
_check_card_root(args.platform, args.dest)
|
|
platforms = [(args.platform, args.dest)]
|
|
elif args.platform:
|
|
print("Detecting platform...")
|
|
detected = detect_platforms(os_type)
|
|
matched = [(n, p) for n, p in detected if n == args.platform]
|
|
if matched:
|
|
platforms = matched
|
|
elif listing:
|
|
# A listing reads the manifest and writes nothing: no folder is
|
|
# needed, and asking BizHawk's for one refused a question asked
|
|
# before downloading BizHawk.
|
|
platforms = [(args.platform, _default_dest(os_type, args.platform) or Path.cwd())]
|
|
elif args.platform in CARD_PLATFORMS:
|
|
# Its files go on its SD card, which is never this machine's
|
|
# own folder: the card's mount point is the user's to name.
|
|
print(
|
|
f" {display_name(args.platform)} keeps its BIOS files on its SD card. "
|
|
f"Mount the card and name its root with --dest, for example: "
|
|
f"--platform {args.platform} --dest /media/$USER/<card> "
|
|
f"(Windows: --dest E:\\)",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
elif args.platform in PORTABLE_ONLY:
|
|
# A portable emulator reads the folder beside its executable:
|
|
# any default would be a folder it never opens.
|
|
print(
|
|
f" Platform '{args.platform}' not found. {PORTABLE_ONLY[args.platform]} "
|
|
f"Name it with --dest, for example: --platform {args.platform} "
|
|
f"--dest '<BizHawk folder>{os.sep}Firmware'",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
default_dest = _default_dest(os_type, args.platform)
|
|
if default_dest is None:
|
|
print(
|
|
f" Platform '{args.platform}' not found, and no folder on this "
|
|
f"system is one it reads. Name it with --dest, for example: "
|
|
f"--platform {args.platform} --dest <its BIOS folder>",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
print(
|
|
f" Platform '{args.platform}' not detected, "
|
|
f"using default path: {default_dest}"
|
|
)
|
|
platforms = [(args.platform, default_dest)]
|
|
assumed_dests.add(default_dest)
|
|
elif args.dest:
|
|
print(f" Using destination: {args.dest}")
|
|
platforms = [(_platform_for_dest(os_type, args.dest, interactive and not listing),
|
|
args.dest)]
|
|
else:
|
|
print("Detecting platform...")
|
|
frontends = detect_frontends(os_type)
|
|
if "esde" in frontends:
|
|
print(" Found ES-DE (BIOS files go to each emulator, not to ES-DE itself)")
|
|
if "launchbox" in frontends:
|
|
print(" Found LaunchBox")
|
|
platforms = detect_platforms(os_type)
|
|
if not platforms:
|
|
print(" No supported platform detected.")
|
|
EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[])
|
|
# Standalone emulators read their BIOS from folders of their own,
|
|
# which the file lists name: an ES-DE user with DuckStation and
|
|
# PCSX2 was sent to RetroArch's folder, which nothing here reads.
|
|
narrowing = (args.system or args.core or args.emulator or args.region
|
|
or args.target or args.installed_cores or args.declared_only)
|
|
if not listing and not narrowing and _install_standalone_folders(
|
|
args, os_type, term, interactive
|
|
):
|
|
return
|
|
if not interactive or listing:
|
|
asked = next((flag for flag, on in (
|
|
("--list-systems", args.list_systems), ("--list-cores", args.list_cores),
|
|
("--list-emulators", args.list_emulators),
|
|
("--list-targets", args.list_targets),
|
|
) if on), "")
|
|
for line in _manual_usage_hint(os_type, asked):
|
|
print(line)
|
|
sys.exit(1)
|
|
platforms = _prompt_manual_platform(os_type)
|
|
plat, typed = platforms[0]
|
|
if typed != _default_dest(os_type, plat):
|
|
_missing_dest(typed, not (args.check or args.dry_run or args.yes),
|
|
"typed")
|
|
else:
|
|
for name, path in platforms:
|
|
print(f" Found {found_label(name, path)}")
|
|
|
|
conflict = os_conflict(platforms)
|
|
if conflict and not interactive and not listing:
|
|
# A machine runs one system: installing to each folder would write
|
|
# where nothing reads, and picking one would be a guess.
|
|
message = (f"{' and '.join(conflict)} are both detected here, and a "
|
|
"machine runs one")
|
|
EVENTS.emit("error", stage="detect", reason="conflict", platform="",
|
|
message=message)
|
|
print(f"Error: {message}. Name the folder with --dest (and --platform for "
|
|
"its file list).",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
if platforms:
|
|
EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[
|
|
{"platform": name, "name": display_name(name), "bios_dir": str(path)}
|
|
for name, path in platforms
|
|
])
|
|
|
|
if len(platforms) > 1 and not listing and interactive:
|
|
# --check installs nothing, and "Install for:" said it would.
|
|
platforms = _prompt_platform_choice(
|
|
platforms, "Check:" if args.check else "Install for:")
|
|
|
|
if args.dest is not None and not listing:
|
|
_missing_dest(args.dest, interactive and not (args.check or args.dry_run or args.yes))
|
|
|
|
total_downloaded = 0
|
|
total_up_to_date = 0
|
|
total_errors = 0
|
|
total_omitted = 0
|
|
omitted_seen: list[dict] = []
|
|
left_out_seen: list[dict] = []
|
|
total_interrupted = False
|
|
total_bytes = 0
|
|
total_seconds = 0.0
|
|
total_declined = 0
|
|
check_pending = 0
|
|
total_verified = 0
|
|
files_pending = 0
|
|
failures: list[tuple[str, str]] = []
|
|
installed_paths: list[Path] = []
|
|
bulk_lines: list[str] = []
|
|
# The BIOS folders read for misplaced files, one per platform.
|
|
bios_indexes: list[FolderIndex] = []
|
|
|
|
# --target is checked for every platform before anything is written, as
|
|
# --installed-cores is below: EmuDeck installed 3.2 GB and only then was
|
|
# steamos refused for RetroArch, with no summary of what had landed.
|
|
target_lists: dict[str, dict] = {}
|
|
if args.target and not args.list_targets:
|
|
for plat_name, _bios_path in platforms:
|
|
targets = fetch_targets(plat_name)
|
|
if targets is None:
|
|
sys.exit(1)
|
|
target_lists[plat_name] = targets
|
|
if args.target not in targets:
|
|
_unknown_target(args.target, plat_name, targets)
|
|
if len(platforms) > 1:
|
|
print(" Nothing was installed. Name one platform with "
|
|
"--platform to install it alone.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.romm_firmware is not None:
|
|
# Applied or refused, never ignored.
|
|
if not any(name == "romm" for name, _path in platforms):
|
|
print("Error: --romm-firmware applies to RomM, which this run does not "
|
|
"install for.", file=sys.stderr)
|
|
sys.exit(1)
|
|
try:
|
|
parse_romm_firmware(args.romm_firmware)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Every check --installed-cores makes before anything is written: a
|
|
# refusal for the second platform must not follow an install of the first.
|
|
cores_found: dict[tuple[str, Path], list[str]] = {}
|
|
for plat_name, bios_path in platforms:
|
|
if plat_name in CORE_PLATFORMS:
|
|
searched = [args.cores_dir] if args.cores_dir else core_dirs(plat_name, bios_path)
|
|
cores_found[(plat_name, bios_path)] = installed_cores(searched)
|
|
elif args.installed_cores:
|
|
print(
|
|
f"Error: {plat_name} ships its cores with the system; "
|
|
f"--installed-cores applies to {', '.join(CORE_PLATFORMS)}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
if args.installed_cores and not cores_found[(plat_name, bios_path)]:
|
|
_no_core_found(plat_name, bios_path, searched)
|
|
|
|
for plat_name, bios_path in platforms:
|
|
if bios_path not in installed_paths:
|
|
installed_paths.append(bios_path)
|
|
if LOCAL.manifest is not None:
|
|
print(f"\nReading the file index for {plat_name} from {LOCAL.manifest}...")
|
|
elif LOCAL.pack is not None:
|
|
print(f"\nReading the file index for {plat_name} from {LOCAL.pack.name}...")
|
|
else:
|
|
print(f"\nFetching file index for {plat_name}...")
|
|
manifest = fetch_manifest(plat_name)
|
|
files = manifest.get("files", [])
|
|
omitted_files = manifest.get("omitted_files", [])
|
|
variants = manifest.get("case_variants", [])
|
|
case_kept = keeps_case(bios_path)
|
|
if variants and case_kept:
|
|
# The list is folded for Windows and macOS, but here bios.col and
|
|
# BIOS.col are two files that two cores open.
|
|
files = files + [{**variant, "case_variant": True} for variant in variants]
|
|
if not case_kept:
|
|
files, folded = fold_case_twins(files)
|
|
if folded:
|
|
# Batocera's share opened over SMB, an exFAT card: the second
|
|
# spelling overwrote the first, and every --check found one
|
|
# of them wrong and fetched it again.
|
|
shown = ", ".join(folded[:3]) + (" ..." if len(folded) > 3 else "")
|
|
print(
|
|
f" {_plural(len(folded), 'file')} left out: {bios_path} does not keep "
|
|
f"case and holds another file under the same name ({shown})"
|
|
)
|
|
|
|
game_data_cut = 0
|
|
if args.no_game_data:
|
|
if not manifest.get("categorized"):
|
|
# Without the field nothing is marked, and the run would
|
|
# install the game data it was asked to leave out.
|
|
print(f"Error: the {plat_name} file list records no file category, "
|
|
"which --no-game-data needs.", file=sys.stderr)
|
|
print(_older_list_advice(), file=sys.stderr)
|
|
sys.exit(1)
|
|
before = len(files)
|
|
files, omitted_files, left_out = leave_out_game_data(files, omitted_files)
|
|
game_data_cut = before - len(files)
|
|
left_out_seen.extend(left_out)
|
|
if left_out:
|
|
for line in _left_out_lines(left_out, args.verbose):
|
|
print(line)
|
|
|
|
if args.declared_only:
|
|
before = len(files)
|
|
files, omitted_files = keep_declared(files, omitted_files)
|
|
print(f" The platform's own list: {before} -> {len(files)} files "
|
|
"(--declared-only)")
|
|
|
|
if args.list_targets:
|
|
targets = fetch_targets(plat_name)
|
|
if targets is None:
|
|
sys.exit(1)
|
|
if not targets:
|
|
print(f" No targets available for {plat_name}")
|
|
else:
|
|
for t in sorted(targets.keys()):
|
|
cores = targets[t].get("cores")
|
|
label = "no core list" if cores is None else f"{len(cores)} cores"
|
|
print(f" {t} ({label})")
|
|
continue
|
|
|
|
# Target filtering. With no --target, a system that names its board
|
|
# narrows to it: a Batocera RPi4 took 8.6 GiB where its board needs
|
|
# 5.5. Only on the device itself, where the board file exists.
|
|
target = args.target
|
|
if not target and not args.every_board and args.dest is None and not LOCAL.offline:
|
|
board = detect_board(plat_name)
|
|
if board:
|
|
known = fetch_targets(plat_name) or {}
|
|
if board in known:
|
|
target = board
|
|
print(f" Board {board} detected (--every-board installs all boards)")
|
|
else:
|
|
print(f" Board {board} has no {PLATFORM_NAMES.get(plat_name, plat_name)} "
|
|
"core list: installing the files of every board")
|
|
else:
|
|
derived = derivative_board(plat_name)
|
|
if derived:
|
|
print(f" {derived[0]} board {derived[1]} detected. {derived[0]} "
|
|
"builds its own set of cores and publishes no core list "
|
|
"per board: installing the files of every board")
|
|
if target:
|
|
targets = target_lists.get(plat_name)
|
|
if targets is None:
|
|
targets = fetch_targets(plat_name)
|
|
if targets is None:
|
|
sys.exit(1)
|
|
target_info = targets.get(target)
|
|
if target_info is None:
|
|
# Carrying on would install every file, which is the opposite
|
|
# of what a filter was asked for: a 4 GB download instead of
|
|
# the subset the target needs. Refuse rather than ignore.
|
|
_unknown_target(target, plat_name, targets)
|
|
sys.exit(1)
|
|
elif target_info.get("cores") is None:
|
|
print(
|
|
f" Target '{target}' publishes no core list; "
|
|
"installing every file"
|
|
)
|
|
else:
|
|
before = len(files)
|
|
files, omitted_files = narrow_to_cores(
|
|
manifest, target_info["cores"], files, omitted_files
|
|
)
|
|
print(f" Filtered {before} -> {len(files)} files for target {target}")
|
|
|
|
found_cores = cores_found.get((plat_name, bios_path), [])
|
|
if "system_cores" not in manifest:
|
|
found_cores = []
|
|
elif found_cores:
|
|
print(f" {_plural(len(found_cores), 'core')} installed")
|
|
if args.installed_cores:
|
|
if "system_cores" not in manifest:
|
|
print(
|
|
f"Error: the {plat_name} file list does not say which cores "
|
|
"read which systems, which --installed-cores needs.",
|
|
file=sys.stderr,
|
|
)
|
|
print(_older_list_advice(), file=sys.stderr)
|
|
sys.exit(1)
|
|
before = len(files)
|
|
files, omitted_files = narrow_to_cores(
|
|
manifest, found_cores, files, omitted_files
|
|
)
|
|
print(f" Narrowed {before} -> {len(files)} files to the installed cores")
|
|
|
|
if args.list_emulators:
|
|
_print_emulators(plat_name, manifest, files, omitted_files)
|
|
continue
|
|
|
|
if args.emulator:
|
|
files, omitted_files = _narrow_to_emulators(
|
|
args.emulator, manifest, plat_name, files, omitted_files
|
|
)
|
|
|
|
# What --core resolves against, for advice that names one.
|
|
core_choices = set(available_choices(files, omitted_files)["cores"])
|
|
selection = _requested_narrowing(args, plat_name, files, omitted_files, manifest)
|
|
# What declining the size question points back at, when it was asked.
|
|
smaller = ""
|
|
if (
|
|
selection is None
|
|
and interactive
|
|
and not args.check
|
|
and not listing
|
|
and records_selection(files)
|
|
):
|
|
installed = (
|
|
narrow_to_cores(manifest, found_cores, files, omitted_files)
|
|
if found_cores and not args.installed_cores else None
|
|
)
|
|
selection = _prompt_custom_selection(
|
|
plat_name, files, omitted_files, installed,
|
|
display_name(plat_name, manifest),
|
|
)
|
|
if selection is None:
|
|
smaller = (" Nothing downloaded. To install less, run the same command "
|
|
"again and type c at the first question to choose systems")
|
|
if installed is not None:
|
|
smaller += (f", or i for only what the installed cores read "
|
|
f"({format_size(_bytes(installed[0]))})")
|
|
smaller += "."
|
|
if selection == INSTALLED_ONLY:
|
|
files, omitted_files = installed
|
|
print("\n Same choice without questions: "
|
|
f"--platform {plat_name} --installed-cores"
|
|
+ (" --no-game-data" if args.no_game_data else "")
|
|
+ (" --declared-only" if args.declared_only else ""))
|
|
_INVOCATION.chosen[plat_name] = [("--installed-cores", None)]
|
|
selection = None
|
|
elif selection is not None:
|
|
print("\n Same choice without questions: "
|
|
+ _same_choice_hint(plat_name, *selection)
|
|
+ (" --no-game-data" if args.no_game_data else "")
|
|
+ (" --declared-only" if args.declared_only else ""))
|
|
systems, cores, regions = selection
|
|
_INVOCATION.chosen[plat_name] = [
|
|
*(("--system", name) for name in systems),
|
|
*(("--core", name) for name in cores),
|
|
*([("--region", ",".join(regions))] if regions else []),
|
|
]
|
|
if selection is not None:
|
|
before = len(files)
|
|
files, omitted_files = narrow(files, omitted_files, *selection)
|
|
print(f" Narrowed {before} -> {len(files)} files")
|
|
|
|
if args.list_systems or args.list_cores:
|
|
_print_choices(plat_name, files, omitted_files, args.list_systems, manifest)
|
|
continue
|
|
|
|
total_size = sum(f.get("size", 0) for f in files)
|
|
print(f" {len(files)} files ({format_size(total_size)})")
|
|
if omitted_files:
|
|
required_omitted = sum(
|
|
1 for entry in omitted_files if entry.get("required", True)
|
|
)
|
|
reasons: dict[str, int] = {}
|
|
for entry in omitted_files:
|
|
reason = entry.get("reason", "unknown")
|
|
reasons[reason] = reasons.get(reason, 0) + 1
|
|
reason_summary = ", ".join(
|
|
f"{OMISSION_REASONS.get(reason, reason)}: {count}"
|
|
for reason, count in sorted(reasons.items())
|
|
)
|
|
print(
|
|
f" Not installable: {_plural(len(omitted_files), 'file')} "
|
|
f"({required_omitted} required, {reason_summary})."
|
|
)
|
|
total_omitted += len(omitted_files)
|
|
omitted_seen.extend(omitted_files)
|
|
|
|
if plat_name == "romm":
|
|
try:
|
|
layout = romm_firmware_layout(bios_path, args.romm_firmware)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if layout is not None:
|
|
files, library = romm_relayout(files, bios_path, *layout)
|
|
if library != bios_path:
|
|
installed_paths[installed_paths.index(bios_path)] = library
|
|
print(f" Firmware laid out as {'/'.join(layout[0] + ('{platform}',) + layout[1])} "
|
|
f"under {library}")
|
|
bios_path = library
|
|
|
|
files, unplaced = place_save_entries(
|
|
files, bios_path, manifest, args.save_dir, display_name(plat_name, manifest)
|
|
)
|
|
if unplaced:
|
|
total_omitted += len(unplaced)
|
|
omitted_seen.extend(unplaced)
|
|
|
|
print("\nChecking existing files...")
|
|
to_download, up_to_date, mismatched = check_local(
|
|
files, bios_path, jobs=args.jobs, term=term
|
|
)
|
|
present = len(up_to_date) + len(mismatched)
|
|
print(
|
|
f" {present}/{len(files)} present "
|
|
f"({len(up_to_date)} verified, {len(mismatched)} wrong hash)"
|
|
)
|
|
|
|
# A partial copy left by an interrupted run is useless once the file
|
|
# is in place: it goes.
|
|
for entry in up_to_date:
|
|
try:
|
|
stale = _entry_path(bios_path, entry)
|
|
except ValueError:
|
|
continue
|
|
stale = stale.parent / f".{stale.name}.part"
|
|
if stale.is_file():
|
|
stale.unlink()
|
|
|
|
# Mismatched files need re-download
|
|
to_download.extend(mismatched)
|
|
# The files this machine already holds: misplaced in the BIOS folder
|
|
# (bios/bios/, a pack extracted into a folder of its own, a name in
|
|
# capitals, still zipped), or in a folder named with --from.
|
|
bios_index = (bios_folder_index(bios_path, files, up_to_date)
|
|
if to_download else None)
|
|
if bios_index is not None:
|
|
bios_indexes.append(bios_index)
|
|
copies = plan_local_copies(to_download, bios_path, bios_index, LOCAL.folders)
|
|
unread = unreadable_copies(to_download, copies, LOCAL.folders)
|
|
hint = [] if LOCAL.pack is not None else bulk_hint(
|
|
to_download, manifest,
|
|
systems_chosen=bool(selection and selection[0]),
|
|
cores_chosen=bool((selection and selection[1]) or args.emulator
|
|
or args.installed_cores or target),
|
|
accepted=core_choices,
|
|
)
|
|
|
|
narrowed = bool(
|
|
target or args.system or args.core or args.region or args.installed_cores
|
|
or args.declared_only or len(files) + game_data_cut + len(unplaced)
|
|
!= len(manifest.get("files", [])) + len(variants if case_kept else [])
|
|
)
|
|
data_dirs = select_data_directories(manifest, files, narrowed)
|
|
data_states = [
|
|
(entry, data_directory_state(entry, bios_path)) for entry in data_dirs
|
|
] if args.check or EVENTS.on else []
|
|
if EVENTS.on:
|
|
_emit_check(
|
|
plat_name, manifest, bios_path, files, up_to_date, mismatched,
|
|
sum(len(state[1]) for _entry, state in data_states), len(omitted_files),
|
|
chosen=selection[0] if selection else None,
|
|
)
|
|
emit_copies(plat_name, to_download, copies, bios_index, bios_path, files)
|
|
emit_unreadable(plat_name, unread)
|
|
total_verified += len(up_to_date)
|
|
|
|
if args.check:
|
|
if to_download:
|
|
# Named, not only counted: a user looking for the one bad
|
|
# BIOS learned "1 files need downloading" and nothing else.
|
|
# A wrong hash comes first and always in full: listed after
|
|
# 8000 missing files, it hid behind "... and 8059 more".
|
|
wrong = {id(entry) for entry in mismatched}
|
|
missing = [entry for entry in to_download if id(entry) not in wrong]
|
|
# A pack extracted from some of its parts: the part is named
|
|
# before the hundreds of files it holds.
|
|
for line in split_pack_lines(bios_path):
|
|
print(line)
|
|
print(f"\n {_plural(len(to_download), 'file')} to download:")
|
|
for line in check_lines(missing, mismatched, files, manifest, bios_path,
|
|
args.verbose, up_to_date, bios_index, copies):
|
|
print(line)
|
|
for line in unreadable_lines(unread, args.verbose):
|
|
print(line)
|
|
print(" Run the same command without --check to fetch them; "
|
|
"a file with a wrong hash is replaced, and the one in place "
|
|
f"kept in {BACKUP_DIR} beside the BIOS folder.")
|
|
check_pending += len(to_download)
|
|
else:
|
|
print("\n All files up to date.")
|
|
absent = _absent_required(omitted_files, bios_path)
|
|
# The collection cannot supply these, so they leave the exit
|
|
# code alone, but a user asking why a game will not start needs
|
|
# their names, and which system and emulator each one is for.
|
|
for line in _needed_lines(
|
|
absent, "Not in the collection and absent here, needed to start an emulator",
|
|
every=args.verbose,
|
|
):
|
|
print(line)
|
|
for entry, (_root, needed, present) in data_states:
|
|
if needed:
|
|
print(f" {entry['ref']}: {present}/{len(entry['files'])} data files "
|
|
f"present, {len(needed)} to fetch")
|
|
check_pending += len(needed)
|
|
continue
|
|
|
|
if args.dry_run:
|
|
files_pending += len(to_download)
|
|
trees = [(entry, data_directory_state(entry, bios_path)[1]) for entry in data_dirs]
|
|
# A data file in place with other contents is kept like a BIOS file.
|
|
kept = plan_kept(mismatched + [
|
|
{"dest": f"{entry['dest']}/{rel}" if entry.get("dest") else rel}
|
|
for entry, needed in trees for rel in needed
|
|
], bios_path)
|
|
_print_plan([f for f in to_download if id(f) not in copies], bios_path, term,
|
|
trees, kept, copy_plan_lines(to_download, copies, bios_index,
|
|
bios_path, files, args.verbose, True),
|
|
verbose=args.verbose)
|
|
for line in unreadable_lines(unread, args.verbose):
|
|
print(line)
|
|
for line in hint:
|
|
print(line)
|
|
continue
|
|
|
|
if to_download:
|
|
dl_size = sum(f.get("size", 0) for f in to_download)
|
|
fetched = [f for f in to_download if id(f) not in copies]
|
|
fetch_size = _bytes(fetched)
|
|
if (
|
|
interactive
|
|
and not args.yes
|
|
and fetch_size >= CONFIRM_FROM_BYTES
|
|
and not _confirm(
|
|
f"\nDownload {format_size(fetch_size)} to {bios_path}? [Y/n] "
|
|
)
|
|
):
|
|
# A beginner who just passed the c entry was sent to a
|
|
# 91-line --help.
|
|
print(smaller or " Nothing downloaded. --system, --core and --region "
|
|
"install less (see --help).")
|
|
total_up_to_date += len(up_to_date)
|
|
total_declined += len(to_download)
|
|
files_pending += len(to_download)
|
|
continue
|
|
EVENTS.emit("download", platform=plat_name, files=len(to_download), bytes=dl_size)
|
|
if unread:
|
|
print()
|
|
for line in unreadable_lines(unread, args.verbose):
|
|
print(line)
|
|
if copies:
|
|
print()
|
|
for line in copy_plan_lines(to_download, copies, bios_index, bios_path,
|
|
files, args.verbose, False):
|
|
print(line)
|
|
rest = (f" and {'copying' if LOCAL.pack is not None else 'downloading'} "
|
|
f"{len(fetched)}" if fetched else "")
|
|
where = f" from {LOCAL.pack.name}" if fetched and LOCAL.pack is not None else ""
|
|
print(f"\nCopying {_plural(len(copies), 'file')} found on this machine"
|
|
f"{rest}{where} ({format_size(dl_size)})...")
|
|
elif LOCAL.pack is not None:
|
|
print(f"\nCopying {len(to_download)} files ({format_size(dl_size)}) "
|
|
f"from {LOCAL.pack.name}...")
|
|
else:
|
|
print(f"\nDownloading {len(to_download)} files ({format_size(dl_size)})...")
|
|
print(" Press Ctrl+C to stop. Running the same command again "
|
|
"carries on from here.")
|
|
# Checked before the folder is created: a run stopped for space
|
|
# left an empty BIOS folder behind.
|
|
_check_free_space(bios_path, dl_size, plat_name, 1 if total_downloaded else 2)
|
|
created = not bios_path.exists()
|
|
try:
|
|
bios_path.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
sys.stdout.flush()
|
|
print(f"\nCannot create {bios_path}.", file=sys.stderr)
|
|
print(f" {exc.strerror}.", file=sys.stderr)
|
|
print(_write_advice(bios_path, bios_path in assumed_dests, plat_name),
|
|
file=sys.stderr)
|
|
# The documented code for a folder that cannot be written,
|
|
# as when it exists read-only; 1 once an earlier platform
|
|
# of the run installed something.
|
|
sys.exit(1 if total_downloaded else 2)
|
|
if created:
|
|
print(f" Created {bios_path}")
|
|
progress = Progress(len(to_download), dl_size, term)
|
|
failed, interrupted = download_files(
|
|
to_download, bios_path, jobs=args.jobs, verbose=args.verbose,
|
|
term=term, progress=progress, copies=copies,
|
|
)
|
|
bulk_lines += hint
|
|
total_downloaded += progress.installed
|
|
files_pending += len(to_download) - progress.installed if interrupted \
|
|
else len(failed)
|
|
total_errors += len(failed)
|
|
failures.extend(failed)
|
|
total_bytes += progress.transferred()
|
|
total_seconds += progress.elapsed()
|
|
if interrupted:
|
|
total_interrupted = True
|
|
break
|
|
else:
|
|
print("\n All files up to date.")
|
|
|
|
total_up_to_date += len(up_to_date)
|
|
|
|
# Data trees the pack carries beside the files: Dolphin's Sys,
|
|
# PPSSPP's assets. Fetched from their source archive, each file
|
|
# checked against the hash the list records.
|
|
if data_dirs and not total_interrupted:
|
|
print("\nData directories:")
|
|
for entry in data_dirs:
|
|
counts = install_data_directory(entry, bios_path)
|
|
total_downloaded += counts["installed"]
|
|
total_up_to_date += counts["present"]
|
|
line = f" {entry['ref']}: {counts['installed']} installed, " \
|
|
f"{counts['present']} already up to date"
|
|
if counts["changed"] or counts["absent"]:
|
|
line += (f", {counts['changed'] + counts['absent']} no longer in "
|
|
"the source (the pack has them)")
|
|
print(line)
|
|
if counts["error"]:
|
|
print(f" {entry['ref']}: could not read "
|
|
f"{LOCAL.pack.name if LOCAL.pack is not None else entry['url']}: "
|
|
f"{counts['error']}", file=sys.stderr)
|
|
failures.append((entry["ref"], counts["error"]))
|
|
total_errors += 1
|
|
for rel, reason in counts["not_kept"]:
|
|
where = f"{entry['dest']}/{rel}" if entry.get("dest") else rel
|
|
failures.append((where, f"{NOT_KEPT} ({reason})"))
|
|
total_errors += 1
|
|
|
|
# Standalone copies
|
|
if (
|
|
manifest.get("standalone_copies")
|
|
and not args.check
|
|
and args.standalone_copies
|
|
):
|
|
print("\nStandalone emulators:")
|
|
lb_root = launchbox_root(os_type)
|
|
extra_dirs = launchbox_bios_dirs(lb_root) if lb_root else None
|
|
copied, skipped, copy_failed = do_standalone_copies(
|
|
manifest, bios_path, os_type, extra_dirs, failures
|
|
)
|
|
if copied or skipped or copy_failed:
|
|
print(
|
|
f" {copied} copied, {skipped} skipped (dir not found), "
|
|
f"{copy_failed} failed"
|
|
)
|
|
total_errors += copy_failed
|
|
elif manifest.get("standalone_copies") and not args.check:
|
|
# Said only where it concerns this machine: "skipped" printed on
|
|
# every run read as an incident, and named no emulator.
|
|
lb_root = launchbox_root(os_type)
|
|
folders = standalone_folders_here(
|
|
manifest, os_type, launchbox_bios_dirs(lb_root) if lb_root else None
|
|
)
|
|
if folders:
|
|
print("\nStandalone emulator folders found here:")
|
|
for folder in folders:
|
|
print(f" {folder}")
|
|
print(" --standalone-copies also copies the BIOS files and keys "
|
|
"they read into them.")
|
|
|
|
sys.stdout = real_stdout
|
|
if not listing:
|
|
EVENTS.emit(
|
|
"summary", mode=_event_mode(args), installed=total_downloaded,
|
|
up_to_date=total_verified if args.check or args.dry_run else total_up_to_date,
|
|
pending=check_pending if args.check else files_pending,
|
|
failed=[{"dest": dest, "reason": str(reason)} for dest, reason in failures],
|
|
skipped=_skipped(omitted_seen), left_out=_left_out_events(left_out_seen),
|
|
kept=KEPT.summary(), interrupted=total_interrupted,
|
|
)
|
|
# A check scripted as `--check && launch` reads the exit code only.
|
|
if args.check and check_pending:
|
|
sys.exit(1)
|
|
if not args.check and not listing and not args.dry_run:
|
|
_report_outcome(
|
|
installed_paths,
|
|
total_downloaded,
|
|
total_up_to_date,
|
|
total_errors,
|
|
total_omitted,
|
|
total_interrupted,
|
|
failures=failures,
|
|
transferred=total_bytes,
|
|
seconds=total_seconds,
|
|
declined=total_declined,
|
|
assumed=assumed_dests,
|
|
omitted_entries=omitted_seen,
|
|
platform_of={path: plat for plat, path in platforms},
|
|
hints=bulk_lines,
|
|
sources=_run_sources(bios_indexes, total_downloaded, total_errors),
|
|
)
|
|
|
|
|
|
def _run_sources(bios_indexes: list[FolderIndex], installed: int, failed: int) -> str:
|
|
"""The end line saying which folder gave how many of the files."""
|
|
folders = [(index.label, index.copied) for index in LOCAL.folders]
|
|
fixed = sum(index.fixed for index in bios_indexes)
|
|
shared = sum(index.copied for index in bios_indexes)
|
|
fetched = installed - fixed - shared - sum(count for _label, count in folders)
|
|
return sources_line(folders, fixed, max(fetched, 0), failed,
|
|
"copied from the pack" if LOCAL.pack is not None else "downloaded",
|
|
shared)
|
|
|
|
|
|
# Full-screen interface
|
|
#
|
|
# A bare run at a terminal opens it, and --tui asks for it. It decides and the
|
|
# installer acts: every check and install is this file run again as a child,
|
|
# with --events, the contract the handheld port reads, and the choices made on
|
|
# screen as its options. The line questions stay behind --plain, for screen
|
|
# readers and for any terminal this cannot drive. Stdlib only: curses is
|
|
# missing from Windows CPython and from some Buildroot images.
|
|
|
|
TUI_MIN_COLS = 40
|
|
TUI_MIN_ROWS = 12
|
|
# Text stops growing past this width: on a 200-column window a row and its
|
|
# size sat a whole screen apart.
|
|
TUI_MAX_WIDTH = 100
|
|
# An arrow key arrives as Esc and two more characters, Esc alone as itself:
|
|
# past this wait an Esc with nothing after it is the key.
|
|
TUI_ESC_WAIT = 0.04
|
|
TUI_FRAME = 0.1
|
|
# Set for a child run handed the interface's own copy of the file list.
|
|
TUI_LIST_ENV = "RETROBIOS_TUI_LIST"
|
|
_PASTE_START = "\x1b[200~"
|
|
_PASTE_END = "\x1b[201~"
|
|
# Turned on by the interface, so a pasted path arrives as one piece and its
|
|
# newline does not submit it half typed.
|
|
_SCREEN_ON = "\x1b[?1049h\x1b[?25l\x1b[?2004h\x1b[2J"
|
|
_SCREEN_OFF = "\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l"
|
|
_STYLES = {"title": "\x1b[1m", "focus": "\x1b[7m", "dim": "\x1b[2m"}
|
|
|
|
|
|
class TuiKey(NamedTuple):
|
|
"""One key press: a name ("up", "enter"), or "char" or "paste" and the text."""
|
|
|
|
name: str
|
|
text: str = ""
|
|
|
|
|
|
_CSI_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end",
|
|
"Z": "shift-tab"}
|
|
_TILDE_KEYS = {"1": "home", "7": "home", "4": "end", "8": "end", "5": "pgup", "6": "pgdn",
|
|
"3": "delete", "11": "f1"}
|
|
_SS3_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end",
|
|
"P": "f1", "M": "enter"}
|
|
# msvcrt.getwch gives these after a \x00 or \xe0 prefix.
|
|
_WINDOWS_KEYS = {"H": "up", "P": "down", "K": "left", "M": "right", "I": "pgup",
|
|
"Q": "pgdn", "G": "home", "O": "end", "S": "delete", ";": "f1"}
|
|
|
|
|
|
def _plain_key(char: str) -> TuiKey | None:
|
|
"""The key one character stands for, None for a control nobody bound."""
|
|
named = {"\r": "enter", "\n": "enter", "\x7f": "backspace", "\x08": "backspace",
|
|
"\t": "tab", "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d",
|
|
"\x15": "ctrl-u"}.get(char)
|
|
if named:
|
|
return TuiKey(named)
|
|
if char < " " or unicodedata.category(char) in ("Cc", "Cs"):
|
|
return None
|
|
return TuiKey("char", char)
|
|
|
|
|
|
def _csi_key(params: str, final: str) -> TuiKey | None:
|
|
if final == "~":
|
|
if params == "200":
|
|
return TuiKey("paste-start")
|
|
name = _TILDE_KEYS.get(params.split(";")[0])
|
|
return TuiKey(name) if name else None
|
|
name = _CSI_KEYS.get(final)
|
|
if name is None:
|
|
return None
|
|
if params in ("", "1"):
|
|
return TuiKey(name)
|
|
if params == "1;2" and name in ("up", "down"):
|
|
return TuiKey(f"shift-{name}")
|
|
return None
|
|
|
|
|
|
def _escape(buffer: str) -> tuple[int, TuiKey | None]:
|
|
"""The sequence at the start of buffer: the characters it takes and its
|
|
key (None for one nobody bound), or 0 while more are needed to tell."""
|
|
if len(buffer) < 2:
|
|
return 0, None
|
|
if buffer[1] == "[":
|
|
if buffer[2:3] == "M":
|
|
# An X10 mouse report: three bytes of position follow.
|
|
return (6, None) if len(buffer) >= 6 else (0, None)
|
|
for end in range(2, len(buffer)):
|
|
code = ord(buffer[end])
|
|
if 0x40 <= code <= 0x7E:
|
|
return end + 1, _csi_key(buffer[2:end], buffer[end])
|
|
if not 0x20 <= code <= 0x3F:
|
|
return 1, TuiKey("esc")
|
|
return 0, None
|
|
if buffer[1] == "O":
|
|
if len(buffer) < 3:
|
|
return 0, None
|
|
name = _SS3_KEYS.get(buffer[2])
|
|
return 3, TuiKey(name) if name else None
|
|
return 1, TuiKey("esc")
|
|
|
|
|
|
class KeyDecoder:
|
|
"""Keys from what a terminal sends, however the reads split it.
|
|
|
|
Unknown sequences (F13, mouse reports) are dropped, never taken as text.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.buffer = ""
|
|
self.paste: str | None = None
|
|
|
|
def feed(self, text: str) -> list[TuiKey]:
|
|
self.buffer += text
|
|
return self._drain(final=False)
|
|
|
|
def flush(self) -> list[TuiKey]:
|
|
"""What is left once the wait for the rest of a sequence is over.
|
|
|
|
An open paste stays open: over SSH its end arrives later than 40 ms.
|
|
"""
|
|
return self._drain(final=True)
|
|
|
|
def close_paste(self) -> list[TuiKey]:
|
|
"""The paste so far, its end never seen, without its newlines."""
|
|
text = "".join(char for char in self.paste or "" if char >= " " and char != "\x7f")
|
|
self.paste = None
|
|
return [TuiKey("paste", text)] if text else []
|
|
|
|
@property
|
|
def waiting(self) -> bool:
|
|
return bool(self.buffer) or self.paste is not None
|
|
|
|
def _drain(self, final: bool) -> list[TuiKey]:
|
|
keys: list[TuiKey] = []
|
|
while self.buffer:
|
|
if self.paste is not None:
|
|
end = self.buffer.find(_PASTE_END)
|
|
if end < 0:
|
|
# The end marker may be cut between two reads.
|
|
keep = next((n for n in range(len(_PASTE_END) - 1, 0, -1)
|
|
if self.buffer.endswith(_PASTE_END[:n])), 0)
|
|
cut = len(self.buffer) - keep
|
|
self.paste += self.buffer[:cut]
|
|
self.buffer = self.buffer[cut:]
|
|
break
|
|
self.paste += self.buffer[:end]
|
|
self.buffer = self.buffer[end + len(_PASTE_END):]
|
|
keys += self.close_paste()
|
|
continue
|
|
if self.buffer[0] == "\x1b":
|
|
taken, key = _escape(self.buffer)
|
|
if not taken:
|
|
if final:
|
|
if self.buffer == "\x1b":
|
|
keys.append(TuiKey("esc"))
|
|
self.buffer = ""
|
|
break
|
|
self.buffer = self.buffer[taken:]
|
|
if key is not None and key.name == "paste-start":
|
|
self.paste = ""
|
|
elif key is not None:
|
|
keys.append(key)
|
|
continue
|
|
key = _plain_key(self.buffer[0])
|
|
self.buffer = self.buffer[1:]
|
|
if key is not None:
|
|
keys.append(key)
|
|
return keys
|
|
|
|
|
|
def decode_windows_key(first: str, read_next: Callable[[], str]) -> TuiKey | None:
|
|
"""A key from msvcrt.getwch: arrows and pages come as a prefix, then a code."""
|
|
if first in ("\x00", "\xe0"):
|
|
name = _WINDOWS_KEYS.get(read_next())
|
|
return TuiKey(name) if name else None
|
|
return _plain_key(first)
|
|
|
|
|
|
def _char_width(char: str) -> int:
|
|
if unicodedata.combining(char) or unicodedata.category(char) in ("Mn", "Me", "Cf"):
|
|
return 0
|
|
return 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
|
|
|
|
|
def cell_width(text: str) -> int:
|
|
"""Terminal cells the text takes: a CJK character two, an accent none."""
|
|
return sum(_char_width(char) for char in text)
|
|
|
|
|
|
def ascii_text(text: str) -> str:
|
|
"""The text in ASCII, accents dropped (Edition) and any other character
|
|
as one ? per cell it took, so the columns stay where they were."""
|
|
if text.isascii():
|
|
return text
|
|
plain = "".join(char for char in unicodedata.normalize("NFKD", text)
|
|
if not unicodedata.combining(char))
|
|
return "".join(char if char.isascii() else "?" * _char_width(char) for char in plain)
|
|
|
|
|
|
def fit(text: str, width: int) -> str:
|
|
"""The text cut to width cells, never through a character."""
|
|
text = screen_text(text)
|
|
kept: list[str] = []
|
|
used = 0
|
|
for char in text:
|
|
size = _char_width(char)
|
|
if used + size > width:
|
|
break
|
|
kept.append(char)
|
|
used += size
|
|
return "".join(kept)
|
|
|
|
|
|
def middle(text: str, width: int) -> str:
|
|
"""A long path cut in its middle, its start and its last folders kept."""
|
|
if cell_width(text) <= width:
|
|
return text
|
|
if width <= 3:
|
|
return fit(text, width)
|
|
room = width - 3
|
|
tail = fit(text[::-1], room // 2)[::-1]
|
|
return fit(text, room - cell_width(tail)) + "..." + tail
|
|
|
|
|
|
def _pad(text: str, width: int) -> str:
|
|
return text + " " * max(0, width - cell_width(text))
|
|
|
|
|
|
def _columns(left: str, right: str, width: int) -> str:
|
|
"""left, and right against the edge when both fit, else left alone."""
|
|
room = width - cell_width(right) - 2
|
|
if not right or room < 8:
|
|
return fit(left, width)
|
|
return _pad(fit(left, room), room) + " " + right
|
|
|
|
|
|
def _split_word(word: str, room: int) -> list[str]:
|
|
"""A word longer than room, cut after a slash where it can be: a path
|
|
read /home/us and er/bios when cut anywhere."""
|
|
pieces = []
|
|
while cell_width(word) > room:
|
|
head = fit(word, max(room, 1))
|
|
cut = head.rfind("/") + 1
|
|
if cut <= len(head) // 3:
|
|
cut = len(head)
|
|
pieces.append(word[:cut])
|
|
word = word[cut:]
|
|
return pieces + [word] if word else pieces
|
|
|
|
|
|
def _wrap(text: str, width: int, indent: str = " ") -> list[str]:
|
|
"""Lines of at most width cells, the ones after the first indented."""
|
|
if cell_width(text) <= width:
|
|
return [text]
|
|
lead = text[:len(text) - len(text.lstrip(" "))]
|
|
lines: list[str] = []
|
|
line = ""
|
|
for word in text.split():
|
|
for piece in _split_word(word, width - len(lead + indent)):
|
|
prefix = lead + indent if lines else lead
|
|
longer = f"{line} {piece}" if line else piece
|
|
if not line or cell_width(prefix + longer) <= width:
|
|
line = longer
|
|
else:
|
|
lines.append(prefix + line)
|
|
line = piece
|
|
lines.append((lead + indent if lines else lead) + line)
|
|
return lines
|
|
|
|
|
|
class TuiLine(str):
|
|
"""A screen line and how it is drawn: "title", "focus", "dim" or plain."""
|
|
|
|
style = ""
|
|
|
|
|
|
def _styled(text: str, style: str = "") -> TuiLine:
|
|
line = TuiLine(screen_text(text))
|
|
line.style = style
|
|
return line
|
|
|
|
|
|
def _short_path(path: Path | None, home: Path) -> str:
|
|
if path is None:
|
|
return ""
|
|
text = str(path)
|
|
home_text = str(home)
|
|
if home_text not in ("", "/", ".") and (text == home_text
|
|
or text.startswith(home_text + os.sep)):
|
|
return "~" + text[len(home_text):]
|
|
return text
|
|
|
|
|
|
def _folded(text: str) -> str:
|
|
"""Text compared without case or accents, as a filter reads it."""
|
|
decomposed = unicodedata.normalize("NFKD", text)
|
|
return "".join(char for char in decomposed if not unicodedata.combining(char)).casefold()
|
|
|
|
|
|
class TuiState:
|
|
"""Everything the screens show. Nothing here touches the terminal."""
|
|
|
|
def __init__(self, os_type: str = "linux", home: Path | None = None) -> None:
|
|
self.os_type = os_type
|
|
self.home = home or Path("/home/user")
|
|
# The buttons the bar names: a pad route (TUI_PAD_ROUTES), "keys"
|
|
# for a keyboard alone, "both" where the code cannot tell.
|
|
self.pad: str = "both"
|
|
self.stack: list = ["setup"]
|
|
self.focus: dict = {}
|
|
self.filters: dict = {}
|
|
self.editing: str = ""
|
|
self.help: bool = False
|
|
self.status: str = ""
|
|
self.cols: int = 80
|
|
self.rows: int = 24
|
|
self.now: float = 0.0
|
|
# (platform, folder, label) for each setup found here.
|
|
self.setups: list = []
|
|
self.notes: list = []
|
|
self.platform: str = ""
|
|
self.dest: Path | None = None
|
|
# A --dest given without --platform: the list asks whose files go there.
|
|
self.dest_given: Path | None = None
|
|
self.folder_default: Path | None = None
|
|
self.path_text: str = ""
|
|
self.browse_dir: Path | None = None
|
|
self.browse_rows: list = []
|
|
self.manifest: dict | None = None
|
|
self.manifest_for: str = ""
|
|
self.manifest_path: Path | None = None
|
|
self.manifest_given: bool = False
|
|
self.error: list = []
|
|
self.error_title: str = ""
|
|
self.error_code: int = 1
|
|
# Typed over in one key: a field opening on a long path took one
|
|
# Backspace per character before anything could be typed.
|
|
self.path_selected: bool = False
|
|
# The ticks kept across a check run again for new options.
|
|
self.keep_ticked: set | None = None
|
|
# (files read, files to read) while the check hashes what is in place.
|
|
self.reading: tuple | None = None
|
|
# Folders to copy BIOS files from (--from), and what the check found.
|
|
self.from_folders: list = []
|
|
self.found: list = []
|
|
# What the browser is choosing: the BIOS folder, or one to copy from.
|
|
self.browse_for: str = "dest"
|
|
# Files an install copies from this machine, whose bytes are no
|
|
# download: dest to (folder, reason), reason set for a misplaced one.
|
|
self.local_dests: dict = {}
|
|
# Per --from folder, its scanned event, and the files on those
|
|
# folders that could not be read (unreadable events).
|
|
self.scanned: list = []
|
|
self.unreadable: list = []
|
|
# The child run in progress: check, confirm, preview or install.
|
|
self.run: str = ""
|
|
self.running: bool = False
|
|
self.run_id: int = 0
|
|
self.started: float = 0.0
|
|
self.checked_flags: list = []
|
|
self.check: dict | None = None
|
|
self.files: list = []
|
|
self.systems: list = []
|
|
self.facts: dict = {}
|
|
self.dry: dict | None = None
|
|
self.dry_files: list = []
|
|
self.dry_summary: dict | None = None
|
|
# Closed by RETROBIOS_IDLE_EXIT, no key ever pressed.
|
|
self.idle_closed = False
|
|
# The check run's summary: what it left out and why.
|
|
self.check_summary: dict | None = None
|
|
# The confirm screen waits for A even when the plan is unchanged:
|
|
# it was opened by Space, which never installs by itself.
|
|
self.must_confirm: bool = False
|
|
# Choices, each one an option of the command line.
|
|
self.ticked: set = set()
|
|
self.cli_systems: list = []
|
|
self.regions: list = []
|
|
self.cli_regions: list = []
|
|
self.cores: list = []
|
|
self.no_game_data: bool = False
|
|
self.installed_only: bool = False
|
|
self.jobs: int = 8
|
|
self.passthrough: list = []
|
|
# The install.
|
|
self.download: dict | None = None
|
|
self.progress: dict | None = None
|
|
# (time, bytes from the network) of the last transfer events: what
|
|
# came from this machine (a copy, the pack, a partial file resumed)
|
|
# is never in a speed.
|
|
self.samples: list = []
|
|
self.network: int = 0
|
|
self.finished: list = []
|
|
self.summary: dict | None = None
|
|
self.code: int | None = None
|
|
self.stopping: bool = False
|
|
self.tail: list = []
|
|
self.log: str = ""
|
|
self.exit_code: int | None = None
|
|
|
|
@property
|
|
def screen(self) -> str:
|
|
return self.stack[-1]
|
|
|
|
|
|
def _env_on(env: Mapping[str, str], name: str) -> bool:
|
|
return env.get(name, "") not in ("", "0")
|
|
|
|
|
|
def plain_requested(args: argparse.Namespace, env: Mapping[str, str] | None = None) -> bool:
|
|
"""Whether the line questions were asked for: --plain, RETROBIOS_PLAIN, ACCESSIBLE."""
|
|
env = os.environ if env is None else env
|
|
return bool(args.plain) or _env_on(env, "RETROBIOS_PLAIN") or _env_on(env, "ACCESSIBLE")
|
|
|
|
|
|
# Each handheld launcher names the buttons that reach the interface
|
|
# (ports/shared/tui.txt exports RB_PAD as this), so the bar names those
|
|
# buttons and no others.
|
|
TUI_PAD_ENV = "RETROBIOS_PAD"
|
|
TUI_PAD_ROUTES = ("gptokeyb", "gptokeyb_muxterm", "muxterm", "evmapy", "miyoo")
|
|
# Set by Steam for what it starts, and by gamescope: a controller may be
|
|
# all there is, mapped by Steam Input to keys nobody here can name.
|
|
_STEAM_MARKERS = ("SteamGamepadUI", "SteamDeck", "SteamGameId", "GAMESCOPE_WAYLAND_DISPLAY")
|
|
|
|
|
|
# Seconds the interface waits for a first key, no run going, before it
|
|
# closes, set by a launcher whose mapper may never open the pad: the screen
|
|
# would otherwise hold a handheld that has no other way out.
|
|
TUI_IDLE_ENV = "RETROBIOS_IDLE_EXIT"
|
|
TUI_IDLE_WORDS = "No button pressed, closing."
|
|
|
|
|
|
def tui_idle_exit(env: Mapping[str, str] | None = None) -> float | None:
|
|
"""The seconds RETROBIOS_IDLE_EXIT names, None when unset or not a
|
|
positive number."""
|
|
env = os.environ if env is None else env
|
|
try:
|
|
seconds = float(env.get(TUI_IDLE_ENV, "").strip())
|
|
except ValueError:
|
|
return None
|
|
return seconds if 0 < seconds < float("inf") else None
|
|
|
|
|
|
def tui_pad_route(os_type: str, env: Mapping[str, str] | None = None) -> str:
|
|
"""The keys the hints name: a launcher's pad route, "keys" at a desktop,
|
|
"both" (button and key) where nothing says which one is held."""
|
|
env = os.environ if env is None else env
|
|
named = env.get(TUI_PAD_ENV, "").strip().lower()
|
|
if named:
|
|
return named if named in TUI_PAD_ROUTES else "both"
|
|
if any(env.get(name) for name in _STEAM_MARKERS):
|
|
return "both"
|
|
if os_type in ("windows", "darwin", "wsl"):
|
|
return "keys"
|
|
# A Linux console is where a handheld draws: a desktop or a remote
|
|
# shell is a keyboard's.
|
|
if os_type == "linux" and env.get("TERM") != "linux" and any(
|
|
env.get(name) for name in ("DISPLAY", "WAYLAND_DISPLAY", "SSH_CONNECTION",
|
|
"SSH_TTY")):
|
|
return "keys"
|
|
return "both"
|
|
|
|
|
|
def _terminal_size(stream: TextIO) -> tuple[int, int]:
|
|
try:
|
|
size = os.get_terminal_size(stream.fileno())
|
|
except (OSError, ValueError, AttributeError, io.UnsupportedOperation):
|
|
return 0, 0
|
|
return size.columns, size.lines
|
|
|
|
|
|
def tui_blocker(
|
|
args: argparse.Namespace, stdin: TextIO | None = None, stdout: TextIO | None = None,
|
|
env: Mapping[str, str] | None = None,
|
|
) -> str:
|
|
"""Why the full-screen interface cannot run here, "" when it can."""
|
|
env = os.environ if env is None else env
|
|
stdin = stdin or sys.stdin
|
|
stdout = stdout or sys.stdout
|
|
for flag, given in (
|
|
("--check", args.check), ("--dry-run", args.dry_run),
|
|
("--list-platforms", args.list_platforms), ("--list-targets", args.list_targets),
|
|
("--list-systems", args.list_systems), ("--list-cores", args.list_cores),
|
|
("--list-emulators", args.list_emulators), ("--events", args.events),
|
|
("--quiet", args.quiet), ("--no-input", args.no_input),
|
|
("--restore", args.restore is not None), ("--yes", args.yes),
|
|
):
|
|
if given:
|
|
return f"{flag} runs without questions"
|
|
for name, stream in (("input", stdin), ("output", stdout)):
|
|
isatty = getattr(stream, "isatty", None)
|
|
if not (isatty and isatty()):
|
|
return f"the {name} is not a terminal"
|
|
if os.name != "nt" and env.get("TERM", "") in ("", "dumb"):
|
|
return "TERM is dumb or not set"
|
|
# A pseudo-terminal nobody sized reports 0x0: script(1) fed from a pipe
|
|
# is one, and the line questions run there as they always have.
|
|
cols, rows = _terminal_size(stdout)
|
|
if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS:
|
|
return (f"the terminal is {cols}x{rows}, smaller than "
|
|
f"{TUI_MIN_COLS}x{TUI_MIN_ROWS}")
|
|
if os.name == "nt":
|
|
import importlib # noqa: PLC0415
|
|
|
|
try:
|
|
importlib.import_module("msvcrt")
|
|
except ImportError:
|
|
return "this Python has no msvcrt to read keys"
|
|
if not _enable_ansi():
|
|
return "this console does not draw escape sequences (Windows 10 or later does)"
|
|
return ""
|
|
try:
|
|
import termios # noqa: PLC0415
|
|
except ImportError:
|
|
return "this Python has no termios to read keys"
|
|
try:
|
|
termios.tcgetattr(stdin.fileno())
|
|
except (termios.error, OSError, ValueError, AttributeError,
|
|
io.UnsupportedOperation) as exc:
|
|
return f"single key presses cannot be read here ({exc})"
|
|
return ""
|
|
|
|
|
|
def tui_flags(state: TuiState) -> list[tuple[str, str | None]]:
|
|
"""The options that carry the choices made on screen, CLI ones included."""
|
|
flags = list(state.passthrough)
|
|
needed = {row["system"] for row in state.systems
|
|
if row["missing"] + row["wrong_hash"] > 0}
|
|
if state.systems and not needed <= state.ticked:
|
|
flags += [("--system", name) for name in sorted(state.ticked)]
|
|
else:
|
|
flags += [("--system", name) for name in state.cli_systems]
|
|
flags += [("--core", name) for name in state.cores]
|
|
if state.regions:
|
|
flags.append(("--region", ",".join(state.regions)))
|
|
if state.no_game_data:
|
|
flags.append(("--no-game-data", None))
|
|
if state.installed_only and not any(flag == "--cores-dir" for flag, _v in flags):
|
|
flags.append(("--installed-cores", None))
|
|
if state.jobs != 8:
|
|
flags.append(("--jobs", str(state.jobs)))
|
|
flags += [("--from", str(folder)) for folder in state.from_folders]
|
|
if offline_requested() and not any(flag == "--offline" for flag, _v in flags):
|
|
flags.append(("--offline", None))
|
|
return flags
|
|
|
|
|
|
_TUI_MODES = {
|
|
"check": ["--check", "--events"],
|
|
"confirm": ["--dry-run", "--events"],
|
|
"preview": ["--dry-run", "--events"],
|
|
"install": ["--events", "--yes"],
|
|
}
|
|
|
|
|
|
def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]:
|
|
"""The child that does what the screen decided."""
|
|
# --dest=VALUE: a folder typed as -x would otherwise be read as an option.
|
|
argv = [python, script, f"--platform={state.platform}", f"--dest={state.dest}"]
|
|
if state.manifest_path is not None:
|
|
argv.append(f"--manifest={state.manifest_path}")
|
|
argv += [flag if value is None else f"{flag}={value}" for flag, value in tui_flags(state)]
|
|
return argv + _TUI_MODES[run]
|
|
|
|
|
|
def tui_command(state: TuiState) -> str:
|
|
"""The same choice as one command, in the form the run was started with."""
|
|
hint = Invocation()
|
|
hint.os_type = state.os_type
|
|
hint.options = ([("--manifest", str(state.manifest_path))]
|
|
if state.manifest_given and state.manifest_path else [])
|
|
hint.options += tui_flags(state)
|
|
return hint.command(state.platform, state.dest)
|
|
|
|
class TuiRow(NamedTuple):
|
|
"""One row a screen lists: what Enter does on it, and what it shows."""
|
|
|
|
action: str
|
|
label: str
|
|
right: str = ""
|
|
value: object = None
|
|
style: str = ""
|
|
# Shorter forms of right, longest first, for a narrow screen: the label
|
|
# keeps its room before the right-hand text does.
|
|
narrow: tuple = ()
|
|
|
|
|
|
# Screens that scroll text instead of listing rows.
|
|
_PAGERS = ("preview", "command", "result", "misplaced", "unreadable")
|
|
_CHECKLISTS = ("systems", "cores")
|
|
|
|
|
|
def _needed(row: dict) -> int:
|
|
return row["missing"] + row["wrong_hash"]
|
|
|
|
|
|
def _tui_system_label(row: dict, titles: dict | None = None) -> str:
|
|
"""The name a person reads: the file list's title when it has one, the
|
|
platform's own name when it is one (Sony - PlayStation), the id with the
|
|
platform's short name otherwise (psx)."""
|
|
title = (titles or {}).get(row["system"])
|
|
if isinstance(title, str) and title.strip():
|
|
return title.strip()
|
|
if row["system"] == OTHER_SYSTEMS:
|
|
return OTHER_TITLE
|
|
name = row.get("name") or row["system"]
|
|
if name == row["system"]:
|
|
return name
|
|
if " " in name or any(char.isupper() for char in name):
|
|
return name
|
|
return f"{row['system']} ({name})"
|
|
|
|
|
|
# What the "other" row of the systems holds, in words (OTHER_SYSTEMS).
|
|
OTHER_TITLE = "Other systems and game engines"
|
|
OTHER_HELP = ("Other systems and game engines: what cores load for systems this setup "
|
|
"does not list, such as game engines and extra computers.")
|
|
|
|
|
|
def _matches(text: str, needle: str) -> bool:
|
|
return not needle or _folded(needle) in _folded(text)
|
|
|
|
|
|
def _filter_row(state: TuiState) -> TuiRow:
|
|
text = state.filters.get(state.screen, "")
|
|
if state.editing == state.screen:
|
|
return TuiRow("filter", f"Filter: {text}_")
|
|
if text:
|
|
return TuiRow("filter", f"Filter: {text}")
|
|
# A pad has no / and no letters: muxterm's own keyboard types.
|
|
how = {"muxterm": "Select shows a keyboard", "gptokeyb": "with a keyboard",
|
|
"gptokeyb_muxterm": "with a keyboard",
|
|
"evmapy": "with a keyboard", "miyoo": "with a keyboard"}.get(state.pad, "/ to type")
|
|
return TuiRow("filter", f"Filter ({how})", style="dim")
|
|
|
|
|
|
def _system_titles(state: TuiState) -> dict:
|
|
titles = (state.manifest or {}).get("system_titles")
|
|
return titles if isinstance(titles, dict) else {}
|
|
|
|
|
|
def _sorted_systems(state: TuiState) -> list[dict]:
|
|
"""Systems with something to download first, the ones in place last, each
|
|
group in the order of the names a person reads."""
|
|
titles = _system_titles(state)
|
|
return sorted(state.systems, key=lambda row: (
|
|
_needed(row) == 0, _tui_system_label(row, titles).casefold()))
|
|
|
|
|
|
# Region names as a person writes them, where title case alone is wrong.
|
|
_REGION_TITLES = {"asia-ntsc": "Asia (NTSC)", "asia-pal": "Asia (PAL)",
|
|
"uk": "United Kingdom", WORLD_REGION: "World"}
|
|
|
|
|
|
def region_title(name: str) -> str:
|
|
"""north-america as North America, asia-ntsc as Asia (NTSC)."""
|
|
return _REGION_TITLES.get(name) or " ".join(
|
|
word.capitalize() for word in name.split("-"))
|
|
|
|
|
|
def tui_choices(state: TuiState) -> dict[str, list[str]]:
|
|
"""What the file list lets one narrow to, read from the list itself."""
|
|
manifest = state.manifest or {}
|
|
return available_choices(manifest.get("files", []), manifest.get("omitted_files", []))
|
|
|
|
|
|
def tui_rows(state: TuiState) -> list[TuiRow]:
|
|
"""The rows of the current screen, in the order they are shown."""
|
|
screen = state.screen
|
|
if screen == "setup":
|
|
# The focused one's folder is spelled out below the list.
|
|
rows = [TuiRow("setup", label, value=index)
|
|
for index, (_plat, _path, label) in enumerate(state.setups)]
|
|
return rows + [TuiRow("another", "Another setup or folder")]
|
|
if screen == "platforms":
|
|
needle = state.filters.get(screen, "")
|
|
systems = _systems_reading()
|
|
return [_filter_row(state)] + [
|
|
TuiRow("platform", display_name(plat), ", ".join(systems.get(plat, [])), plat)
|
|
for plat in AVAILABLE_PLATFORMS
|
|
if _matches(" ".join([plat, display_name(plat), *systems.get(plat, [])]), needle)
|
|
]
|
|
if screen == "folder":
|
|
rows = [TuiRow("default", "Use this folder")] if state.folder_default else []
|
|
return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")]
|
|
if screen == "browse":
|
|
return list(state.browse_rows)
|
|
if screen == "create":
|
|
label = "Create the whole path" if state.facts.get("risky") else "Create it and continue"
|
|
return [TuiRow("create", label), TuiRow("other", "Choose another folder")]
|
|
if screen == "cardroot":
|
|
return [TuiRow("cardroot", "Use the card root"), TuiRow("other", "Choose another folder")]
|
|
if screen == "plan":
|
|
return _plan_rows(state)
|
|
if screen == "systems":
|
|
return _system_rows(state)
|
|
if screen == "options":
|
|
return _option_rows(state)
|
|
if screen == "regions":
|
|
ranked = [TuiRow("region", f"[{rank}] {region_title(name)}", value=name)
|
|
for rank, name in enumerate(state.regions, 1)]
|
|
known = sorted(set(tui_choices(state)["regions"]) | set(state.cli_regions),
|
|
key=region_title)
|
|
return ranked + [TuiRow("region", f"[ ] {region_title(name)}", value=name)
|
|
for name in known if name not in state.regions]
|
|
if screen == "cores":
|
|
needle = state.filters.get(screen, "")
|
|
return [_filter_row(state)] + [
|
|
TuiRow("core", f"[{'x' if name in state.cores else ' '}] {name}", value=name)
|
|
for name in tui_choices(state)["cores"] if _matches(name, needle)
|
|
]
|
|
if screen == "confirm":
|
|
if state.running or state.dry is None:
|
|
return []
|
|
return [TuiRow("install", "Install"), TuiRow("back", "Back")]
|
|
if screen == "error":
|
|
rows = ([TuiRow("retry", "Try again")]
|
|
if state.run == "check" and state.error_title
|
|
and state.error_title != TUI_OFFLINE_TITLE else [])
|
|
return rows + [TuiRow("back", "Choose another setup or folder" if state.run == "check"
|
|
else "Back"),
|
|
TuiRow("quit", "Quit")]
|
|
if screen == "quit":
|
|
return [TuiRow("quit", "Quit"), TuiRow("stay", "Stay")]
|
|
return []
|
|
|
|
|
|
def _plan_rows(state: TuiState) -> list[TuiRow]:
|
|
check = state.check or {}
|
|
if not (_needed(check) if check else 0) and not check.get("data_missing"):
|
|
return [TuiRow("close", "Close"), TuiRow("options", "Options")]
|
|
size = check.get("bytes_needed", 0)
|
|
label = f"Install {format_size(size)}" if size >= CONFIRM_FROM_BYTES else "Install"
|
|
rows = [TuiRow("install", label)]
|
|
if _offline_only() and not _plan_copies(state, check):
|
|
# Nothing here to copy, and the network refused: Install would
|
|
# only fail every file.
|
|
rows = []
|
|
if state.facts.get("writable") is False or _short_of_space(state, check):
|
|
# Every file would fail alike, or the run would stop before the
|
|
# first one: the way out is another folder.
|
|
rows = [TuiRow("folder", "Choose another folder"), TuiRow("install", "Install anyway"),
|
|
*rows[1:]]
|
|
if records_selection((state.manifest or {}).get("files", [])) and len(state.systems) > 1:
|
|
rows.append(TuiRow("systems", "Choose systems"))
|
|
rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder"))
|
|
if _tui_misplaced(state):
|
|
rows.append(TuiRow("misplaced", "Show the files in the wrong place"))
|
|
if state.unreadable:
|
|
rows.append(TuiRow("unreadable", "Show the files that could not be read"))
|
|
return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")]
|
|
|
|
|
|
def _offline_only() -> bool:
|
|
"""--offline without a pack: what is not on this machine cannot come."""
|
|
return offline_requested() and LOCAL.pack is None
|
|
|
|
|
|
def _plan_copies(state: TuiState, check: dict) -> list[str]:
|
|
"""The files to install that this machine holds, by destination."""
|
|
local = {event["dest"] for event in state.found} if check is state.check else set()
|
|
sizes = {event["dest"] for event in state.files}
|
|
return [dest for dest in local if dest in sizes]
|
|
|
|
|
|
def _short_of_space(state: TuiState, check: dict) -> bool:
|
|
free = state.facts.get("free")
|
|
return free is not None and check.get("bytes_needed", 0) > free
|
|
|
|
|
|
def _system_rows(state: TuiState) -> list[TuiRow]:
|
|
needle = state.filters.get("systems", "")
|
|
titles = _system_titles(state)
|
|
rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)]
|
|
shared = _shared_needed(state)
|
|
for row in _sorted_systems(state):
|
|
label = _tui_system_label(row, titles)
|
|
if not _matches(f"{label} {row['system']}", needle):
|
|
continue
|
|
box = "[x]" if row["system"] in state.ticked else "[ ]"
|
|
need = _needed(row)
|
|
count, size = shared.get(row["system"], (0, 0))
|
|
own, own_size = need - count, row["bytes_needed"] - size
|
|
narrow: tuple = ()
|
|
if not need:
|
|
right = "in place"
|
|
elif not count:
|
|
right = f"{_plural(need, 'file')}, {format_size(row['bytes_needed'])}"
|
|
narrow = (format_size(row["bytes_needed"]),)
|
|
elif not own:
|
|
right = f"{_plural(need, 'file')}, {format_size(size)}, all shared"
|
|
narrow = (f"{format_size(size)}, shared", format_size(size))
|
|
else:
|
|
right = f"{_plural(own, 'file')}, {format_size(own_size)} + {count} shared"
|
|
narrow = (f"{format_size(own_size)} + {count} shared",
|
|
f"{format_size(own_size)} + {count} sh.", format_size(own_size))
|
|
rows.append(TuiRow("system", f"{box} {label}", right, row["system"],
|
|
"" if need else "dim", narrow))
|
|
return rows
|
|
|
|
|
|
def _shared_needed(state: TuiState) -> dict[str, tuple[int, int]]:
|
|
"""Per system, the files to download that other systems carry too, and
|
|
their size: `3DO 1704 files, 349.0 MB` read as the 3DO BIOS, when 1689
|
|
are the MAME files six systems share. The line mode counts them apart
|
|
the same way (_system_sized)."""
|
|
shared: dict[str, tuple[int, int]] = {}
|
|
for event in state.files:
|
|
if len(event["systems"]) > 1:
|
|
for system in event["systems"]:
|
|
count, size = shared.get(system, (0, 0))
|
|
shared[system] = (count + 1, size + event["size"])
|
|
return shared
|
|
|
|
|
|
def _cores_dir_given(state: TuiState) -> bool:
|
|
return any(flag == "--cores-dir" for flag, _value in state.passthrough)
|
|
|
|
|
|
def _yes(value: bool) -> str:
|
|
return "yes" if value else "no"
|
|
|
|
|
|
def _option_rows(state: TuiState) -> list[TuiRow]:
|
|
manifest = state.manifest or {}
|
|
choices = tui_choices(state)
|
|
rows = []
|
|
if choices["regions"]:
|
|
rows.append(TuiRow("regions", "Preferred regions: "
|
|
+ (", ".join(map(region_title, state.regions)) or "any"),
|
|
"--region"))
|
|
if manifest.get("categorized"):
|
|
# "Leave out game data: no" was a double negative.
|
|
rows.append(TuiRow("game_data", "Game data: "
|
|
+ ("left out, firmware only" if state.no_game_data else "kept"),
|
|
"--no-game-data"))
|
|
found = len(state.facts.get("cores", []))
|
|
if _cores_dir_given(state):
|
|
rows.append(TuiRow("installed", "Installed cores only: yes, implied by --cores-dir",
|
|
"--cores-dir"))
|
|
elif state.platform in CORE_PLATFORMS and "system_cores" in manifest and found:
|
|
rows.append(TuiRow("installed", f"Installed cores only: "
|
|
f"{_yes(state.installed_only)} ({found} found)",
|
|
"--installed-cores"))
|
|
if choices["cores"]:
|
|
chosen = f"{len(state.cores)} chosen" if state.cores else "all"
|
|
rows.append(TuiRow("cores", f"Cores: {chosen}", "--core"))
|
|
copied = ", ".join(str(folder) for folder in state.from_folders) or "none"
|
|
rows += [
|
|
TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"),
|
|
TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest",
|
|
style="path"),
|
|
TuiRow("from", f"Copy from: {copied}", "--from", style="path"),
|
|
TuiRow("preview", "Preview the file list", "--dry-run"),
|
|
TuiRow("command", "Show the command line"),
|
|
]
|
|
return rows
|
|
|
|
|
|
def _push(state: TuiState, screen: str, focus: int = 0) -> list[tuple]:
|
|
state.stack.append(screen)
|
|
state.focus[screen] = focus
|
|
return []
|
|
|
|
|
|
def _replace(state: TuiState, screen: str) -> None:
|
|
state.stack[-1] = screen
|
|
state.focus[screen] = 0
|
|
|
|
|
|
def _back(state: TuiState) -> list[tuple]:
|
|
effects = [("kill",)] if state.running else []
|
|
if state.running:
|
|
# What the stopped run still reports belongs to no screen.
|
|
state.running = False
|
|
state.run_id += 1
|
|
if len(state.stack) == 1:
|
|
state.stack.append("quit")
|
|
state.focus["quit"] = 0
|
|
return effects
|
|
state.stack.pop()
|
|
if state.screen == "checking" and not state.running:
|
|
# Stay after Esc on a check started from the command line: a
|
|
# Checking screen nothing feeds counted seconds forever.
|
|
return effects + _start_check(state)
|
|
if state.screen == "plan" and _counts_predate_options(state):
|
|
# The plan's numbers were read under the options before: they are
|
|
# read again, the systems ticked kept.
|
|
needed = {row["system"] for row in state.systems if _needed(row)}
|
|
state.keep_ticked = None if needed <= state.ticked else set(state.ticked)
|
|
return effects + _start_check(state)
|
|
return effects
|
|
|
|
|
|
def tui_quit(state: TuiState, code: int = 130) -> list[tuple]:
|
|
"""Leave now: with the install's own code once it ran, else 130."""
|
|
if state.screen == "result":
|
|
code = state.code if state.code is not None else 1
|
|
elif state.screen == "error":
|
|
code = state.error_code
|
|
state.exit_code = code
|
|
return [("kill",)] if state.running else []
|
|
|
|
|
|
def tui_interrupt(state: TuiState) -> list[tuple]:
|
|
"""Ctrl-C: an install stops and says what it did, anything else ends."""
|
|
if state.run == "install" and state.running:
|
|
if state.stopping:
|
|
return [("kill",)]
|
|
state.stopping = True
|
|
return [("stop",)]
|
|
return tui_quit(state)
|
|
|
|
|
|
def _start_run(state: TuiState, run: str) -> list[tuple]:
|
|
state.run = run
|
|
state.running = True
|
|
state.run_id += 1
|
|
state.started = state.now
|
|
if run == "check":
|
|
# The systems of an earlier check go first: ticks read against them
|
|
# dropped the --system the command line gave.
|
|
state.check, state.files, state.systems, state.reading = None, [], [], None
|
|
state.found, state.scanned, state.unreadable = [], [], []
|
|
state.check_summary = None
|
|
state.checked_flags = tui_flags(state)
|
|
elif run in ("confirm", "preview"):
|
|
state.dry, state.dry_files, state.dry_summary = None, [], None
|
|
else:
|
|
state.download = state.progress = state.reading = None
|
|
state.samples, state.local_dests, state.network = [], {}, 0
|
|
state.finished, state.summary, state.code, state.stopping = [], None, None, False
|
|
return [("run", run)]
|
|
|
|
|
|
def _start_check(state: TuiState) -> list[tuple]:
|
|
"""Check the chosen folder, after fetching the file list once."""
|
|
if "plan" in state.stack:
|
|
del state.stack[state.stack.index("plan"):]
|
|
while state.stack and state.stack[-1] in ("checking", "error", "create"):
|
|
state.stack.pop()
|
|
state.stack.append("checking")
|
|
state.facts, state.ticked = {}, set()
|
|
if state.manifest is not None and state.manifest_for == state.platform:
|
|
return _start_run(state, "check")
|
|
state.manifest = None
|
|
state.run, state.run_id = "check", state.run_id + 1
|
|
state.started = state.now
|
|
return [("fetch", state.platform)]
|
|
|
|
|
|
def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]:
|
|
card = card_system(dest)
|
|
if card and card[0] == plat and card[1] != dest:
|
|
# A muOS or KNULLI card's root: that system reads a folder below it.
|
|
dest = card[1]
|
|
state.platform, state.dest = plat, dest
|
|
if plat in CARD_PLATFORMS and card_platform(dest) is None:
|
|
# The engine refuses a folder inside the card, the list writing
|
|
# Bios/<TAG>/ below the root: the root is offered instead.
|
|
for parent in list(dest.absolute().parents)[:2]:
|
|
holder = card_platform(parent)
|
|
if holder:
|
|
state.facts = {"card_root": parent, "card": holder}
|
|
return _push(state, "cardroot")
|
|
if not dest.exists():
|
|
nearest = _nearest_folder(dest)
|
|
missing = len(dest.absolute().parts) - len(nearest.parts)
|
|
state.facts = {"nearest": nearest, "risky": nearest.parent == nearest or missing > 2}
|
|
# A path whose start was mistyped would be created from the root:
|
|
# the safe answer is the one under A.
|
|
return _push(state, "create", 1 if state.facts["risky"] else 0)
|
|
return _start_check(state)
|
|
|
|
|
|
def _open_folder_screen(state: TuiState, default: Path | None) -> list[tuple]:
|
|
state.folder_default = default
|
|
return _push(state, "folder")
|
|
|
|
|
|
def _mount_name(mount: Path) -> str:
|
|
"""How a person names a mounted card: E:\\ on Windows, its label elsewhere."""
|
|
return mount.name or str(mount)
|
|
|
|
|
|
def _mounted_cards() -> list[Path]:
|
|
"""Mount points of removable media, where a card or a share appears.
|
|
|
|
A handheld's card is most often prepared on a PC with a card reader: on
|
|
Windows every drive but the system one may be that card.
|
|
"""
|
|
if os.name == "nt":
|
|
system = os.environ.get("SystemDrive", "C:").upper()
|
|
return [Path(f"{letter}:\\") for letter in "DEFGHIJKLMNOPQRSTUVWXYZ"
|
|
if f"{letter}:" != system and os.path.exists(f"{letter}:\\")]
|
|
found: list[Path] = []
|
|
for root in (Path("/media"), Path("/run/media"), Path("/mnt"), Path("/Volumes")):
|
|
try:
|
|
children = sorted(root.iterdir())
|
|
except OSError:
|
|
continue
|
|
for child in children:
|
|
# WSL's own mounts, and the Windows system drive, hold no card.
|
|
if child.name in ("wsl", "wslg") or (root.name == "mnt" and child.name == "c"):
|
|
continue
|
|
if os.path.ismount(child):
|
|
found.append(child)
|
|
elif root.name in ("media", "run") or root == Path("/run/media"):
|
|
# /media/<user>/<label> and /run/media/<user>/<label>.
|
|
try:
|
|
found += [g for g in sorted(child.iterdir()) if os.path.ismount(g)]
|
|
except OSError:
|
|
continue
|
|
return found
|
|
|
|
|
|
def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], str]:
|
|
"""A directory's rows for the browser, cards first, and what went wrong.
|
|
With zips, the ZIP files too: a backup is often one archive."""
|
|
if folder is None:
|
|
drives = [Path(f"{letter}:\\") for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
if os.path.exists(f"{letter}:\\")]
|
|
return [TuiRow("dir", str(drive), value=drive) for drive in drives], ""
|
|
rows = [TuiRow("use", "Use this folder"), TuiRow("up", "..", value=folder.parent)]
|
|
for card in _mounted_cards():
|
|
if card != folder:
|
|
system = card_system(card)
|
|
what = f"{system[2]} card" if system else "card or drive"
|
|
rows.append(TuiRow("dir", f"{card} ({what})", value=card))
|
|
try:
|
|
entries = list(os.scandir(folder))
|
|
except OSError as exc:
|
|
return rows, f"Cannot open this folder: {exc.strerror or exc}"
|
|
entries.sort(key=lambda entry: (entry.name.startswith("."), entry.name.casefold()))
|
|
rows += [TuiRow("dir", f"{entry.name}/", value=Path(entry.path))
|
|
for entry in entries if entry.is_dir()]
|
|
if zips:
|
|
rows += [TuiRow("zip", entry.name, value=Path(entry.path)) for entry in entries
|
|
if entry.is_file() and entry.name.lower().endswith(".zip")]
|
|
return rows, ""
|
|
|
|
|
|
def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = None) -> None:
|
|
state.browse_dir = folder
|
|
state.browse_rows, state.status = _browse_rows(folder, state.browse_for == "from")
|
|
focus = 0
|
|
for index, row in enumerate(state.browse_rows):
|
|
if came_from is not None and row.action == "dir" and row.value == came_from:
|
|
focus = index
|
|
state.focus["browse"] = focus
|
|
|
|
|
|
def _nearest_folder(path: Path) -> Path:
|
|
probe = path.absolute()
|
|
while not probe.is_dir() and probe.parent != probe:
|
|
probe = probe.parent
|
|
return probe
|
|
|
|
|
|
def typed_path(text: str) -> Path | None:
|
|
"""A folder typed or pasted: quotes off, ~ and variables expanded."""
|
|
# Explorer's "Copy as path" quotes both ends, a half-selected copy one.
|
|
text = text.strip().strip("\"'").strip()
|
|
if not text:
|
|
return None
|
|
return Path(os.path.expanduser(os.path.expandvars(text)))
|
|
|
|
|
|
def _tui_folder_facts(state: TuiState) -> dict:
|
|
"""Free space, whether the folder takes files, and the cores found."""
|
|
probe = _nearest_folder(state.dest)
|
|
try:
|
|
free: int | None = shutil.disk_usage(probe).free
|
|
except OSError:
|
|
free = None
|
|
cores = (installed_cores(core_dirs(state.platform, state.dest))
|
|
if state.platform in CORE_PLATFORMS else [])
|
|
return {"free": free, "writable": _folder_writable(state.dest), "cores": cores}
|
|
|
|
|
|
def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
|
|
screen, action = state.screen, row.action
|
|
if action == "filter":
|
|
state.editing = screen
|
|
return []
|
|
if screen == "setup":
|
|
if action == "another":
|
|
return _push(state, "platforms", _platform_focus(state, "retroarch"))
|
|
plat, path, _label = state.setups[row.value]
|
|
return _choose(state, plat, path)
|
|
if screen == "platforms":
|
|
if state.dest_given is not None:
|
|
return _choose(state, row.value, state.dest_given)
|
|
state.platform = row.value
|
|
default = None if row.value in CARD_PLATFORMS else _default_dest(state.os_type, row.value)
|
|
return _open_folder_screen(state, default)
|
|
if screen == "folder":
|
|
if action == "default":
|
|
return _choose(state, state.platform, state.folder_default)
|
|
if action == "browse":
|
|
state.browse_for = "dest"
|
|
_push(state, "browse")
|
|
_browse_to(state, _nearest_folder(state.folder_default or state.home))
|
|
return []
|
|
state.path_text = str(state.folder_default or "")
|
|
state.path_selected = bool(state.path_text)
|
|
return _push(state, "path")
|
|
if screen == "browse":
|
|
if action in ("use", "zip") and state.browse_for == "from":
|
|
chosen = state.browse_dir if action == "use" else row.value
|
|
if chosen not in state.from_folders:
|
|
state.from_folders.append(chosen)
|
|
state.browse_for = "dest"
|
|
# The check runs again with it: the plan then says what it holds.
|
|
return _start_check(state)
|
|
if action == "use":
|
|
return _choose(state, state.platform, state.browse_dir)
|
|
if action == "up" and state.browse_dir is not None:
|
|
if row.value == state.browse_dir:
|
|
_browse_to(state, None if os.name == "nt" else state.browse_dir)
|
|
else:
|
|
_browse_to(state, row.value, came_from=state.browse_dir)
|
|
return []
|
|
_browse_to(state, row.value)
|
|
return []
|
|
if screen == "create":
|
|
if action == "create":
|
|
state.stack.pop()
|
|
return _start_check(state)
|
|
return _back(state)
|
|
if screen == "cardroot":
|
|
if action == "cardroot":
|
|
state.stack.pop()
|
|
return _choose(state, state.platform, state.facts["card_root"])
|
|
return _back(state)
|
|
if screen == "plan":
|
|
if action == "install":
|
|
return _begin_install(state)
|
|
if action == "close":
|
|
state.exit_code = 0
|
|
return []
|
|
if action == "quit":
|
|
return tui_quit(state)
|
|
if action == "folder":
|
|
return _open_folder_screen(state, state.dest)
|
|
if action == "fromcard":
|
|
return _open_from_browser(state)
|
|
if action in ("misplaced", "unreadable"):
|
|
return _push(state, action)
|
|
# On the first system, so A continues and the D-pad ticks.
|
|
return _push(state, action, 3 if action == "systems" else 0)
|
|
if screen == "systems":
|
|
if action == "all":
|
|
state.ticked = {row["system"] for row in state.systems}
|
|
elif action == "none":
|
|
state.ticked = set()
|
|
else:
|
|
return _begin_install(state)
|
|
return []
|
|
if screen == "options":
|
|
return _option(state, action, 0)
|
|
if screen == "regions":
|
|
_toggle_region(state, row.value)
|
|
return []
|
|
if screen == "cores":
|
|
return _back(state)
|
|
if screen == "confirm":
|
|
if action == "install":
|
|
state.stack.pop()
|
|
return _install_now(state)
|
|
return _back(state)
|
|
if screen == "error":
|
|
if action == "retry":
|
|
return _start_check(state)
|
|
if action == "quit":
|
|
return tui_quit(state, state.error_code)
|
|
return _back(state)
|
|
if screen == "quit":
|
|
if action == "quit":
|
|
return tui_quit(state)
|
|
return _back(state)
|
|
return []
|
|
|
|
|
|
def _systems_reading() -> dict[str, list[str]]:
|
|
"""The device systems that get each platform's files, as detection knows
|
|
them: someone on muOS or ArkOS looks for that name, not RetroArch."""
|
|
names: dict[str, list[str]] = {}
|
|
for rule in _EMBEDDED_RULES:
|
|
if (rule.manifest and rule.name != display_name(rule.manifest)
|
|
and rule.name not in names.setdefault(rule.manifest, [])):
|
|
names[rule.manifest].append(rule.name)
|
|
return names
|
|
|
|
|
|
def _platform_focus(state: TuiState, plat: str) -> int:
|
|
"""The platform list's row for plat, below its filter row."""
|
|
return 1 + AVAILABLE_PLATFORMS.index(plat) if plat in AVAILABLE_PLATFORMS else 1
|
|
|
|
|
|
def _option(state: TuiState, action: str, step: int) -> list[tuple]:
|
|
"""A row of the options: step 0 is Enter, -1 and 1 are Left and Right."""
|
|
if action == "game_data":
|
|
state.no_game_data = step > 0 if step else not state.no_game_data
|
|
elif action == "installed" and not _cores_dir_given(state):
|
|
state.installed_only = step > 0 if step else not state.installed_only
|
|
elif action == "jobs":
|
|
state.jobs = max(1, min(32, state.jobs + (step or 1)))
|
|
elif action == "cores":
|
|
# On the first core: the filter row under A opened a field a
|
|
# controller cannot type into.
|
|
return _push(state, action, 1 if tui_choices(state)["cores"] else 0)
|
|
elif action in ("regions", "command"):
|
|
return _push(state, action)
|
|
elif action == "folder":
|
|
return _open_folder_screen(state, state.dest)
|
|
elif action == "from" and step < 0:
|
|
# Said, and the screen stays: Back to the plan counts again.
|
|
if state.from_folders:
|
|
removed = state.from_folders.pop()
|
|
state.status = f"No longer copying from {_last_name(str(removed))}."
|
|
elif action == "from":
|
|
return _open_from_browser(state)
|
|
elif action == "preview":
|
|
_push(state, "preview")
|
|
return _start_run(state, "preview")
|
|
return []
|
|
|
|
|
|
def _open_from_browser(state: TuiState) -> list[tuple]:
|
|
"""The browser, to name an old card, a backup or a ZIP to copy from."""
|
|
state.browse_for = "from"
|
|
_push(state, "browse")
|
|
start = state.from_folders[-1].parent if state.from_folders else state.home
|
|
_browse_to(state, _nearest_folder(start))
|
|
return []
|
|
|
|
|
|
def _toggle_region(state: TuiState, name: str) -> None:
|
|
if name in state.regions:
|
|
state.regions.remove(name)
|
|
else:
|
|
state.regions.append(name)
|
|
rows = tui_rows(state)
|
|
state.focus["regions"] = next((i for i, row in enumerate(rows) if row.value == name), 0)
|
|
|
|
|
|
def _move_region(state: TuiState, name: str, step: int) -> None:
|
|
if name not in state.regions:
|
|
return
|
|
index = state.regions.index(name)
|
|
target = max(0, min(len(state.regions) - 1, index + step))
|
|
state.regions.insert(target, state.regions.pop(index))
|
|
state.focus["regions"] = target
|
|
|
|
|
|
def _begin_install(state: TuiState) -> list[tuple]:
|
|
if state.systems and not state.ticked:
|
|
state.status = "No system is ticked: tick one, or choose All."
|
|
return []
|
|
state.must_confirm = False
|
|
if tui_flags(state) != state.checked_flags:
|
|
_push(state, "confirm")
|
|
return _start_run(state, "confirm")
|
|
return _install_now(state)
|
|
|
|
|
|
def _install_now(state: TuiState) -> list[tuple]:
|
|
_push(state, "installing")
|
|
return _start_run(state, "install")
|
|
|
|
|
|
def _same_plan(one: dict | None, other: dict | None) -> bool:
|
|
keys = ("missing", "wrong_hash", "bytes_needed", "data_missing")
|
|
return bool(one and other) and all(one[k] == other[k] for k in keys)
|
|
|
|
|
|
def _fail(state: TuiState, lines: list[str], code: int) -> list[tuple]:
|
|
state.error_title = ""
|
|
state.error = [line for line in lines if line.strip()] or [
|
|
f"The installer stopped with code {code} and said nothing."]
|
|
state.error_code = code or 1
|
|
state.running = False
|
|
_replace(state, "error")
|
|
return []
|
|
|
|
|
|
def _tick(state: TuiState, row: TuiRow, on: bool | None) -> None:
|
|
if state.screen == "systems" and row.action == "system":
|
|
chosen = state.ticked
|
|
elif state.screen == "cores" and row.action == "core":
|
|
chosen = set(state.cores)
|
|
else:
|
|
return
|
|
value = row.value
|
|
want = (value not in chosen) if on is None else on
|
|
if state.screen == "systems":
|
|
if want:
|
|
state.ticked.add(value)
|
|
else:
|
|
state.ticked.discard(value)
|
|
elif want and value not in state.cores:
|
|
state.cores.append(value)
|
|
elif not want and value in state.cores:
|
|
state.cores.remove(value)
|
|
|
|
|
|
def _edit_text(text: str, key: TuiKey, selected: bool = False) -> str | None:
|
|
"""The field after a key, None when the key is not an edit. A selected
|
|
text is replaced by what is typed or pasted, and cleared by Backspace."""
|
|
if key.name in ("char", "paste"):
|
|
return key.text if selected else text + key.text
|
|
if key.name in ("backspace", "delete"):
|
|
return "" if selected else text[:-1]
|
|
if key.name == "ctrl-u":
|
|
return ""
|
|
return None
|
|
|
|
|
|
def _filter_key(state: TuiState, key: TuiKey) -> list[tuple]:
|
|
"""Typing in a list's filter. It never ticks or unticks anything, and
|
|
focus never lands on All or None: the next A or X there undid a choice."""
|
|
screen = state.screen
|
|
edited = _edit_text(state.filters.get(screen, ""), key)
|
|
rows = tui_rows(state)
|
|
filter_row = next((i for i, row in enumerate(rows) if row.action == "filter"), 0)
|
|
if edited is not None:
|
|
state.filters[screen] = edited
|
|
state.focus[screen] = filter_row
|
|
return []
|
|
if key.name == "esc":
|
|
state.filters[screen] = ""
|
|
state.editing = ""
|
|
rows = tui_rows(state)
|
|
if key.name in ("enter", "down") and len(rows) > filter_row + 1:
|
|
state.focus[screen] = filter_row + 1
|
|
else:
|
|
state.focus[screen] = filter_row
|
|
return []
|
|
|
|
|
|
def _path_key(state: TuiState, key: TuiKey) -> list[tuple]:
|
|
edited = _edit_text(state.path_text, key, state.path_selected)
|
|
if edited is not None:
|
|
state.path_text, state.path_selected = edited, False
|
|
return []
|
|
if key.name == "esc":
|
|
return _back(state)
|
|
if key.name == "enter":
|
|
path = typed_path(state.path_text)
|
|
if path is None:
|
|
state.status = "Type a folder first."
|
|
return []
|
|
state.stack.pop()
|
|
return _choose(state, state.platform, path)
|
|
return []
|
|
|
|
|
|
def _pager_key(state: TuiState, key: TuiKey) -> list[tuple]:
|
|
screen = state.screen
|
|
top = state.focus.get(screen, 0)
|
|
page = max(1, state.rows - 5)
|
|
name, char = key.name, key.text if key.name == "char" else ""
|
|
if name == "up" or char == "k":
|
|
top -= 1
|
|
elif name == "down" or char == "j":
|
|
top += 1
|
|
elif name == "pgup":
|
|
top -= page
|
|
elif name == "pgdn":
|
|
top += page
|
|
elif name == "home" or char == "g":
|
|
top = 0
|
|
elif name == "end" or char == "G":
|
|
top = 1 << 30
|
|
elif name in ("enter", "esc", "backspace") or char == " ":
|
|
if screen == "result":
|
|
return tui_quit(state)
|
|
return _back(state)
|
|
state.focus[screen] = max(0, min(top, _pager_last(state, _pager_items(state))))
|
|
return []
|
|
|
|
|
|
def _pager_last(state: TuiState, items: list) -> int:
|
|
"""The first item shown when the end of a pager is in view, found from
|
|
the end: only the last screenful is wrapped."""
|
|
width = min(state.cols, TUI_MAX_WIDTH) - 2
|
|
room = max(1, state.rows - 4)
|
|
used, index = 0, len(items)
|
|
while index > 0:
|
|
size = len(_text_lines([items[index - 1]], width))
|
|
if used + size > room:
|
|
break
|
|
used += size
|
|
index -= 1
|
|
return index
|
|
|
|
|
|
def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
|
|
"""What a key does on the current screen, and the work it starts."""
|
|
if key.name in ("ctrl-c", "ctrl-d"):
|
|
return tui_interrupt(state)
|
|
if state.cols < TUI_MIN_COLS or state.rows < TUI_MIN_ROWS:
|
|
return []
|
|
if state.help:
|
|
state.help = False
|
|
return []
|
|
state.status = ""
|
|
screen = state.screen
|
|
if screen == "path":
|
|
return _path_key(state, key)
|
|
if state.editing:
|
|
return _filter_key(state, key)
|
|
if state.pad == "miyoo":
|
|
key = _miyoo_key(state, key)
|
|
char = key.text if key.name == "char" else ""
|
|
if key.name == "f1" or char == "?":
|
|
state.help = True
|
|
return []
|
|
if screen == "checking":
|
|
if char == "q":
|
|
return tui_quit(state)
|
|
return _back(state) if key.name in ("esc", "backspace") else []
|
|
if screen == "installing":
|
|
# B, Esc and q are the stops a controller or a plain keyboard has.
|
|
if key.name in ("esc", "backspace") or char == "q":
|
|
return tui_interrupt(state)
|
|
return []
|
|
if char == "q":
|
|
return tui_quit(state)
|
|
if screen in _PAGERS:
|
|
return _pager_key(state, key)
|
|
rows = tui_rows(state)
|
|
if not rows:
|
|
return _back(state) if key.name in ("esc", "backspace") else []
|
|
focus = max(0, min(state.focus.get(screen, 0), len(rows) - 1))
|
|
row = rows[focus]
|
|
last = len(rows) - 1
|
|
page = max(1, state.rows - 6)
|
|
wrap = len(rows) < 10
|
|
name = key.name
|
|
if name == "up" or char == "k":
|
|
focus = last if wrap and focus == 0 else max(0, focus - 1)
|
|
elif name == "down" or char == "j":
|
|
focus = 0 if wrap and focus == last else min(last, focus + 1)
|
|
elif name == "pgup":
|
|
focus = max(0, focus - page)
|
|
elif name == "pgdn":
|
|
focus = min(last, focus + page)
|
|
elif name == "home" or char == "g":
|
|
focus = 0
|
|
elif name == "end" or char == "G":
|
|
focus = last
|
|
elif name in ("esc", "backspace") and state.filters.get(screen):
|
|
# The filter goes first, the screen with the second press, and the
|
|
# row in focus stays in focus.
|
|
state.filters[screen] = ""
|
|
rows = tui_rows(state)
|
|
focus = next((i for i, item in enumerate(rows)
|
|
if item.action == row.action and item.value == row.value), focus)
|
|
elif name in ("esc", "backspace"):
|
|
return _back(state)
|
|
elif (name == "tab" or char == "o") and screen in ("plan", "systems", "confirm"):
|
|
return _push(state, "options")
|
|
elif char == "/" and any(item.action == "filter" for item in rows):
|
|
state.editing = screen
|
|
return []
|
|
elif screen == "regions" and (name == "enter" or char == " "):
|
|
return _activate(state, row)
|
|
elif screen == "regions" and name in ("left", "shift-up"):
|
|
_move_region(state, row.value, -1)
|
|
return []
|
|
elif screen == "regions" and name in ("right", "shift-down"):
|
|
_move_region(state, row.value, 1)
|
|
return []
|
|
elif screen in _CHECKLISTS and row.action in ("system", "core") and (
|
|
char == " " or name in ("right", "left")):
|
|
_tick(state, row, None if char == " " else name == "right")
|
|
return []
|
|
elif screen in _CHECKLISTS and char == " ":
|
|
# X on All, None or the filter: only A, an explicit choice, acts there.
|
|
return []
|
|
elif screen == "systems" and char in ("a", "n"):
|
|
return _activate(state, rows[0 if char == "a" else 1])
|
|
elif screen == "options" and name in ("left", "right"):
|
|
return _option(state, row.action, 1 if name == "right" else -1) \
|
|
if row.action in ("game_data", "installed", "jobs") \
|
|
or (row.action == "from" and name == "left") else []
|
|
elif char == " ":
|
|
return _space(state, row)
|
|
elif name == "enter":
|
|
return _activate(state, row)
|
|
state.focus[screen] = focus
|
|
return []
|
|
|
|
|
|
# A Miyoo's L1 and R1 are the keys E and T (codes 18 and 20).
|
|
_MIYOO_PAGES = {"e": "pgup", "t": "pgdn"}
|
|
|
|
|
|
def _miyoo_key(state: TuiState, key: TuiKey) -> TuiKey:
|
|
"""A Miyoo's buttons as the keys they stand for, outside a text field:
|
|
its A is Space, the button that chooses, so it acts as Enter but where
|
|
it ticks a system or a core."""
|
|
if key.name != "char":
|
|
return key
|
|
if key.text in _MIYOO_PAGES:
|
|
return TuiKey(_MIYOO_PAGES[key.text])
|
|
if key.text != " ":
|
|
return key
|
|
if state.screen in _CHECKLISTS:
|
|
rows = tui_rows(state)
|
|
row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] \
|
|
if rows else None
|
|
if row is not None and row.action in ("system", "core"):
|
|
return key
|
|
return TuiKey("enter")
|
|
|
|
|
|
# Rows Space leaves alone: it is X (tick) on PortMaster and Y on muxterm, a
|
|
# key pressed for something else, and these end the run or write.
|
|
_SPACE_INERT = ("install", "close", "quit")
|
|
|
|
|
|
def _space(state: TuiState, row: TuiRow) -> list[tuple]:
|
|
"""Space opens what a row leads to, and never installs by itself: on
|
|
the plan's Install it opens the confirm screen, where A installs."""
|
|
if row.action == "install" and state.screen == "plan":
|
|
if state.systems and not state.ticked:
|
|
state.status = "No system is ticked: tick one, or choose All."
|
|
return []
|
|
state.must_confirm = True
|
|
_push(state, "confirm")
|
|
return _start_run(state, "confirm")
|
|
if row.action in _SPACE_INERT:
|
|
return []
|
|
return _activate(state, row)
|
|
|
|
|
|
def _take_event(state: TuiState, event: dict) -> None:
|
|
kind = event.get("event")
|
|
if kind == "checking" and state.run in ("check", "install"):
|
|
state.reading = (event["files_done"], event["files_total"])
|
|
elif kind == "found" and state.run == "check":
|
|
state.found.append(event)
|
|
elif kind == "scanned" and state.run == "check":
|
|
state.scanned.append(event)
|
|
elif kind == "unreadable" and state.run == "check":
|
|
state.unreadable.append(event)
|
|
elif kind == "found" and state.run == "install":
|
|
state.local_dests[event["dest"]] = (event["folder"], event["reason"])
|
|
elif state.run == "check":
|
|
if kind == "check":
|
|
state.check = event
|
|
elif kind == "file" and event.get("state") != "present":
|
|
state.files.append(event)
|
|
elif kind == "system":
|
|
state.systems.append(event)
|
|
elif kind == "summary":
|
|
state.check_summary = event
|
|
elif state.run in ("confirm", "preview"):
|
|
if kind == "check":
|
|
state.dry = event
|
|
elif kind == "file" and event.get("state") != "present":
|
|
state.dry_files.append(event)
|
|
elif kind == "summary":
|
|
state.dry_summary = event
|
|
elif state.run == "install":
|
|
if kind == "download":
|
|
state.download = event
|
|
elif kind == "transfer":
|
|
state.network = event["bytes_network"]
|
|
state.samples = [sample for sample in state.samples
|
|
if sample[0] >= state.now - 10] + [(state.now, state.network)]
|
|
elif kind in ("progress", "downloaded"):
|
|
state.progress = event
|
|
if kind == "downloaded":
|
|
state.finished.append((event["dest"], event["ok"], event["reason"]))
|
|
elif kind == "summary":
|
|
state.summary = event
|
|
|
|
|
|
def tui_receive(state: TuiState, message: tuple) -> list[tuple]:
|
|
"""What the file list fetch and the child runs report back."""
|
|
kind = message[0]
|
|
if kind == "manifest":
|
|
_kind, plat, manifest = message
|
|
if plat != state.platform or state.screen != "checking":
|
|
return []
|
|
state.manifest, state.manifest_for = manifest, plat
|
|
return _start_run(state, "check")
|
|
if kind == "manifest_failed":
|
|
_kind, plat, title, lines, code = message
|
|
if plat != state.platform or state.screen != "checking":
|
|
return []
|
|
_fail(state, lines, code)
|
|
state.error_title = title
|
|
return []
|
|
_kind, run_id, *rest = message
|
|
if run_id != state.run_id:
|
|
return []
|
|
if kind == "event":
|
|
_take_event(state, rest[0])
|
|
return []
|
|
code, tail = rest
|
|
state.running = False
|
|
state.tail = [line for line in tail if not line.startswith("RetroBIOS ")]
|
|
tail = state.tail
|
|
if state.run == "check":
|
|
if state.check is None:
|
|
return _fail(state, tail, code)
|
|
state.ticked = {row["system"] for row in state.systems if _needed(row)}
|
|
if state.keep_ticked is not None:
|
|
state.ticked = state.keep_ticked & {row["system"] for row in state.systems}
|
|
state.keep_ticked = None
|
|
state.facts = _tui_folder_facts(state)
|
|
_replace(state, "plan")
|
|
elif state.run == "confirm" and state.screen == "confirm":
|
|
if state.dry is None:
|
|
return _fail(state, tail, code)
|
|
if _same_plan(state.check, state.dry) and not state.must_confirm:
|
|
state.stack.pop()
|
|
return _install_now(state)
|
|
elif state.run == "install":
|
|
state.code = 130 if state.stopping and code < 0 else code if code >= 0 else 128 - code
|
|
_replace(state, "result")
|
|
return []
|
|
|
|
|
|
# Shorter key names for a narrow bar: at 40 columns "X/Space Tick" was cut,
|
|
# and nothing said how to tick with a controller.
|
|
_SHORT_KEYS = (("A/Enter ", "A "), ("B/Esc ", "B "), ("X/Space ", "X "), ("Y/o ", "Y "),
|
|
("Left/Right ", "<> "), ("Up/Down ", "^v "))
|
|
|
|
|
|
# The key of a hint as each route names it, None for a key it lacks. The
|
|
# bar is written in the "both" form ("A/Enter Install"), the route's own
|
|
# names replace it: a PC user read "A/Enter", a muOS player was told a
|
|
# Start+Select that muxterm does not have.
|
|
_ROUTE_KEYS: dict[str, dict[str, str | None]] = {
|
|
"keys": {"A/Enter": "Enter", "B/Esc": "Esc", "X/Space": "Space", "Y/o": "o"},
|
|
# retrobios.gptk: A Enter, B Esc, X Space, Y Tab, L1 and R1 a page,
|
|
# Start Enter, Select Esc, Start+Select the kill.
|
|
"gptokeyb": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None,
|
|
"/": None, "Ctrl-C": "Start+Select", "Enter": "A", "Esc": "B",
|
|
"Ctrl-U": None},
|
|
# RetroBIOS.sh.keys: the same buttons, and no combo that ends the run.
|
|
"evmapy": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None,
|
|
"/": None, "Ctrl-C": None, "Enter": "A", "Esc": "B", "Ctrl-U": None},
|
|
# muxterm's own keys: Start Enter, B Backspace, Y Space, Menu closes the
|
|
# terminal. A, X, L1 and R1 send nothing, Select shows its keyboard.
|
|
"muxterm": {"A/Enter": "Start", "B/Esc": "B", "X/Space": "Y", "Y/o": None, "?": None,
|
|
"/": None, "Ctrl-C": "Menu", "Enter": "Start", "Esc": None,
|
|
"Ctrl-U": None},
|
|
# Buttons that are keys already: A Space, Start Enter, L2 Tab, R2
|
|
# Backspace. B is Left Ctrl, a modifier: alone it sends no byte to a
|
|
# terminal (the console's keyboard driver only records its state), so
|
|
# the interface never sees it, nor X, Y and Select (Shift, Alt, Ctrl).
|
|
"miyoo": {"A/Enter": "A", "B/Esc": "R2", "X/Space": "A", "Y/o": "L2", "?": None,
|
|
"/": None, "Ctrl-C": None, "Enter": "Start", "Esc": None, "Ctrl-U": None},
|
|
}
|
|
# gptokeyb2 with retrobios.gptk inside muxterm, which no longer reads the pad:
|
|
# the gptokeyb buttons.
|
|
_ROUTE_KEYS["gptokeyb_muxterm"] = _ROUTE_KEYS["gptokeyb"]
|
|
|
|
|
|
def _route_hint(state: TuiState, parts: tuple) -> tuple:
|
|
"""The bar's entries named as the run's input names them."""
|
|
names = _ROUTE_KEYS.get(state.pad)
|
|
if names is None or state.help:
|
|
return parts
|
|
named = []
|
|
for part in parts:
|
|
key, sep, what = part.partition(" ")
|
|
if key in names:
|
|
# On a Miyoo A is Space, which ticks in a checklist: Start continues.
|
|
mine = ("Start" if state.pad == "miyoo" and key == "A/Enter"
|
|
and state.screen in _CHECKLISTS else names[key])
|
|
if mine is None:
|
|
continue
|
|
key = mine
|
|
named.append(key + sep + what)
|
|
if state.pad == "muxterm" and state.screen == "path":
|
|
named.append("Select Keyboard")
|
|
return tuple(named)
|
|
|
|
|
|
def _hint(width: int, *parts: str) -> str:
|
|
"""The keys that matter now, short names first given up, then whole
|
|
entries from the right."""
|
|
full = " ".join(parts)
|
|
if cell_width(full) <= width:
|
|
return full
|
|
short = []
|
|
for part in parts:
|
|
for long_name, short_name in _SHORT_KEYS:
|
|
if part.startswith(long_name):
|
|
part = short_name + part[len(long_name):]
|
|
short.append(part)
|
|
text = ""
|
|
for part in short:
|
|
longer = f"{text} {part}" if text else part
|
|
if cell_width(longer) > width:
|
|
break
|
|
text = longer
|
|
return text or fit(short[0], width)
|
|
|
|
|
|
# What A does on each kind of row: the bar names it for the row in focus.
|
|
_ROW_VERBS = {
|
|
"install": "Install", "close": "Close", "quit": "Quit", "systems": "Open",
|
|
"options": "Open", "setup": "Continue", "another": "Open", "platform": "Choose",
|
|
"default": "Use this folder", "browse": "Browse", "type": "Type a path",
|
|
"use": "Use this folder", "up": "Go up", "dir": "Open", "create": "Create",
|
|
"other": "Choose another", "cardroot": "Use the root", "all": "Tick all",
|
|
"none": "Untick all", "filter": "Filter", "system": "Continue", "core": "Done",
|
|
"region": "Tick", "regions": "Open", "cores": "Open", "command": "Open",
|
|
"preview": "Open", "folder": "Choose", "game_data": "Change", "installed": "Change",
|
|
"jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay",
|
|
"fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it",
|
|
"unreadable": "Show",
|
|
}
|
|
|
|
|
|
def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine:
|
|
left = ("> " if focused else " ") + row.label
|
|
if row.narrow:
|
|
# The name first: the longest right-hand form that leaves it whole,
|
|
# else none at all when that is what keeps it whole. Nintendo Game
|
|
# Boy, Game Boy Advance and Game Boy Color read alike once cut.
|
|
whole = width - cell_width(left) - 2
|
|
right = next((form for form in (row.right, *row.narrow)
|
|
if cell_width(form) <= whole), "")
|
|
if not right and cell_width(left) > width:
|
|
right = row.narrow[-1]
|
|
text = _columns(left, right, width)
|
|
else:
|
|
right = middle(row.right, max(12, width // 2)) if row.right else ""
|
|
text = _columns(left, right, width)
|
|
return _styled(text, "focus" if focused else row.style)
|
|
|
|
|
|
def _text_lines(items: list, width: int) -> list[TuiLine]:
|
|
lines: list[TuiLine] = []
|
|
for item in items:
|
|
style = getattr(item, "style", "")
|
|
if style == "path":
|
|
# One line, cut in its middle: the end of a path tells it apart.
|
|
lines.append(_styled(middle(str(item), width)))
|
|
continue
|
|
for piece in _wrap(str(item), width):
|
|
lines.append(_styled(fit(piece, width), style))
|
|
return lines
|
|
|
|
|
|
def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> list[TuiLine]:
|
|
"""height lines of a longer list from first, the last one saying where.
|
|
The lead rows (All, None, Filter) are not counted among the entries."""
|
|
if len(lines) <= height:
|
|
return lines
|
|
shown = max(1, height - 1)
|
|
first = max(0, min(first, len(lines) - shown))
|
|
end = first + shown
|
|
count = len(lines) - lead
|
|
where = f"{max(1, first + 1 - lead)}-{max(1, end - lead)} of {count}"
|
|
return lines[first:end] + [_styled(where, "dim")]
|
|
|
|
|
|
def _compose(
|
|
state: TuiState, width: int, height: int, before: list, rows: list[TuiRow],
|
|
after: list = (), footer: list = (),
|
|
) -> list[TuiLine]:
|
|
"""Text, then the rows, then more text, in height lines. The rows are
|
|
always shown, scrolled around the focus when they do not fit."""
|
|
footer_lines = _text_lines(list(footer), width)
|
|
room = max(1, height - len(footer_lines))
|
|
focus = max(0, min(state.focus.get(state.screen, 0), len(rows) - 1)) if rows else 0
|
|
row_lines = [_row_line(row, index == focus, width) for index, row in enumerate(rows)]
|
|
if len(row_lines) >= room:
|
|
first = focus - (room - 1) // 2
|
|
lead = next((i for i, row in enumerate(rows)
|
|
if row.action not in ("all", "none", "filter")), 0)
|
|
body = _scrolled(row_lines, first, room, lead)
|
|
else:
|
|
budget = room - len(row_lines)
|
|
top: list[TuiLine] = []
|
|
for item in before:
|
|
lines = _text_lines([item], width)
|
|
if len(top) + len(lines) > budget:
|
|
break
|
|
top += lines
|
|
budget -= len(top)
|
|
gap = [_styled("")] if top and row_lines and budget > 0 else []
|
|
budget -= len(gap)
|
|
tail = _text_lines(list(after), width)
|
|
bottom = ([_styled("")] + tail)[:budget] if tail and budget > 1 else []
|
|
body = top + gap + row_lines + bottom
|
|
body += [_styled("")] * (room - len(body))
|
|
return body + footer_lines
|
|
|
|
|
|
def _pager(
|
|
state: TuiState, width: int, height: int, items: list, lead: int = 0,
|
|
) -> list[TuiLine]:
|
|
"""The items in view, from the first shown on, each wrapped alone: the
|
|
8,000 lines of a full preview were wrapped again at every key."""
|
|
first = max(0, min(state.focus.get(state.screen, 0), _pager_last(state, items)))
|
|
if len(items) <= height and first == 0:
|
|
lines = _text_lines(items, width)
|
|
if len(lines) <= height:
|
|
return lines
|
|
room = max(1, height - 1)
|
|
lines: list[TuiLine] = []
|
|
index = first
|
|
while index < len(items) and len(lines) < room:
|
|
lines += _text_lines([items[index]], width)
|
|
index += 1
|
|
whole = index if len(lines) <= room else index - 1
|
|
lines = lines[:room]
|
|
start = max(1, first + 1 - lead)
|
|
return lines + [_styled(f"{start}-{max(start, whole - lead)} of {len(items) - lead}",
|
|
"dim")]
|
|
|
|
|
|
def _tui_misplaced(state: TuiState) -> list[dict]:
|
|
"""The files the BIOS folder holds at another place, each with its reason."""
|
|
return [event for event in state.found if event["reason"]]
|
|
|
|
|
|
def _tui_found_elsewhere(state: TuiState) -> dict[str, list[dict]]:
|
|
"""The files a --from folder holds, by the folder as it was named."""
|
|
found: dict[str, list[dict]] = {}
|
|
for event in state.found:
|
|
if not event["reason"]:
|
|
found.setdefault(event["folder"], []).append(event)
|
|
return found
|
|
|
|
|
|
def _scan_lines(state: TuiState) -> list[str]:
|
|
"""What the reading of the --from folders left out, one line each."""
|
|
lines = []
|
|
for scan in state.scanned:
|
|
if scan["truncated"]:
|
|
lines.append(f"Read the first {MAX_SCANNED_FILES:,} files of {scan['folder']}.")
|
|
lines += [f"{drive} is another drive: add it to copy from it too."
|
|
for drive in scan["other_drives"]]
|
|
if state.unreadable:
|
|
count = len(state.unreadable)
|
|
lines.append(f"{_plural(count, 'file')} on your old card could not be read.")
|
|
return lines
|
|
|
|
|
|
def _last_name(folder: str) -> str:
|
|
"""A folder or ZIP by its last name, a drive by itself (E:\\)."""
|
|
return Path(folder).name or folder
|
|
|
|
|
|
def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
|
|
need = _needed(check)
|
|
data = check.get("data_missing", 0)
|
|
# A file found on this machine is copied, not downloaded: the count and
|
|
# the size to download leave it out, from the check's own events.
|
|
local = {event["dest"] for event in state.found} if check is state.check else set()
|
|
sizes = {event["dest"]: event["size"] for event in state.files}
|
|
copied = [dest_ for dest_ in local if dest_ in sizes]
|
|
fetch = max(0, need - len(copied))
|
|
fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied))
|
|
elsewhere = _tui_found_elsewhere(state) if check is state.check else {}
|
|
offline = _offline_only()
|
|
if elsewhere:
|
|
# The folder by its last name: the whole path pushed the BIOS
|
|
# folder off a 40x15 screen. The Options row spells it out.
|
|
where = ", ".join(f"{len(events)} found in {_last_name(folder)}"
|
|
for folder, events in elsewhere.items())
|
|
headline = (f"{where}, {fetch} need the internet (--offline)" if fetch and offline
|
|
else f"{where}, {fetch} to download ({format_size(fetch_bytes)})"
|
|
if fetch else f"{where}, nothing to download")
|
|
elif need and fetch and offline:
|
|
headline = (f"{_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the "
|
|
"internet, which --offline does not use")
|
|
elif need and fetch:
|
|
headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}"
|
|
elif need:
|
|
headline = f"{_plural(len(copied), 'file')} to copy from this machine"
|
|
if need:
|
|
if data:
|
|
headline += f", and {_plural(data, 'data file')}"
|
|
if not need:
|
|
headline = (f"{_plural(data, 'data file')} to fetch" if data
|
|
else "Everything is in place")
|
|
wrong = check["wrong_hash"]
|
|
offline_lines = (["Copy them from your old card or a folder, or run the installer "
|
|
"again with a pack (--from-pack) or without --offline."]
|
|
if offline and fetch else [])
|
|
# The folder comes second: on a 40x12 screen it is what is left once
|
|
# the rows are drawn, and nothing is written before it is seen.
|
|
lines: list = [
|
|
_styled(headline, "title"),
|
|
_styled(f"BIOS folder: {_short_path(dest, state.home)}", "path"),
|
|
]
|
|
if state.facts.get("writable") is False:
|
|
lines.append("You cannot write to this folder: choose another one.")
|
|
lines.append(f"{check['present']} in place, {check['missing']} missing, "
|
|
f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}")
|
|
found_in = _RETROARCH_CONFIGS.get(dest) if dest is not None else None
|
|
if found_in:
|
|
lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path"))
|
|
free = state.facts.get("free")
|
|
if free is not None:
|
|
lines.append(f"Free space: {format_size(free)}")
|
|
if check["bytes_needed"] > free:
|
|
lines.append(f"Not enough free space: {format_size(check['bytes_needed'])} "
|
|
"needed. Free some, or choose another folder.")
|
|
if wrong:
|
|
lines.append("They are replaced, and the old copies are kept in a backup folder.")
|
|
misplaced = _tui_misplaced(state) if check is state.check else []
|
|
if misplaced:
|
|
lines.insert(3, f"{_plural(len(misplaced), 'file')} "
|
|
f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, "
|
|
f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the "
|
|
"emulators read "
|
|
f"{'it' if len(misplaced) == 1 else 'them'}.")
|
|
if state.facts.get("writable") is False:
|
|
if state.os_type == "windows":
|
|
lines.append("Or run the installer as an administrator.")
|
|
else:
|
|
# sudo changes the home folder, and with it what is detected:
|
|
# the folder is named so root writes here.
|
|
hint = Invocation()
|
|
hint.os_type = state.os_type
|
|
lines.append("Or run it as root, this folder named: "
|
|
+ hint.command(state.platform, dest, elevated=True))
|
|
if check is state.check:
|
|
lines += _scan_lines(state)
|
|
if check.get("not_installable"):
|
|
lines += _unavailable_lines(state)
|
|
if check is state.check:
|
|
lines += _unplaced_lines(state.check_summary, state)
|
|
return lines[:2] + offline_lines + lines[2:]
|
|
|
|
|
|
def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]:
|
|
"""The files read from the frontend's save directory that the run left
|
|
out, that directory being unknown: said, never silent."""
|
|
count = sum(group.get("files", 0) for group in (summary or {}).get("skipped") or []
|
|
if group.get("reason") == "no_save_directory")
|
|
if not count:
|
|
return []
|
|
name = display_name(state.platform) if state.platform else "the frontend"
|
|
return [f"{_plural(count, 'file')} read from {name}'s save directory left out: "
|
|
"its place is not known. Name it with --save-dir."]
|
|
|
|
|
|
def _view_setup(state: TuiState, width: int, height: int) -> tuple:
|
|
rows = tui_rows(state)
|
|
focus = rows[min(state.focus.get("setup", 0), len(rows) - 1)]
|
|
after = ([f"BIOS folder: {state.setups[focus.value][1]}"]
|
|
if focus.action == "setup" else [])
|
|
before = ["Found on this machine:" if state.setups else "Nothing was found here."]
|
|
return ("Where do you play?",
|
|
_compose(state, width, height, before, rows, after + state.notes),
|
|
("A/Enter Continue", "B/Esc Quit", "? Help"))
|
|
|
|
|
|
def _view_platforms(state: TuiState, width: int, height: int) -> tuple:
|
|
rows = tui_rows(state)
|
|
if state.dest_given is not None:
|
|
title = "Whose files go there?"
|
|
before = [f"Choose the setup that reads {_short_path(state.dest_given, state.home)}."]
|
|
else:
|
|
title = "Which setup?"
|
|
before = ["Choose the setup that will use the BIOS files."]
|
|
needle = state.filters.get("platforms", "")
|
|
after = [f"No setup matches '{needle}'."] if len(rows) == 1 and needle else []
|
|
return (title, _compose(state, width, height, before, rows, after),
|
|
("A/Enter Choose", "B/Esc Back", "/ Filter", "? Help"))
|
|
|
|
|
|
def _view_folder(state: TuiState, width: int, height: int) -> tuple:
|
|
name = display_name(state.platform)
|
|
if state.platform in CARD_PLATFORMS:
|
|
title = "Where is the SD card?"
|
|
before = [f"{name} keeps its BIOS files on its SD card. Choose the card's "
|
|
"root folder, the one holding .system."]
|
|
elif state.folder_default is not None:
|
|
title = "Which folder?"
|
|
before = [f"{name} reads its BIOS files from:", str(state.folder_default)]
|
|
else:
|
|
title = "Which folder?"
|
|
before = [f"No usual {name} folder here: browse for it or type it."]
|
|
return (title, _compose(state, width, height, before, tui_rows(state)),
|
|
("A/Enter Choose", "B/Esc Back", "? Help"))
|
|
|
|
|
|
def _view_browse(state: TuiState, width: int, height: int) -> tuple:
|
|
where = "Drives" if state.browse_dir is None else middle(str(state.browse_dir), width)
|
|
after = [state.status] if state.status else []
|
|
if state.browse_for == "from":
|
|
after.insert(0, "Choose the old card, a backup folder or a ZIP: its BIOS files "
|
|
"are copied, each one checked, nothing on it is changed.")
|
|
return ("Copy from" if state.browse_for == "from" else "Browse for a folder",
|
|
_compose(state, width, height, [where], tui_rows(state), after),
|
|
("A/Enter Open", "B/Esc Back", "? Help"))
|
|
|
|
|
|
def _view_path(state: TuiState, width: int, height: int) -> tuple:
|
|
text = state.path_text + "_"
|
|
if cell_width(text) > width - 2:
|
|
text = "..." + fit(text[::-1], width - 5)[::-1]
|
|
lines = [f"The folder for {display_name(state.platform)}:", _styled(f"> {text}", "focus"),
|
|
"", ("Typing or pasting replaces it. " if state.path_selected else "")
|
|
+ "Ctrl-U clears. Quotes are removed, ~ and variables are expanded."]
|
|
if state.status:
|
|
lines.append(state.status)
|
|
return ("Type a path", _compose(state, width, height, lines, []),
|
|
("Enter Use this folder", "Esc Back", "Ctrl-U Clear"))
|
|
|
|
|
|
def _view_create(state: TuiState, width: int, height: int) -> tuple:
|
|
before = [f"{state.dest} does not exist.",
|
|
f"The nearest folder that does: {state.facts.get('nearest', '')}"]
|
|
if state.facts.get("risky"):
|
|
before.append("Every folder of this path would be made: check its start before "
|
|
"creating it.")
|
|
return ("Create the folder?", _compose(state, width, height, before, tui_rows(state)),
|
|
("A/Enter Choose", "B/Esc Back"))
|
|
|
|
|
|
def _view_cardroot(state: TuiState, width: int, height: int) -> tuple:
|
|
name = display_name(state.facts.get("card", ""))
|
|
before = [f"{state.dest} is inside a {name} card. {name} reads its BIOS files under "
|
|
f"the card's root: {state.facts.get('card_root', '')}"]
|
|
return ("Use the card root?", _compose(state, width, height, before, tui_rows(state)),
|
|
("A/Enter Choose", "B/Esc Back"))
|
|
|
|
|
|
def _view_checking(state: TuiState, width: int, height: int) -> tuple:
|
|
lines = [f"{display_name(state.platform)}: {_short_path(state.dest, state.home)}", ""]
|
|
elapsed = format_duration(max(0.0, state.now - state.started))
|
|
if state.manifest is None:
|
|
lines.append(f"Getting the file list... {elapsed}")
|
|
else:
|
|
files = len(state.manifest.get("files", []))
|
|
lines += [f"File list: {_plural(files, 'file')}.", _reading_line(state, elapsed)]
|
|
return ("Checking", _compose(state, width, height, lines, []),
|
|
("Ctrl-C Quit", "B/Esc Back"))
|
|
|
|
|
|
def _reading_line(state: TuiState, elapsed: str) -> str:
|
|
if state.reading:
|
|
done, total = state.reading
|
|
return f"Reading the files already in the folder: {done} of {total}"
|
|
return f"Reading the files already in the folder... {elapsed}"
|
|
|
|
|
|
def _choice_line(state: TuiState) -> list[str]:
|
|
"""What the plan's numbers do not include yet. The options are read
|
|
again by a check of their own, so only ticked systems are left here."""
|
|
if tui_flags(state) == state.checked_flags:
|
|
return []
|
|
return [f"For your {len(state.ticked)} of {len(state.systems)} systems, the exact "
|
|
"count comes before the install."]
|
|
|
|
|
|
def _view_plan(state: TuiState, width: int, height: int) -> tuple:
|
|
check = state.check or {}
|
|
lines = _plan_lines(check, state.dest, state)
|
|
lines[2:2] = _choice_line(state)
|
|
if state.status:
|
|
lines.append(state.status)
|
|
rows = tui_rows(state)
|
|
if width < 54:
|
|
rows = [row._replace(label="Copy from your old card or a folder")
|
|
if row.action == "fromcard" else row for row in rows]
|
|
# A muOS card gets RetroArch's files: the title says both.
|
|
system = detected_os(state.platform, state.dest) if state.dest is not None else ""
|
|
name = display_name(state.platform, state.manifest)
|
|
return (f"{system} ({name} files)" if system and system != name else name,
|
|
_compose(state, width, height, lines, rows),
|
|
("A/Enter Install", "B/Esc Back", "Y/o Options", "? Help"))
|
|
|
|
|
|
def _counts_predate_options(state: TuiState) -> bool:
|
|
"""Whether the check's counts were read under other options than the
|
|
ones chosen since: a region or a core changes what each system needs."""
|
|
def others(flags: list) -> list:
|
|
return [flag for flag in flags if flag[0] != "--system"]
|
|
return others(tui_flags(state)) != others(state.checked_flags)
|
|
|
|
|
|
def _ticked_files(state: TuiState) -> list[dict]:
|
|
return [event for event in state.files
|
|
if set(event["systems"]) & state.ticked
|
|
or (not event["systems"] and OTHER_SYSTEMS in state.ticked)]
|
|
|
|
|
|
def _view_systems(state: TuiState, width: int, height: int) -> tuple:
|
|
rows = tui_rows(state)
|
|
if width < 60:
|
|
# The name first: the size alone stays beside it, the count is in
|
|
# the total below.
|
|
rows = [row._replace(right=re.sub(r"^\d+ files?, ", "", row.right)) for row in rows]
|
|
elif width >= 78:
|
|
# The id --system takes, beside a name that does not show it.
|
|
# Only where it fits whole: a cut id ("maker26-co") said nothing.
|
|
rows = [row._replace(label=f"{row.label} {row.value}")
|
|
if row.action == "system" and row.value not in row.label
|
|
and cell_width(f"> {row.label} {row.value} {row.right}") + 2 <= width
|
|
else row for row in rows]
|
|
chosen = _ticked_files(state)
|
|
footer = [f"{len(state.ticked)} of {len(state.systems)} systems: "
|
|
f"{_plural(len(chosen), 'file')}, "
|
|
f"{format_size(sum(e['size'] for e in chosen))}"
|
|
+ (" before your options" if _counts_predate_options(state) else "")]
|
|
focused = rows[min(state.focus.get("systems", 0), len(rows) - 1)]
|
|
if focused.value == OTHER_SYSTEMS:
|
|
footer.insert(0, OTHER_HELP)
|
|
elif "shared" in focused.right:
|
|
count = _shared_needed(state)[focused.value][0]
|
|
footer.insert(0, f"{count} of these files are shared with other systems, "
|
|
"counted once in the total.")
|
|
needle = state.filters.get("systems", "")
|
|
after = [f"No system matches '{needle}'."] if len(rows) == 3 and needle else []
|
|
if state.status:
|
|
after.append(state.status)
|
|
return ("Choose systems",
|
|
_compose(state, width, height, [], rows, after, footer),
|
|
("A/Enter Continue", "B/Esc Back", "X/Space Tick", "? Help"))
|
|
|
|
|
|
def _view_options(state: TuiState, width: int, height: int) -> tuple:
|
|
rows = tui_rows(state)
|
|
before = ["Each row is an option of the command line, named on the right."]
|
|
after: list = []
|
|
if width < 60:
|
|
# A value cut to make room for its flag said nothing: the flag of
|
|
# the focused row is named below instead.
|
|
focus = rows[min(state.focus.get("options", 0), len(rows) - 1)]
|
|
before = []
|
|
after = [f"Sets {focus.right}"] if focus.right else []
|
|
rows = [row._replace(right="") for row in rows]
|
|
# A folder is cut in its middle, its last levels being what tells it apart.
|
|
room = width - 4 - (0 if width < 60 else 2 + max(cell_width(r.right) for r in rows))
|
|
rows = [_middle_label(row, room) if row.style == "path" else row for row in rows]
|
|
if state.status:
|
|
after.append(state.status)
|
|
return ("Options", _compose(state, width, height, before, rows, after),
|
|
("A/Enter Change", "B/Esc Back", "Left/Right Set", "? Help"))
|
|
|
|
|
|
def _middle_label(row: TuiRow, room: int) -> TuiRow:
|
|
"""A row naming a folder, the folder cut in its middle to fit."""
|
|
name, _sep, value = row.label.partition(": ")
|
|
return row._replace(label=f"{name}: " + middle(value, room - len(name) - 2), style="")
|
|
|
|
|
|
def _view_regions(state: TuiState, width: int, height: int) -> tuple:
|
|
before = ["Tick the regions to keep, best first. Where a system has one BIOS "
|
|
"per region, the best one found is installed."]
|
|
return ("Preferred regions",
|
|
_compose(state, width, height, before, tui_rows(state)),
|
|
("A/Enter Tick", "B/Esc Done", "Left/Right Order", "? Help"))
|
|
|
|
|
|
def _view_cores(state: TuiState, width: int, height: int) -> tuple:
|
|
before = ["Tick the cores you use. With none ticked, every core's files are kept."]
|
|
return ("Cores", _compose(state, width, height, before, tui_rows(state)),
|
|
("A/Enter Done", "B/Esc Back", "X/Space Tick", "? Help"))
|
|
|
|
|
|
_STATE_WORDS = {"missing": "missing", "wrong_hash": "wrong copy"}
|
|
|
|
|
|
def _pager_items(state: TuiState) -> list:
|
|
"""The text of a screen that scrolls, the same for its view and its keys."""
|
|
if state.screen == "misplaced":
|
|
return [f"{event['dest']}: {event['reason']}" for event in _tui_misplaced(state)]
|
|
if state.screen == "unreadable":
|
|
return [f"{event['path']}" + (f", {event['member']}" if event["member"] else "")
|
|
+ f": {event['reason']}" for event in state.unreadable]
|
|
if state.screen == "result":
|
|
return tui_outcome(state)[1]
|
|
if state.screen == "command":
|
|
return ["The same choice without questions:", "", tui_command(state)]
|
|
if state.running:
|
|
items = ["Counting the files for these choices..."]
|
|
elif state.dry is None:
|
|
items = state.tail or ["The preview did not finish."]
|
|
else:
|
|
items = [f"{_plural(_needed(state.dry), 'file')} to download, "
|
|
f"{format_size(state.dry['bytes_needed'])}. Nothing is written.", ""]
|
|
items += [f"{_STATE_WORDS.get(event['state'], event['state']):<10} "
|
|
f"{format_size(event['size']):>9} {event['dest']}"
|
|
for event in state.dry_files]
|
|
for kept in (state.dry_summary or {}).get("kept") or []:
|
|
items.append(f"Replaced files would be kept in {kept['folder']}")
|
|
return items
|
|
|
|
|
|
def _view_preview(state: TuiState, width: int, height: int) -> tuple:
|
|
# The count and the blank line above the files are not among them.
|
|
lead = 2 if state.dry is not None and not state.running else 0
|
|
return ("Preview", _pager(state, width, height, _pager_items(state), lead),
|
|
("A/Enter Back", "Up/Down Scroll"))
|
|
|
|
|
|
def _view_unreadable(state: TuiState, width: int, height: int) -> tuple:
|
|
return ("Files that could not be read",
|
|
_pager(state, width, height, _pager_items(state)),
|
|
("A/Enter Back", "Up/Down Scroll"))
|
|
|
|
|
|
def _view_misplaced(state: TuiState, width: int, height: int) -> tuple:
|
|
return ("Files in the wrong place", _pager(state, width, height, _pager_items(state)),
|
|
("A/Enter Back", "Up/Down Scroll"))
|
|
|
|
|
|
def _view_command(state: TuiState, width: int, height: int) -> tuple:
|
|
return ("Command line", _pager(state, width, height, _pager_items(state)),
|
|
("A/Enter Back",))
|
|
|
|
|
|
def _view_confirm(state: TuiState, width: int, height: int) -> tuple:
|
|
if state.running or state.dry is None:
|
|
lines: list = ["Counting the files for these choices..."]
|
|
else:
|
|
lines = _plan_lines(state.dry, state.dest, state)
|
|
return ("Confirm", _compose(state, width, height, lines, tui_rows(state)),
|
|
("A/Enter Install", "B/Esc Back", "? Help"))
|
|
|
|
|
|
def _bar(share: float, width: int) -> str:
|
|
room = max(4, width - 7)
|
|
filled = int(share * room)
|
|
return f"[{'#' * filled}{'-' * (room - filled)}] {int(share * 100):3d}%"
|
|
|
|
|
|
# What stops an install a second time, and the first time, per route.
|
|
_STOP_KEYS = {
|
|
"both": ("B or Ctrl-C", "B, Ctrl-C or Start+Select"),
|
|
"keys": ("Esc or Ctrl-C", "Esc or Ctrl-C"),
|
|
"gptokeyb": ("B", "B or Start+Select"),
|
|
"gptokeyb_muxterm": ("B", "B or Start+Select"),
|
|
"evmapy": ("B", "B"),
|
|
"muxterm": ("B", "B"),
|
|
"miyoo": ("R2", "R2"),
|
|
}
|
|
|
|
|
|
def _view_installing(state: TuiState, width: int, height: int) -> tuple:
|
|
lines: list = []
|
|
if state.download is None:
|
|
elapsed = format_duration(max(0.0, state.now - state.started))
|
|
lines.append(_reading_line(state, elapsed))
|
|
else:
|
|
total = state.download["bytes"]
|
|
progress = state.progress or {}
|
|
done = progress.get("bytes_done", 0)
|
|
lines.append(_bar(min(done / total, 1.0) if total else 1.0, width))
|
|
lines.append(f"{format_size(done)} of {format_size(total)}")
|
|
window = [sample for sample in state.samples if sample[0] >= state.now - 5]
|
|
start, base = window[0] if window else (state.now, state.network)
|
|
seconds = state.now - start
|
|
speed = (state.network - base) / seconds if seconds >= 1 else 0
|
|
if speed > 0:
|
|
lines.append(f"{format_size(int(speed))}/s, "
|
|
f"{format_duration((total - done) / speed)} left")
|
|
files_done = progress.get("files_done", 0)
|
|
lines.append(f"{files_done} of {_plural(state.download['files'], 'file')}")
|
|
if files_done >= state.download["files"] and state.summary is None:
|
|
lines.append("Finishing...")
|
|
stops = _STOP_KEYS.get(state.pad, _STOP_KEYS["both"])
|
|
lines.append(_styled(f"Stopping, finishing the files in flight. Press {stops[0]} "
|
|
"again to stop now." if state.stopping
|
|
else f"{stops[1]} stops. What is done stays done.", "dim"))
|
|
room = height - len(lines) - 1
|
|
failed = [item for item in reversed(state.finished) if not item[1]]
|
|
good = [item for item in reversed(state.finished) if item[1]]
|
|
shown = (failed + good)[:max(0, room)]
|
|
if shown:
|
|
lines.append("")
|
|
lines += [f"{'ok ' if ok else 'FAILED'} {dest}" + (f" ({reason})" if reason else "")
|
|
for dest, ok, reason in shown]
|
|
return ("Installing", _compose(state, width, height, lines, []),
|
|
("B/Esc Stop", "? Help"))
|
|
|
|
|
|
_SPACE_NEEDED = re.compile(r"Needed: (\S+ \S+)\. Free: (\S+ \S+)\.")
|
|
|
|
|
|
def _space_stop(state: TuiState) -> re.Match | None:
|
|
"""Needed and free, when the run stopped for lack of space."""
|
|
return next(filter(None, (_SPACE_NEEDED.search(line) for line in state.tail)), None)
|
|
|
|
|
|
def _space_words(state: TuiState) -> str:
|
|
"""The run's stop for lack of space, in words, "" for another stop."""
|
|
match = _space_stop(state)
|
|
if match is None:
|
|
return ""
|
|
dest = (state.dest or Path(".")).absolute()
|
|
card = any((mount == dest or mount in dest.parents) and card_system(mount)
|
|
for mount in _mounted_cards())
|
|
return f"The {'card' if card else 'drive'} is full: {match[1]} needed, {match[2]} free."
|
|
|
|
|
|
def _tui_sources(state: TuiState, installed: int, failed: int) -> str:
|
|
"""Where the installed files came from, as the line mode says it:
|
|
"6 files copied from /media/me/OLD, 42 downloaded, 2 still missing." """
|
|
ok = {dest for dest, good, _why in state.finished if good}
|
|
folders: dict[str, int] = {}
|
|
fixed = 0
|
|
for dest, (folder, reason) in state.local_dests.items():
|
|
if dest not in ok:
|
|
continue
|
|
if reason:
|
|
fixed += 1
|
|
else:
|
|
# By its name, as the plan said "6 found in old card".
|
|
name = _last_name(folder)
|
|
folders[name] = folders.get(name, 0) + 1
|
|
copied = fixed + sum(folders.values())
|
|
return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed)
|
|
|
|
|
|
def _unavailable(state: TuiState) -> tuple[list[dict], int]:
|
|
"""The list's files RetroBIOS cannot supply for the systems chosen: the
|
|
ones an emulator needs to start, and how many others."""
|
|
listed = {row["system"] for row in state.systems}
|
|
chosen = state.ticked if state.systems else None
|
|
needed, others = [], 0
|
|
for entry in (state.manifest or {}).get("omitted_files") or []:
|
|
system = entry.get("system") if entry.get("system") in listed else OTHER_SYSTEMS
|
|
if chosen is not None and system not in chosen:
|
|
continue
|
|
if state.no_game_data and entry.get("category") == "game_data":
|
|
continue
|
|
if entry.get("required"):
|
|
needed.append(entry)
|
|
else:
|
|
others += 1
|
|
return needed, others
|
|
|
|
|
|
def _unavailable_lines(state: TuiState) -> list[str]:
|
|
"""The files that cannot be supplied and stop an emulator, by name and
|
|
system: "1 file ... an emulator needs it" named neither."""
|
|
needed, _others = _unavailable(state)
|
|
if not needed:
|
|
return []
|
|
titles = _system_titles(state)
|
|
rows = {row["system"]: row for row in state.systems}
|
|
|
|
def system(name: str) -> str:
|
|
return _tui_system_label(rows.get(name) or {"system": name}, titles)
|
|
|
|
names = [f"{entry['dest']} ({system(entry['system'])})" if entry.get("system")
|
|
else entry["dest"] for entry in needed[:3]]
|
|
more = f" and {len(needed) - 3} more" if len(needed) > 3 else ""
|
|
return [f"Not available here, and needed to start "
|
|
f"{'its emulator' if len(needed) == 1 else 'their emulators'}: "
|
|
f"{', '.join(names)}{more}. Your own copy goes in the BIOS folder."]
|
|
|
|
|
|
def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
|
|
"""The result in words, first what happened, then where and what next."""
|
|
summary = state.summary or {}
|
|
# Killed by the second Ctrl-C, the child wrote no summary: what it
|
|
# reported finished is what is installed.
|
|
installed = summary.get("installed", sum(1 for _dest, ok, _why in state.finished if ok))
|
|
failed = summary.get("failed") or []
|
|
up_to_date = summary.get("up_to_date", 0)
|
|
total = (state.download or {}).get("files", 0)
|
|
space = "" if summary else _space_words(state)
|
|
if summary.get("interrupted") or state.code == 130:
|
|
title = "Stopped"
|
|
head = (f"Stopped: {installed} of {total} installed, run again to finish."
|
|
if total else "Stopped. Run again to carry on: what is done stays done.")
|
|
elif failed and installed:
|
|
title = "Finished with errors"
|
|
head = f"{installed} installed, {len(failed)} failed."
|
|
elif failed:
|
|
title = "Nothing installed"
|
|
head = f"Nothing was installed: {_plural(len(failed), 'file')} failed."
|
|
elif not summary and space:
|
|
title = "Not enough space"
|
|
head = space
|
|
elif not summary:
|
|
title = "Stopped"
|
|
head = f"The installer stopped with code {state.code}."
|
|
elif installed:
|
|
title = "Done"
|
|
head = f"{_plural(installed, 'file')} installed."
|
|
if up_to_date:
|
|
head += f" {_plural(up_to_date, 'file')} already in place."
|
|
else:
|
|
title = "Done"
|
|
head = "Everything is in place."
|
|
lines = [head]
|
|
sources = _tui_sources(state, installed, len(failed))
|
|
if sources:
|
|
lines.append(sources)
|
|
lines.append(f"BIOS folder: {state.dest}")
|
|
if space:
|
|
lines.append("Free some space, or run again and choose another folder.")
|
|
causes: dict[str, list[str]] = {}
|
|
for failure in failed:
|
|
causes.setdefault(failure["reason"], []).append(failure["dest"])
|
|
shown = 0
|
|
for reason, dests in causes.items():
|
|
if len(dests) > 3:
|
|
# A read-only folder fails every file alike: said once.
|
|
lines.append(f" {len(dests)} files failed, all for one reason: "
|
|
f"{reason or 'none given'}.")
|
|
continue
|
|
for dest in dests:
|
|
if shown < 20:
|
|
lines.append(f" failed: {dest}" + (f" ({reason})" if reason else ""))
|
|
shown += 1
|
|
if shown > 20:
|
|
lines.append(f" ... and {shown - 20} more")
|
|
if not summary and not space:
|
|
lines += [f" {line}" for line in state.tail[-8:]]
|
|
run = Invocation()
|
|
run.os_type = state.os_type
|
|
for kept in summary.get("kept") or []:
|
|
lines.append(f"The files it replaced are kept in {kept['folder']}")
|
|
lines.append(f"To put them back: {run.launcher()} --restore "
|
|
f"{run.word(str(kept['folder']))}")
|
|
if summary and not summary.get("interrupted") and installed + up_to_date:
|
|
lines += _unavailable_lines(state)
|
|
for group in summary.get("left_out") or []:
|
|
lines.append(f"Left out as {group['label']}: {_plural(group['files'], 'file')}.")
|
|
lines += _unplaced_lines(summary, state)
|
|
lines += ["Same choice without questions:", f" {tui_command(state)}"]
|
|
if tui_keeps_log(state):
|
|
lines.append(f"Details: {state.log}")
|
|
return title, lines
|
|
|
|
|
|
def tui_keeps_log(state: TuiState) -> bool:
|
|
"""Whether the run's log is kept: when a failure names it, and only then.
|
|
57 folders of logs from stopped and finished runs were left in /tmp."""
|
|
if state.screen == "crashed":
|
|
return True
|
|
# A full card is said in words on the screen: its log adds nothing.
|
|
return (bool(state.log) and state.code not in (0, 130, None)
|
|
and _space_stop(state) is None)
|
|
|
|
|
|
def _view_result(state: TuiState, width: int, height: int) -> tuple:
|
|
title, lines = tui_outcome(state)
|
|
return (title, _pager(state, width, height, lines), ("A/Enter Exit", "Up/Down Scroll"))
|
|
|
|
|
|
def _view_error(state: TuiState, width: int, height: int) -> tuple:
|
|
return (state.error_title or "Something went wrong",
|
|
_compose(state, width, height, state.error, tui_rows(state)),
|
|
("A/Enter Choose", "B/Esc Back"))
|
|
|
|
|
|
def _view_quit(state: TuiState, width: int, height: int) -> tuple:
|
|
return ("Quit?",
|
|
_compose(state, width, height, ["Quit without installing? Nothing was written."],
|
|
tui_rows(state)),
|
|
("A/Enter Choose", "B/Esc Stay"))
|
|
|
|
|
|
_TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = {
|
|
"setup": _view_setup, "platforms": _view_platforms, "folder": _view_folder,
|
|
"browse": _view_browse, "path": _view_path, "create": _view_create,
|
|
"checking": _view_checking, "plan": _view_plan, "systems": _view_systems,
|
|
"options": _view_options, "regions": _view_regions, "cores": _view_cores,
|
|
"preview": _view_preview, "command": _view_command, "confirm": _view_confirm,
|
|
"installing": _view_installing, "result": _view_result, "error": _view_error,
|
|
"quit": _view_quit, "cardroot": _view_cardroot, "misplaced": _view_misplaced,
|
|
"unreadable": _view_unreadable,
|
|
}
|
|
|
|
|
|
# The help's names for each action, per route: the buttons first, then the
|
|
# keys a keyboard plugged in also has. None for an action the route lacks.
|
|
_HELP_KEYS: dict[str, dict[str, str | None]] = {
|
|
"both": {"move": "Up/Down, k/j", "page": "PgUp/PgDn, L1/R1", "ok": "A/Enter, Start",
|
|
"tick": "X, Right/Left", "options": "Y, o, Tab", "back": "B/Esc, Backspace",
|
|
"stop": "B/Esc, Ctrl-C", "kill": "Start+Select", "enter": "Enter",
|
|
"esc": "Esc"},
|
|
"keys": {"move": "Up/Down, k/j", "page": "PgUp/PgDn", "ok": "Enter",
|
|
"tick": "Space, Right/Left", "options": "o, Tab", "back": "Esc, Backspace",
|
|
"stop": "Esc, Ctrl-C", "kill": None, "enter": "Enter", "esc": "Esc"},
|
|
"gptokeyb": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn",
|
|
"ok": "A, Start, Enter", "tick": "X, Space, Right/Left",
|
|
"options": "Y, Tab, o", "back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C",
|
|
"kill": "Select, then Start", "enter": "A, Enter", "esc": "B, Esc"},
|
|
"evmapy": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
|
|
"tick": "X, Space, Right/Left", "options": "Y, Tab, o",
|
|
"back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None,
|
|
"enter": "A, Enter", "esc": "B, Esc"},
|
|
# muxterm keeps PgUp and PgDn for its own scrollback, and its L1 and R1
|
|
# send nothing: no key pages a list there.
|
|
"muxterm": {"move": "D-pad, Up/Down", "page": None, "ok": "Start, Enter",
|
|
"tick": "Y, Space, Right/Left", "options": "o, Tab",
|
|
"back": "B, Esc, Backspace", "stop": "B, Esc, Ctrl-C", "kill": None,
|
|
"enter": "Start, Enter", "esc": "Esc"},
|
|
"miyoo": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
|
|
"tick": "A, Space, Right/Left", "options": "L2, Tab, o",
|
|
"back": "R2, Esc, Backspace", "stop": "R2, Esc, Ctrl-C", "kill": None,
|
|
"enter": "Start, Enter", "esc": "Esc"},
|
|
}
|
|
# muxterm keeps PgUp and PgDn for its scrollback even when gptokeyb2 owns the
|
|
# pad, and the launcher sends nothing on L1 and R1 there.
|
|
_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": None}
|
|
|
|
|
|
def _route_extras(state: TuiState) -> list[tuple[str, str]]:
|
|
"""What a route's own buttons do that no key of the table says."""
|
|
if state.pad == "muxterm":
|
|
return [("Select", "Keyboard on or off"), ("Menu", "Close the terminal")]
|
|
if state.pad == "miyoo":
|
|
return [("B", "Sends no key here")]
|
|
return []
|
|
|
|
|
|
def _help_lines(state: TuiState) -> list[tuple[str, str]]:
|
|
screen = state.screen
|
|
names = _HELP_KEYS.get(state.pad, _HELP_KEYS["both"])
|
|
# gptokeyb's kill: Start pressed first also chooses the row in focus.
|
|
kill = ([(names["kill"], "Quit at once")]
|
|
if state.pad in ("gptokeyb", "gptokeyb_muxterm") else [])
|
|
if screen == "installing":
|
|
return [(names["stop"], "Stop: what is done stays done"),
|
|
*([(names["kill"], "Stop")] if names["kill"] else []),
|
|
*_route_extras(state), ("?, F1", "This help")]
|
|
if screen == "path":
|
|
return [(names["enter"], "Use this folder"), ("Backspace", "Delete a character"),
|
|
("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"),
|
|
*_route_extras(state)]
|
|
keys = [(names["move"], "Move")]
|
|
if len(tui_rows(state)) > 9 or screen in _PAGERS:
|
|
keys += [*([(names["page"], "Page")] if names["page"] else []),
|
|
("Home/End, g/G", "First, last")]
|
|
primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0]
|
|
ok = names["ok"]
|
|
if state.pad == "miyoo" and screen in _CHECKLISTS:
|
|
ok = names["enter"]
|
|
keys.append((ok, primary.split(" ", 1)[-1]))
|
|
if screen in _CHECKLISTS:
|
|
keys.append((names["tick"], "Tick, untick"))
|
|
if screen == "systems":
|
|
keys.append(("a, n", "All, none"))
|
|
if screen in ("platforms", "systems", "cores"):
|
|
keys.append(("/", "Filter (Esc clears)"))
|
|
if screen == "regions":
|
|
keys.append(("Left/Right", "Move a region up or down the order"))
|
|
if screen == "options":
|
|
keys.append(("Left/Right", "Change a value"))
|
|
if screen in ("plan", "systems", "confirm"):
|
|
keys.append((names["options"], "Options"))
|
|
back = "Quit" if len(state.stack) == 1 else "Back"
|
|
keys += [(names["back"], back), ("q, Ctrl-C", "Quit"), *kill, *_route_extras(state),
|
|
("?, F1", "This help")]
|
|
return keys
|
|
|
|
|
|
def _primary(state: TuiState) -> str | None:
|
|
"""What A does on the row in focus, for the first entry of the bar."""
|
|
if state.screen in _PAGERS:
|
|
return None
|
|
rows = tui_rows(state)
|
|
if not rows:
|
|
return None
|
|
row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))]
|
|
verb = _ROW_VERBS.get(row.action)
|
|
return f"A/Enter {verb}" if verb else None
|
|
|
|
|
|
def _view_help(state: TuiState, width: int, height: int) -> tuple:
|
|
keys = _help_lines(state)
|
|
column = min(22, max(cell_width(key) for key, _what in keys) + 2)
|
|
lines = [f"{_pad(key, column)}{what}" for key, what in keys]
|
|
if state.screen in ("setup", "plan", "browse", "folder") and state.dest is not None:
|
|
lines += ["", f"BIOS folder: {state.dest}"]
|
|
return ("Keys", _compose(state, width, height, lines, []), ("Any key closes this help",))
|
|
|
|
|
|
|
|
|
|
def tui_view(state: TuiState, cols: int, rows: int) -> list[str]:
|
|
"""The screen as rows lines of at most cols cells, from the state alone."""
|
|
if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS:
|
|
lines = [_styled("")] * rows
|
|
text = fit("Make the window larger", cols)
|
|
if rows:
|
|
lines[rows // 2] = _styled(" " * max(0, (cols - cell_width(text)) // 2) + text)
|
|
return lines
|
|
width = min(cols, TUI_MAX_WIDTH) - 2
|
|
height = rows - 3
|
|
view = _view_help if state.help else _TUI_VIEWS[state.screen]
|
|
title, body, hint = view(state, width, height)
|
|
primary = _primary(state) if not state.help and hint[0].startswith("A/Enter") else None
|
|
if primary:
|
|
hint = (primary, *hint[1:])
|
|
hint = _route_hint(state, hint)
|
|
lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")]
|
|
lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]]
|
|
lines += [_styled("")] * (rows - 1 - len(lines))
|
|
# Never the last cell of the screen: a terminal may scroll when it is written.
|
|
lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "dim"))
|
|
return lines
|
|
|
|
def _setup_label(plat: str, path: Path) -> str:
|
|
system = detected_os(plat, path)
|
|
name = display_name(plat)
|
|
return f"{system} ({name} files)" if system and system != name else name
|
|
|
|
|
|
def _install_kind(path: Path) -> str:
|
|
"""What sets one install of a frontend apart from another of the same."""
|
|
# The config detection read it from says how it was installed: the
|
|
# Flatpak's system directory can be anywhere its config names.
|
|
found = _RETROARCH_CONFIGS.get(path)
|
|
text = path.as_posix() + (" " + Path(found[0]).as_posix() if found else "")
|
|
for marker, word in (("/.var/app/", "Flatpak"), ("/steamapps/", "Steam"),
|
|
("/snap/", "Snap")):
|
|
if marker in text:
|
|
return word
|
|
if (path.parent / "retroarch.cfg").is_file():
|
|
return "portable"
|
|
return ""
|
|
|
|
|
|
def tell_apart(setups: list[tuple]) -> list[tuple]:
|
|
"""Two setups that read alike get the word that separates them: the
|
|
Flatpak and the native RetroArch were both "RetroArch" at 40 columns.
|
|
The one no package names is "installed", and two of those keep the
|
|
end of their folder, the only thing that tells them apart."""
|
|
labels = [label for _plat, _path, label in setups]
|
|
kinds = [_install_kind(path) or "installed" for _plat, path, _label in setups]
|
|
named = list(zip(labels, kinds))
|
|
return [(plat, path, label if labels.count(label) == 1
|
|
else f"{label} ({kind})" if named.count((label, kind)) == 1
|
|
else f"{label} ({'/'.join(path.parts[-2:])})")
|
|
for (plat, path, label), kind in zip(setups, kinds)]
|
|
|
|
|
|
def _tui_passthrough(args: argparse.Namespace) -> list[tuple[str, str | None]]:
|
|
"""Options the screens do not change, handed to every run unchanged."""
|
|
flags: list[tuple[str, str | None]] = []
|
|
for flag in ("--target", "--romm-firmware"):
|
|
value = getattr(args, flag[2:].replace("-", "_"))
|
|
if value is not None:
|
|
flags.append((flag, str(value)))
|
|
for flag in ("--save-dir", "--cores-dir"):
|
|
value = getattr(args, flag[2:].replace("-", "_"))
|
|
if value is not None:
|
|
flags.append((flag, str(Path(value).absolute())))
|
|
flags += [("--emulator", name) for name in args.emulator or []]
|
|
flags += [("--from-pack", str(Path(pack).absolute())) for pack in args.from_pack or []]
|
|
for flag in ("--every-board", "--standalone-copies", "--verbose"):
|
|
if getattr(args, flag[2:].replace("-", "_")):
|
|
flags.append((flag, None))
|
|
return flags
|
|
|
|
|
|
def tui_start(
|
|
args: argparse.Namespace, os_type: str, detect: bool = True,
|
|
) -> tuple[TuiState, list[tuple]]:
|
|
"""The first screen, from what is found here and what the command named."""
|
|
state = TuiState(os_type=os_type, home=Path.home())
|
|
state.pad = tui_pad_route(os_type)
|
|
state.cli_systems = list(args.system or [])
|
|
state.regions = resolve_regions(args.region) if args.region else []
|
|
state.cli_regions = list(state.regions)
|
|
state.from_folders = [Path(folder).absolute() for folder in getattr(args, "from") or []]
|
|
state.cores = list(dict.fromkeys(
|
|
token.strip() for value in args.core or [] for token in value.split(",")
|
|
if token.strip()))
|
|
state.no_game_data = bool(args.no_game_data)
|
|
state.installed_only = bool(args.installed_cores)
|
|
state.jobs = args.jobs
|
|
state.passthrough = _tui_passthrough(args)
|
|
if args.manifest is not None:
|
|
state.manifest_path = Path(args.manifest).absolute()
|
|
state.manifest_given = True
|
|
plat = normalize_platform(args.platform) if args.platform else ""
|
|
if detect:
|
|
captured = io.StringIO()
|
|
with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured):
|
|
found = detect_platforms(os_type)
|
|
frontends = detect_frontends(os_type)
|
|
cards = [(card_system(mount), mount) for mount in _mounted_cards()]
|
|
state.setups = [(name, path, _setup_label(name, path)) for name, path in found]
|
|
# On the device itself its own card is the system found, by its
|
|
# own path (muOS reads /run/muos/storage/bios, a bind of MUOS/bios).
|
|
here = {detected_os(name, path) for name, path in found}
|
|
for card, mount in cards:
|
|
if card and (card[0], card[1]) not in found and card[2] not in here:
|
|
_DETECTED_OS.setdefault((card[0], card[1]), card[2])
|
|
state.setups.append((card[0], card[1],
|
|
f"{card[2]} SD card ({_mount_name(mount)})"))
|
|
state.setups = tell_apart(state.setups)
|
|
if "esde" in frontends:
|
|
state.notes.append("ES-DE found: its BIOS files go to each emulator, "
|
|
"not to ES-DE itself.")
|
|
state.notes += [line.strip() for line in captured.getvalue().splitlines()
|
|
if line.strip()]
|
|
if plat and args.dest is not None:
|
|
state.stack = []
|
|
return state, _choose(state, plat, Path(args.dest))
|
|
if plat:
|
|
matched = [setup for setup in state.setups if setup[0] == plat]
|
|
if len(matched) == 1:
|
|
state.stack = []
|
|
return state, _choose(state, plat, matched[0][1])
|
|
if matched:
|
|
state.setups = matched
|
|
return state, []
|
|
state.platform = plat
|
|
state.stack = []
|
|
default = None if plat in CARD_PLATFORMS else _default_dest(os_type, plat)
|
|
return state, _open_folder_screen(state, default)
|
|
if args.dest is not None:
|
|
card = card_platform(Path(args.dest))
|
|
if card:
|
|
state.stack = []
|
|
return state, _choose(state, card, Path(args.dest))
|
|
state.dest_given = Path(args.dest)
|
|
if state.dest_given is not None or not state.setups:
|
|
found_names = list(dict.fromkeys(setup[0] for setup in state.setups))
|
|
default = found_names[0] if len(found_names) == 1 else "retroarch"
|
|
state.stack = ["platforms"]
|
|
state.focus["platforms"] = _platform_focus(state, default)
|
|
return state, []
|
|
|
|
|
|
def _log_tail(path: Path, count: int = 12) -> list[str]:
|
|
"""The last lines a child wrote for a person, which say why it stopped."""
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
return [f"The log {path} cannot be read: {exc.strerror or exc}"]
|
|
lines = [line.rstrip() for line in text.replace("\r", "\n").splitlines()]
|
|
# The banner says which installer ran, not what went wrong.
|
|
return [line for line in lines if line.strip() and not line.startswith("RetroBIOS ")
|
|
][-count:]
|
|
|
|
|
|
class _EngineRun:
|
|
"""This installer run as a child with --events, read line by line."""
|
|
|
|
def __init__(
|
|
self, argv: list[str], log: Path, inbox: queue.Queue, run_id: int,
|
|
env: dict | None = None,
|
|
) -> None:
|
|
self.log = log
|
|
# Its own session: a signal typed at the terminal reaches the
|
|
# interface alone, which forwards one Ctrl-C and waits for the summary.
|
|
options: dict = {} if os.name == "nt" else {"start_new_session": True}
|
|
if env is not None:
|
|
options["env"] = env
|
|
with open(log, "w", encoding="utf-8", errors="replace") as handle:
|
|
self.proc = subprocess.Popen(
|
|
argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=handle,
|
|
text=True, encoding="utf-8", errors="replace", **options,
|
|
)
|
|
threading.Thread(target=self._read, args=(inbox, run_id), daemon=True).start()
|
|
|
|
def _read(self, inbox: queue.Queue, run_id: int) -> None:
|
|
assert self.proc.stdout is not None
|
|
for line in self.proc.stdout:
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(event, dict):
|
|
inbox.put(("event", run_id, event))
|
|
code = self.proc.wait()
|
|
inbox.put(("ended", run_id, code, _log_tail(self.log)))
|
|
|
|
def running(self) -> bool:
|
|
return self.proc.poll() is None
|
|
|
|
def interrupt(self, from_key: bool) -> None:
|
|
if not self.running():
|
|
return
|
|
if os.name != "nt":
|
|
self.proc.send_signal(signal.SIGINT)
|
|
elif from_key:
|
|
# The child shares the console: Ctrl-C read as a key reached no
|
|
# one, so it is sent to the console, this process ignoring it.
|
|
os.kill(0, signal.CTRL_C_EVENT) # type: ignore[attr-defined]
|
|
|
|
def stop(self, grace: float) -> None:
|
|
"""End the child, letting it write its summary for up to grace seconds."""
|
|
if not self.running():
|
|
return
|
|
if grace:
|
|
self.interrupt(from_key=True)
|
|
try:
|
|
self.proc.wait(timeout=grace)
|
|
return
|
|
except subprocess.TimeoutExpired:
|
|
# It ignored Ctrl-C for that long: it is stopped outright.
|
|
self.proc.kill()
|
|
self.proc.kill()
|
|
self.proc.wait()
|
|
|
|
|
|
# One try, for a screen with Try again on it: three tries of 30 s were 85 s
|
|
# of a bare timer before the error said there was no network.
|
|
TUI_FETCH_TIMEOUT = 15
|
|
|
|
|
|
TUI_OFFLINE_TITLE = "No internet: offline mode"
|
|
|
|
|
|
def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None:
|
|
"""The file list, fetched once in this process, or what went wrong."""
|
|
if LOCAL.manifest is not None or LOCAL.pack is not None:
|
|
mark = len(log.getvalue())
|
|
try:
|
|
inbox.put(("manifest", plat, fetch_manifest(plat)))
|
|
except SystemExit as exc:
|
|
inbox.put(("manifest_failed", plat, "", log.getvalue()[mark:].splitlines(),
|
|
_event_exit_code(exc.code)))
|
|
return
|
|
url = MANIFEST_URL.format(platform=plat)
|
|
try:
|
|
data = _fetch_json(url, MAX_MANIFEST_BYTES, "file list", attempts=1,
|
|
timeout=TUI_FETCH_TIMEOUT)
|
|
inbox.put(("manifest", plat, _validate_manifest(data, plat)))
|
|
return
|
|
except ValueError as exc:
|
|
title, lines = "The file list is not valid", [f"What arrived is not a file list: {exc}."]
|
|
if isinstance(exc.__cause__, (json.JSONDecodeError, UnicodeDecodeError)):
|
|
# The parser's "Expecting value: line 2 column 1" told no one anything.
|
|
lines = ["What arrived is not a file list: a Wi-Fi sign-in page or a cut "
|
|
"download looks like this.", "Check the connection, then choose Try again."]
|
|
except urllib.error.HTTPError as exc:
|
|
title = "No file list"
|
|
lines = [f"There is no file list for {display_name(plat)} at this address."
|
|
if exc.code == 404 else f"The server answered HTTP {exc.code}."]
|
|
except (urllib.error.URLError, OSError) as exc:
|
|
code, words = network_words(exc)
|
|
if code == "offline":
|
|
# Asked for, not a failure: no Try again, and the two ways a
|
|
# run without the network gets its list.
|
|
inbox.put(("manifest_failed", plat, TUI_OFFLINE_TITLE, [
|
|
"This run makes no network request (--offline), and the list of "
|
|
"BIOS files comes from the network.",
|
|
"Run the installer again with the list saved as a file "
|
|
"(--manifest FILE, a copy of install/<platform>.json) or with a pack "
|
|
"(--from-pack PACK), or without --offline."], 1))
|
|
return
|
|
title = {"clock": "The clock is wrong",
|
|
"no_ca": "No trusted certificates"}.get(code, "No connection")
|
|
# The screen's first row is Try again: "then run again" was a second way.
|
|
lines = [words[0].upper() + words[1:].replace(", then run again",
|
|
", then choose Try again") + "."]
|
|
if code == "network":
|
|
lines = [f"The list of BIOS files could not be downloaded: {words}.",
|
|
"Check the Wi-Fi or the cable, then choose Try again."]
|
|
proxy = next((name for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY",
|
|
"all_proxy") if os.environ.get(name)), "")
|
|
if proxy:
|
|
# Named, never shown: the value can hold a password.
|
|
lines.append(f"{proxy} is set, so the connection goes through a proxy: "
|
|
"check that it answers.")
|
|
if os.environ.get("RETROBIOS_BASE_URL"):
|
|
lines.append(f"RETROBIOS_BASE_URL chose the address: {url}")
|
|
inbox.put(("manifest_failed", plat, title, lines, 1))
|
|
|
|
|
|
class _TuiTerminal:
|
|
"""The terminal in raw mode: keys in, frames out, put back as found."""
|
|
|
|
def __init__(self, stdin: TextIO, stdout: TextIO) -> None:
|
|
self.stdin, self.stdout = stdin, stdout
|
|
self.fd = stdin.fileno()
|
|
self.saved: list | None = None
|
|
self.decoder = KeyDecoder()
|
|
self.text = codecs.getincrementaldecoder(
|
|
getattr(stdin, "encoding", None) or "utf-8")(errors="replace")
|
|
self.encoding = getattr(stdout, "encoding", None) or "utf-8"
|
|
locale_name = os.environ.get("LC_ALL") or os.environ.get("LANG") or ""
|
|
try:
|
|
utf8 = codecs.lookup(self.encoding).name == "utf-8"
|
|
except LookupError:
|
|
utf8 = False
|
|
# Python turns a C locale into UTF-8 for itself, not for the
|
|
# terminal: a console font there has no CJK, often no accents.
|
|
self.ascii = not utf8 or locale_name in ("C", "POSIX")
|
|
self.ended = False
|
|
self.heard = 0.0
|
|
self.frame: list[tuple[str, str]] = []
|
|
self.entered = False
|
|
|
|
def enter(self) -> None:
|
|
if os.name != "nt":
|
|
import termios # noqa: PLC0415
|
|
|
|
# Entered again after a stop, the mode to go back to stays the
|
|
# one found the first time.
|
|
if self.saved is None:
|
|
self.saved = termios.tcgetattr(self.fd)
|
|
mode = termios.tcgetattr(self.fd)
|
|
mode[0] &= ~(termios.ICRNL | termios.INLCR | termios.IGNCR | termios.IXON)
|
|
# ISIG off: Ctrl-C arrives as a key and is forwarded once, by hand.
|
|
mode[3] &= ~(termios.ICANON | termios.ECHO | termios.ISIG | termios.IEXTEN)
|
|
mode[6][termios.VMIN] = 1
|
|
mode[6][termios.VTIME] = 0
|
|
termios.tcsetattr(self.fd, termios.TCSANOW, mode)
|
|
self.entered = True
|
|
self.write(_SCREEN_ON)
|
|
|
|
def leave(self) -> None:
|
|
"""The main screen and the mode found, the mode even when the write
|
|
fails: a closed window answers EIO."""
|
|
try:
|
|
# Never entered (stopped while it started): the main screen and
|
|
# the cursor are as they were, nothing is written over them.
|
|
if getattr(self, "entered", True):
|
|
self.entered = False
|
|
self.write(_SCREEN_OFF)
|
|
finally:
|
|
if self.saved is not None:
|
|
import termios # noqa: PLC0415
|
|
|
|
saved, self.saved = self.saved, None
|
|
termios.tcsetattr(self.fd, termios.TCSADRAIN, saved)
|
|
|
|
def write(self, text: str) -> None:
|
|
self.stdout.write(text.encode(self.encoding, "replace").decode(self.encoding))
|
|
self.stdout.flush()
|
|
|
|
def size(self) -> tuple[int, int]:
|
|
return _terminal_size(self.stdout)
|
|
|
|
def draw(self, lines: list[str], cols: int) -> None:
|
|
"""Rewrite the lines that changed since the last frame, and only those."""
|
|
out = [] if self.frame else ["\x1b[H\x1b[2J"]
|
|
frame = [(str(line), getattr(line, "style", "")) for line in lines]
|
|
for index, (text, style) in enumerate(frame):
|
|
if index < len(self.frame) and self.frame[index] == (text, style):
|
|
continue
|
|
text = screen_text(text)
|
|
if getattr(self, "ascii", False):
|
|
text = ascii_text(text)
|
|
if style == "focus":
|
|
text = _pad(text, min(cols, TUI_MAX_WIDTH) - (index == len(frame) - 1))
|
|
out.append(f"\x1b[{index + 1};1H{_STYLES.get(style, '')}{text}\x1b[0m\x1b[K")
|
|
self.frame = frame
|
|
if out:
|
|
self.write("".join(out))
|
|
|
|
def keys(self, timeout: float) -> list[TuiKey]:
|
|
if os.name == "nt":
|
|
return self._console_keys(timeout)
|
|
if self.ended:
|
|
time.sleep(timeout)
|
|
return []
|
|
import select # noqa: PLC0415
|
|
|
|
if not select.select([self.fd], [], [], timeout)[0]:
|
|
# A paste whose end never came, after a second of silence.
|
|
if self.decoder.paste is not None and time.monotonic() - self.heard > 1:
|
|
return self.decoder.close_paste()
|
|
return []
|
|
keys = self._read()
|
|
while self.decoder.buffer and not self.ended:
|
|
if select.select([self.fd], [], [], TUI_ESC_WAIT)[0]:
|
|
keys += self._read()
|
|
else:
|
|
keys += self.decoder.flush()
|
|
break
|
|
return keys
|
|
|
|
def _read(self) -> list[TuiKey]:
|
|
data = os.read(self.fd, 4096)
|
|
self.heard = time.monotonic()
|
|
if not data:
|
|
# The terminal went away: what Ctrl-D at a question does.
|
|
self.ended = True
|
|
return [TuiKey("ctrl-d")]
|
|
return self.decoder.feed(self.text.decode(data))
|
|
|
|
def _console_keys(self, timeout: float) -> list[TuiKey]:
|
|
import msvcrt # noqa: PLC0415
|
|
|
|
end = time.monotonic() + timeout
|
|
keys: list[TuiKey] = []
|
|
while True:
|
|
while msvcrt.kbhit(): # type: ignore[attr-defined]
|
|
key = decode_windows_key(msvcrt.getwch(), # type: ignore[attr-defined]
|
|
msvcrt.getwch) # type: ignore[attr-defined]
|
|
if key is not None:
|
|
keys.append(key)
|
|
if keys or time.monotonic() >= end:
|
|
return keys
|
|
time.sleep(0.01)
|
|
|
|
|
|
TUI_WORK_PREFIX = "retrobios-tui-"
|
|
# The interface's process id, in its work folder: a folder whose process is
|
|
# gone was left by a kill no handler sees (SIGKILL, a power cut).
|
|
TUI_OWNER = "owner"
|
|
# Written when a failed run's log is kept: it stays a week, then goes.
|
|
TUI_KEPT = "kept"
|
|
TUI_KEEP_SECONDS = 7 * 24 * 3600
|
|
|
|
|
|
def _owner_alive(folder: Path) -> bool | None:
|
|
"""Whether the run that made folder is still running, None when unknown."""
|
|
try:
|
|
pid = int((folder / TUI_OWNER).read_text(encoding="utf-8").strip())
|
|
except (OSError, ValueError):
|
|
return None
|
|
if os.name == "nt" or pid <= 0:
|
|
# os.kill(pid, 0) ends a process on Windows instead of asking, and
|
|
# 0 or a negative number names a process group, not the run.
|
|
return None
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
except (OSError, OverflowError):
|
|
# A number past what a pid holds is no process this run can ask.
|
|
return None
|
|
return True
|
|
|
|
|
|
def _mark_kept(work: Path) -> None:
|
|
try:
|
|
(work / TUI_KEPT).write_text("", encoding="utf-8")
|
|
except OSError as exc:
|
|
# The log is still there, only its week is not counted.
|
|
print(f"The log folder {work} could not be marked: {exc}", file=sys.stderr)
|
|
|
|
|
|
def _prune_work_folders(root: Path, now: float) -> list[Path]:
|
|
"""Remove what earlier runs of the interface left in root: the folder of
|
|
a run killed outright, and a failed run's log after a week. A folder
|
|
whose run is still going, or unknown and recent, stays."""
|
|
removed = []
|
|
try:
|
|
folders = [path for path in root.glob(TUI_WORK_PREFIX + "*") if path.is_dir()]
|
|
except OSError:
|
|
return removed
|
|
for folder in folders:
|
|
try:
|
|
age = now - folder.stat().st_mtime
|
|
kept = (folder / TUI_KEPT).exists()
|
|
except OSError:
|
|
continue
|
|
alive = _owner_alive(folder)
|
|
if alive or ((alive is None or kept) and age < TUI_KEEP_SECONDS):
|
|
continue
|
|
shutil.rmtree(folder, ignore_errors=True)
|
|
removed.append(folder)
|
|
return removed
|
|
|
|
|
|
def tui_farewell(state: TuiState) -> list[str]:
|
|
"""What stays in the terminal once the full screen is gone."""
|
|
return [screen_text(line) for line in _farewell_lines(state)]
|
|
|
|
|
|
def _farewell_lines(state: TuiState) -> list[str]:
|
|
if state.idle_closed:
|
|
return [f"RetroBIOS: {TUI_IDLE_WORDS}"]
|
|
if state.screen != "result" and state.download is not None:
|
|
# An install ran: what it wrote is said, whatever ended it.
|
|
title, lines = tui_outcome(state)
|
|
return [f"RetroBIOS: {title}", *lines]
|
|
if state.screen == "crashed":
|
|
return [f"RetroBIOS stopped on an unexpected error. What it was is in {state.log}."]
|
|
if state.screen == "result":
|
|
title, lines = tui_outcome(state)
|
|
return [f"RetroBIOS: {title}", *lines]
|
|
if state.screen == "error":
|
|
return [f"RetroBIOS: {state.error_title or 'something went wrong'}",
|
|
*(line.replace("then choose Try again", "then run the installer again")
|
|
for line in state.error)]
|
|
if state.exit_code == 0:
|
|
return [f"RetroBIOS: everything is in place in {state.dest}."]
|
|
return ["RetroBIOS: stopped. Nothing was installed."]
|
|
|
|
|
|
def run_tui(args: argparse.Namespace) -> int:
|
|
"""The full-screen interface, until it closes: the exit code it ends with."""
|
|
flags = {"interrupt": False, "resized": False, "suspend": False, "resume": False,
|
|
"term": 0}
|
|
handlers: dict[int, object] = {}
|
|
|
|
def on_stop(signum: int, _frame: object) -> None:
|
|
flags["term"] = flags["term"] or signum
|
|
|
|
# Before the start, whose detection and pack opening take seconds on a
|
|
# handheld: a Start+Select then ended on the default action, without a
|
|
# word or the terminal put back.
|
|
for name in ("SIGTERM", "SIGHUP"):
|
|
number = getattr(signal, name, None)
|
|
if number is not None:
|
|
handlers[number] = signal.signal(number, on_stop)
|
|
try:
|
|
started = _tui_startup(args)
|
|
except BaseException:
|
|
_restore_handlers(handlers)
|
|
raise
|
|
if started is None:
|
|
_restore_handlers(handlers)
|
|
return 1
|
|
state, effects = started
|
|
if flags["term"]:
|
|
state.exit_code = 128 + flags["term"]
|
|
try:
|
|
work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX))
|
|
except OSError as exc:
|
|
_restore_handlers(handlers)
|
|
print(f"Error: no work folder could be made in {tempfile.gettempdir()}: {exc}. "
|
|
"Free some space there, or point TMPDIR (TEMP on Windows) at another "
|
|
"folder.", file=sys.stderr)
|
|
return 1
|
|
try:
|
|
(work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8")
|
|
except OSError as exc:
|
|
# A full temp disk: the folder has no owner, and a later run leaves
|
|
# it a week like any unknown one. The logs fail as they come.
|
|
print(f"The work folder {work} could not be marked as this run's: {exc}",
|
|
file=sys.stderr)
|
|
script = str(Path(__file__).resolve())
|
|
inbox: queue.Queue = queue.Queue()
|
|
current: list[_EngineRun] = []
|
|
written: dict[str, Path] = {}
|
|
# Start+Select on a handheld is gptokeyb's pkill: SIGTERM, then SIGKILL
|
|
# 3 s later. The install gets 2 s to say what it wrote.
|
|
terminating = {"signal": 0, "deadline": 0.0}
|
|
ignore_interrupt_until = [0.0]
|
|
terminal = _TuiTerminal(sys.stdin, sys.stdout)
|
|
real_out, real_err = sys.stdout, sys.stderr
|
|
buffer = io.StringIO()
|
|
|
|
def perform(todo: list[tuple], from_key: bool = True) -> None:
|
|
for effect in todo:
|
|
kind = effect[0]
|
|
if kind == "fetch":
|
|
threading.Thread(target=_tui_fetch, args=(effect[1], inbox, buffer),
|
|
daemon=True).start()
|
|
elif kind == "run":
|
|
for run in current:
|
|
run.stop(0)
|
|
current.clear()
|
|
if not state.manifest_given and not args.from_pack and state.manifest:
|
|
if state.platform not in written:
|
|
path = work / f"{state.platform}.json"
|
|
path.write_text(json.dumps(state.manifest), encoding="utf-8")
|
|
written[state.platform] = path
|
|
state.manifest_path = written[state.platform]
|
|
log = work / f"{effect[1]}.log"
|
|
if effect[1] == "install":
|
|
state.log = str(log)
|
|
own_list = state.manifest_path in written.values()
|
|
try:
|
|
current.append(_EngineRun(
|
|
tui_argv(state, effect[1], sys.executable, script), log, inbox,
|
|
state.run_id, {**os.environ, TUI_LIST_ENV: "1"} if own_list else None))
|
|
except OSError as exc:
|
|
inbox.put(("ended", state.run_id, 1,
|
|
[f"The installer could not be started: {exc}"]))
|
|
elif kind == "stop":
|
|
for run in current:
|
|
run.interrupt(from_key)
|
|
if os.name == "nt" and from_key:
|
|
ignore_interrupt_until[0] = time.monotonic() + 1.0
|
|
elif kind == "kill":
|
|
for run in current:
|
|
run.stop(0)
|
|
|
|
def on_interrupt(_signum: int, _frame: object) -> None:
|
|
flags["interrupt"] = True
|
|
|
|
def on_resize(_signum: int, _frame: object) -> None:
|
|
flags["resized"] = True
|
|
|
|
def on_suspend(_signum: int, _frame: object) -> None:
|
|
flags["suspend"] = True
|
|
|
|
def on_resume(_signum: int, _frame: object) -> None:
|
|
flags["resume"] = True
|
|
|
|
try:
|
|
for name, handler in (("SIGINT", on_interrupt), ("SIGWINCH", on_resize),
|
|
("SIGTSTP", on_suspend), ("SIGCONT", on_resume)):
|
|
number = getattr(signal, name, None)
|
|
if number is not None:
|
|
handlers[number] = signal.signal(number, handler)
|
|
idle = tui_idle_exit()
|
|
# Counted from the moment the screen last waited for a key with no
|
|
# run going: given --platform, the first screen moves on by itself
|
|
# to the plan once the check is read, and no key comes either.
|
|
quiet_since, pressed = time.monotonic(), False
|
|
sys.stdout = sys.stderr = buffer
|
|
try:
|
|
_prune_work_folders(work.parent, time.time())
|
|
except OSError as exc:
|
|
# What earlier runs left stays until a later run: this one opens.
|
|
print(f"Earlier work folders could not be removed: {exc}", file=sys.stderr)
|
|
if state.exit_code is None:
|
|
# Stopped while it started: the screen never opens.
|
|
terminal.enter()
|
|
perform(effects)
|
|
while state.exit_code is None:
|
|
if flags["suspend"]:
|
|
# Stopped from outside (kill -TSTP): the shell gets its
|
|
# terminal back as it lent it, and the screen returns on fg.
|
|
flags["suspend"] = False
|
|
terminal.leave()
|
|
os.kill(os.getpid(), signal.SIGSTOP) # type: ignore[attr-defined]
|
|
flags["resume"] = True
|
|
if flags["resume"]:
|
|
flags["resume"] = False
|
|
terminal.enter()
|
|
terminal.frame = []
|
|
cols, rows = terminal.size()
|
|
if (cols, rows) != (state.cols, state.rows) or flags["resized"]:
|
|
flags["resized"] = False
|
|
state.cols, state.rows = cols, rows
|
|
terminal.frame = []
|
|
state.now = time.monotonic()
|
|
terminal.draw(tui_view(state, cols, rows), cols)
|
|
for key in terminal.keys(TUI_FRAME):
|
|
pressed = True
|
|
state.now = time.monotonic()
|
|
perform(tui_update(state, key))
|
|
if state.exit_code is not None:
|
|
break
|
|
if state.running:
|
|
quiet_since = time.monotonic()
|
|
elif (idle is not None and not pressed and state.exit_code is None
|
|
and time.monotonic() - quiet_since >= idle):
|
|
state.idle_closed, state.exit_code = True, 0
|
|
if flags["interrupt"]:
|
|
flags["interrupt"] = False
|
|
if time.monotonic() >= ignore_interrupt_until[0]:
|
|
perform(tui_interrupt(state), from_key=False)
|
|
if flags["term"] and not terminating["signal"]:
|
|
terminating["signal"] = flags["term"]
|
|
if state.run == "install" and state.running:
|
|
terminating["deadline"] = time.monotonic() + 2
|
|
if not state.stopping:
|
|
perform(tui_interrupt(state))
|
|
elif state.screen == "result" and state.code is not None:
|
|
state.exit_code = state.code
|
|
else:
|
|
state.exit_code = 128 + terminating["signal"]
|
|
if terminating["signal"] and state.running and \
|
|
time.monotonic() > terminating["deadline"]:
|
|
perform([("kill",)])
|
|
while state.exit_code is None:
|
|
try:
|
|
message = inbox.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
state.now = time.monotonic()
|
|
perform(tui_receive(state, message))
|
|
if terminating["signal"] and state.screen == "result":
|
|
# The result is printed in the terminal on the way out.
|
|
state.exit_code = state.code if state.code is not None else 1
|
|
except Exception as exc: # noqa: BLE001
|
|
if flags["term"] or _closed_terminal(exc, terminal.fd):
|
|
# The window closed under a screen that was changing, or the
|
|
# SIGCONT that comes with its SIGHUP put the screen back: the
|
|
# write failed before the signal was read. That is its exit.
|
|
state.exit_code = 128 + (flags["term"] or getattr(signal, "SIGHUP", 1))
|
|
else:
|
|
_tui_crashed(state, work)
|
|
finally:
|
|
# Each step on its own: a write the closed window refuses, or a
|
|
# second signal while the install writes its summary, must not
|
|
# skip the ones after it and leave the terminal raw.
|
|
problems = _tui_cleanup(terminal, current, handlers, state)
|
|
sys.stdout, sys.stderr = real_out, real_err
|
|
for path in written.values():
|
|
if path.exists():
|
|
path.unlink()
|
|
if not tui_keeps_log(state):
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
else:
|
|
_mark_kept(work)
|
|
stream = sys.stderr if state.screen in ("error", "crashed") else sys.stdout
|
|
lines = tui_farewell(state)
|
|
if problems:
|
|
lines.append(f"The terminal may not be restored ({problems[0]}): type reset "
|
|
"and press Enter if it looks wrong.")
|
|
try:
|
|
for line in lines:
|
|
print(line, file=stream)
|
|
stream.flush()
|
|
except OSError:
|
|
# The terminal is gone (a closed window answers EIO, a closed pipe
|
|
# EPIPE): the exit code is all that still reaches anyone, and the
|
|
# flush at exit would replace it with 120.
|
|
_drop_closed_terminal()
|
|
return state.exit_code if state.exit_code is not None else 1
|
|
|
|
|
|
def _tui_startup(args: argparse.Namespace) -> tuple[TuiState, list[tuple]] | None:
|
|
"""The local sources opened and the first screen chosen, None when a
|
|
--from folder cannot be read (said on stderr)."""
|
|
os_type = _INVOCATION.os_type or detect_os()
|
|
if os_type == "wsl":
|
|
_enter_wsl()
|
|
# The --from folders are read by each check, not here: indexing a large
|
|
# card held the first frame back. Only whether each can be read is asked.
|
|
folders = getattr(args, "from") or []
|
|
setattr(args, "from", None)
|
|
try:
|
|
_open_local_sources(args)
|
|
finally:
|
|
setattr(args, "from", folders)
|
|
for folder in folders:
|
|
problem = from_folder_problem(str(folder))
|
|
if problem is not None:
|
|
print(f"Error: {screen_text(str(folder))} {problem[1]}: --from names the folder "
|
|
"to copy BIOS files from.", file=sys.stderr)
|
|
return None
|
|
return tui_start(args, os_type)
|
|
|
|
|
|
# What a write to a closed window or a closed pipe answers.
|
|
_CLOSED_TERMINAL = frozenset({errno.EIO, errno.EPIPE, errno.ENXIO})
|
|
|
|
|
|
def _closed_terminal(exc: BaseException, fd: int | None = None) -> bool:
|
|
"""Whether exc is the terminal gone: an OSError, or a termios.error,
|
|
whose first argument is the errno, answering EIO, EPIPE or ENXIO, and,
|
|
given the terminal's fd, that terminal no longer answering. A failing
|
|
SD card answers EIO too, and that read error is kept in a crash log."""
|
|
if isinstance(exc, OSError):
|
|
closed = exc.errno in _CLOSED_TERMINAL
|
|
elif type(exc).__name__ == "error" and type(exc).__module__ == "termios":
|
|
closed = bool(exc.args) and exc.args[0] in _CLOSED_TERMINAL
|
|
else:
|
|
closed = False
|
|
return closed and (fd is None or _terminal_gone(fd))
|
|
|
|
|
|
def _terminal_gone(fd: int) -> bool:
|
|
"""Whether the terminal at fd hung up: a closed window answers EIO to
|
|
tcgetattr, a live one its settings."""
|
|
import termios # noqa: PLC0415
|
|
|
|
try:
|
|
termios.tcgetattr(fd)
|
|
except (termios.error, OSError):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _tui_crashed(state: TuiState, work: Path) -> None:
|
|
"""A traceback is where every reporter of #71, #73 and #74 stopped: it
|
|
goes to a file, and the terminal says one sentence."""
|
|
crash = work / "error.log"
|
|
try:
|
|
crash.write_text(traceback.format_exc(), encoding="utf-8")
|
|
except OSError as exc:
|
|
print(f"The error could not be written to {crash}: {exc}", file=sys.stderr)
|
|
state.log, state.code, state.exit_code = str(crash), 1, 1
|
|
state.stack = ["crashed"]
|
|
|
|
|
|
def _restore_handlers(handlers: dict) -> None:
|
|
for number, previous in handlers.items():
|
|
signal.signal(number, previous)
|
|
|
|
|
|
def _drop_closed_terminal() -> None:
|
|
"""Point stdout and stderr at nothing once the terminal is gone, so what
|
|
they still hold is flushed somewhere at exit."""
|
|
try:
|
|
null = os.open(os.devnull, os.O_WRONLY)
|
|
except OSError:
|
|
# No null device to point them at: the flush at exit fails as before.
|
|
return
|
|
try:
|
|
for stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
fd = stream.fileno()
|
|
except (ValueError, AttributeError, io.UnsupportedOperation):
|
|
# A stream with no descriptor (a test's StringIO) has no
|
|
# terminal behind it to lose.
|
|
continue
|
|
os.dup2(null, fd)
|
|
finally:
|
|
os.close(null)
|
|
|
|
|
|
def _tui_cleanup(
|
|
terminal: _TuiTerminal, runs: list[_EngineRun], handlers: dict, state: TuiState,
|
|
) -> list[str]:
|
|
"""Stop the child, put the terminal and the signal handlers back: what failed."""
|
|
failures: tuple = (OSError, ValueError)
|
|
if os.name != "nt":
|
|
import termios # noqa: PLC0415
|
|
|
|
failures += (termios.error,)
|
|
problems: list[str] = []
|
|
|
|
def step(action: Callable[[], object]) -> None:
|
|
try:
|
|
action()
|
|
except failures as exc:
|
|
problems.append(str(exc) or type(exc).__name__)
|
|
|
|
for name in ("SIGTERM", "SIGHUP", "SIGTSTP"):
|
|
number = getattr(signal, name, None)
|
|
if number is not None:
|
|
step(functools.partial(signal.signal, number, signal.SIG_IGN))
|
|
# An install stopped by a signal still gets to write its summary.
|
|
for run in runs:
|
|
step(functools.partial(run.stop, 10 if state.running and state.run == "install"
|
|
else 0))
|
|
step(terminal.leave)
|
|
for number, previous in handlers.items():
|
|
step(functools.partial(signal.signal, number, previous))
|
|
return problems
|
|
|
|
|
|
def _line_buffered_stdout() -> None:
|
|
"""Flush stdout line by line, as stderr is.
|
|
|
|
Piped to a log (`| tee install.log`), stdout was block-buffered while
|
|
errors went out at once: the log opened on "Could not fetch the file
|
|
list" before the banner and the step that produced it.
|
|
"""
|
|
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
|
if reconfigure is not None:
|
|
try:
|
|
reconfigure(line_buffering=True)
|
|
except (ValueError, OSError):
|
|
# A stream that cannot be reconfigured keeps its own buffering:
|
|
# the order of a log is cosmetic, the run goes on.
|
|
return
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_line_buffered_stdout()
|
|
# A reader that stops early (`install.py --list | head`) ends the run
|
|
# quietly instead of on a BrokenPipeError traceback.
|
|
try:
|
|
main()
|
|
sys.stdout.flush()
|
|
except BrokenPipeError:
|
|
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
sys.exit(141)
|
|
except KeyboardInterrupt:
|
|
# Outside the download phase (fetching the file list, checking the
|
|
# files present, rebuilding data directories): nothing is half
|
|
# written there, and a socket traceback read as a crash. Stopped at
|
|
# a question, nothing has started, and there is nothing to carry on.
|
|
if _writing.is_set():
|
|
print("\nStopped. Run the same command again to carry on from here. "
|
|
"Nothing is lost.")
|
|
else:
|
|
print("\nStopped. Nothing was installed.")
|
|
sys.exit(130)
|