mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 01:53:56 -06:00
chore: merge installer batch
This commit is contained in:
commit
46302de5bb
26 files changed
+1332
-224
No files matched your search
@@ -24,7 +24,7 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh
|
||||
# Windows (PowerShell)
|
||||
irm https://abdess.github.io/retrobios/i.ps1 | iex
|
||||
|
||||
# Android (Termux, after pkg install python and termux-setup-storage)
|
||||
# Android (Termux: it installs Python and asks for storage access)
|
||||
curl -fsSL https://abdess.github.io/retrobios/i | sh
|
||||
|
||||
# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Onion, spruceOS or MinUI card
|
||||
|
||||
+21
-16
@@ -16,7 +16,7 @@
|
||||
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
|
||||
# the site's build rewrites this line there (scripts/sitebootstrap.py).
|
||||
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
$defaultInstallSha256 = "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547"
|
||||
$defaultInstallSha256 = "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
@@ -68,30 +68,35 @@
|
||||
$installer = $temporary
|
||||
}
|
||||
|
||||
# The py launcher first, then python3 and python. The Microsoft Store
|
||||
# places a python.exe that only points at the Store: it fails the
|
||||
# version check like a missing one.
|
||||
$findPython = {
|
||||
$found = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($found) { return ,@($found, @("-3")) }
|
||||
foreach ($name in @("python3", "python")) {
|
||||
$found = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($found) { return ,@($found, @()) }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$usable = {
|
||||
param($candidate)
|
||||
if (-not $candidate) { return $false }
|
||||
$arguments = $candidate[1]
|
||||
& $candidate[0].Source @arguments -c "import sys; raise SystemExit(sys.version_info < (3, 8))" | Out-Null
|
||||
& $candidate[0].Source @arguments -c "import sys, ssl, unicodedata, zlib; raise SystemExit(sys.version_info < (3, 8))" 2>$null | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
# The py launcher first, then python3 and python, the first that
|
||||
# passes the check: a py launcher left with no usable runtime hid a
|
||||
# Python 3.12 on PATH. The Microsoft Store places a python.exe that
|
||||
# only points at the Store: it fails the check like a missing one.
|
||||
$findPython = {
|
||||
$candidates = @()
|
||||
$found = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($found) { $candidates += ,@($found, @("-3")) }
|
||||
foreach ($name in @("python3", "python")) {
|
||||
$found = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($found) { $candidates += ,@($found, @()) }
|
||||
}
|
||||
foreach ($candidate in $candidates) {
|
||||
if (& $usable $candidate) { return ,$candidate }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$needPython = "Python 3.8 or newer is required. Install it with " +
|
||||
"'winget install Python.Python.3.12' or from https://www.python.org/downloads/ " +
|
||||
"(tick 'Add python.exe to PATH'), then open a new PowerShell window and run this command again."
|
||||
$candidate = & $findPython
|
||||
if (-not (& $usable $candidate)) {
|
||||
if (-not $candidate) {
|
||||
# winget ships with Windows 10 and 11: at a console the bootstrap
|
||||
# offers to run the install the message names, then carries on.
|
||||
$winget = Get-Command winget -ErrorAction SilentlyContinue
|
||||
@@ -109,7 +114,7 @@
|
||||
$candidate = & $findPython
|
||||
}
|
||||
}
|
||||
if (-not (& $usable $candidate)) { throw $needPython }
|
||||
if (-not $candidate) { throw $needPython }
|
||||
}
|
||||
$python = $candidate[0]
|
||||
$launcher = $candidate[1]
|
||||
|
||||
+327
-73
@@ -43,17 +43,25 @@ import textwrap
|
||||
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, Iterable, Iterator, Mapping
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO
|
||||
from typing import Any, BinaryIO, ContextManager, NamedTuple, NoReturn, TextIO
|
||||
|
||||
# Built as C extensions that a Python compiled without their libraries
|
||||
# lacks: the run stopped on a ModuleNotFoundError traceback.
|
||||
try:
|
||||
import unicodedata
|
||||
import zlib
|
||||
except ImportError as _missing:
|
||||
print(f"Error: this Python was built without its {_missing.name} module, which "
|
||||
"the installer needs. Run it with another Python 3.8 or newer.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# File lists are read from main, the newest ones, whichever commit the
|
||||
# installer came from. RETROBIOS_REF pins them to a tag or a commit when
|
||||
@@ -645,15 +653,18 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
|
||||
# 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
|
||||
# refused certificate under a clock set in the past, no_ca for no
|
||||
# trusted certificates on the machine, or no_ssl for a Python built
|
||||
# without ssl) or detect (reason: conflict, two
|
||||
# systems found on one machine, or not_found, the platform named with
|
||||
# --platform, or by a file list or a pack in a run without questions,
|
||||
# found nowhere and no folder to assume) 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) or dest (reason: unreadable, a
|
||||
# folder above it this user cannot open, or not_writable, the BIOS
|
||||
# folder cannot be created, a summary following).
|
||||
# folder cannot be created, a summary following) or option (reason:
|
||||
# unknown, a platform, target, system, core or emulator the run cannot
|
||||
# use, refused before any download).
|
||||
"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
|
||||
@@ -665,6 +676,12 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
|
||||
# message says it and what it means. Not written when either version
|
||||
# is unknown, and never a refusal.
|
||||
"version": ("platform", "list_version", "installed_version", "message"),
|
||||
# Before choices: the board the system or its card names, read with no
|
||||
# --target given. applied is whether the run keeps its cores' files
|
||||
# only, false for a board the platform's list does not name. message
|
||||
# says it. A kind of its own: the full-screen interface always gives
|
||||
# --dest, and the board was never applied there.
|
||||
"board": ("platform", "board", "applied", "message"),
|
||||
# Before the file events of a check: the systems the run's list lets
|
||||
# one choose among, --system aside, in id order (other for the files
|
||||
# no system names). What Choose systems lists, and what the plan counts
|
||||
@@ -1246,11 +1263,14 @@ def _retroarch_cfg_dir(
|
||||
|
||||
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.
|
||||
not set the key. A config this user cannot read is said once, like a
|
||||
closed folder: a retroarch.cfg a `sudo retroarch` left to root hid the
|
||||
setup without a word.
|
||||
"""
|
||||
try:
|
||||
lines = cfg_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
_note_refused(cfg_path, exc)
|
||||
return None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
@@ -1457,6 +1477,33 @@ def derivative_board(platform: str) -> tuple[str, str] | None:
|
||||
return (name, board) if board else None
|
||||
|
||||
|
||||
# The board a card names on a partition a computer reads. Batocera writes
|
||||
# its image's board to boot/batocera.board on the BATOCERA boot partition,
|
||||
# the FAT one beside SHARE (batocera-43.1, board/batocera/scripts/
|
||||
# post-image-script.sh:60), and reads it there itself (package/batocera/
|
||||
# core/batocera-scripts/scripts/batocera-info). batocera.arch is inside the
|
||||
# image, out of a computer's reach.
|
||||
_CARD_BOARD_FILES: dict[str, str] = {"batocera": "boot/batocera.board"}
|
||||
|
||||
|
||||
def card_board(platform: str) -> str | None:
|
||||
"""The board the card mounted here names, None where no card or two
|
||||
cards of different boards do: a card is most often prepared on a
|
||||
computer, where the system's own board file does not exist."""
|
||||
where = _CARD_BOARD_FILES.get(platform)
|
||||
if where is None:
|
||||
return None
|
||||
boards: set[str] = set()
|
||||
for mount in _mounted_cards():
|
||||
try:
|
||||
value = (mount / where).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if value.strip() and not _CONTROLS.search(value.strip()):
|
||||
boards.add(value.strip().lower())
|
||||
return boards.pop() if len(boards) == 1 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, ("", ""))
|
||||
@@ -1748,7 +1795,10 @@ _VERSION_TEXT = re.compile(r"[0-9A-Za-z][0-9A-Za-z._+-]{0,39}")
|
||||
# What generate_pack.py wrote as every list's version until it recorded the
|
||||
# platform's own: not a version, so a list holding it names none.
|
||||
_NO_LIST_VERSION = "1.0"
|
||||
_MD5_CHECK = "{name}'s own BIOS check may report some files as wrong though they work."
|
||||
# What the platform's own check does, not a verdict on it: it compares the
|
||||
# files with the list of its own version, and no run proved them working.
|
||||
_MD5_CHECK = ("{name}'s own BIOS check reads the list of the version it runs, and "
|
||||
"may show some files as not matching it.")
|
||||
|
||||
|
||||
def _version_line(path: Path) -> str:
|
||||
@@ -3046,6 +3096,31 @@ def did_you_mean(
|
||||
return f"{listing} lists all {len(known)}."
|
||||
|
||||
|
||||
def refuse_name(platform: str, *lines: str) -> NoReturn:
|
||||
"""End the run on a name it cannot use (a platform, target, system, core
|
||||
or emulator), refused before any download.
|
||||
|
||||
The reason went to stderr only: a reader of --events got start, then
|
||||
end with code 1, and could not tell a typo from a failed download. Code
|
||||
2, as for an option argparse refuses: 1 also means --check found files
|
||||
to fetch, and a script reading it reran the install on a typo forever.
|
||||
"""
|
||||
message = " ".join(line.strip() for line in lines)
|
||||
if message.startswith("Error: "):
|
||||
message = message[len("Error: "):]
|
||||
EVENTS.emit("error", stage="option", reason="unknown", platform=platform,
|
||||
message=message)
|
||||
for line in lines:
|
||||
print(line, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# Frontends and systems that keep no BIOS folder of their own: each
|
||||
# emulator they start reads its own, which a run without --platform finds.
|
||||
# --platform es-de was "Unknown platform", the answer left unsaid.
|
||||
_NO_BIOS_FOLDER = ("ES-DE", "LaunchBox", "Android")
|
||||
|
||||
|
||||
def normalize_platform(name: str, say: bool = True) -> str:
|
||||
"""Return the canonical platform id for user-supplied input.
|
||||
|
||||
@@ -3065,10 +3140,27 @@ def normalize_platform(name: str, say: bool = True) -> str:
|
||||
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)
|
||||
for frontend in _NO_BIOS_FOLDER:
|
||||
if key and key == _name_key(frontend):
|
||||
refuse_name(name.strip(), f"{frontend} has no BIOS folder of its own: run again "
|
||||
"without --platform, and the installer fills the folder of each "
|
||||
"emulator it finds here.")
|
||||
# --list-platforms prints TrimUI with why it gets no folder, and naming
|
||||
# it answered "Unknown platform".
|
||||
for rule in _EMBEDDED_RULES:
|
||||
if key and not rule.manifest and key == _name_key(rule.name):
|
||||
refuse_name(name.strip(), f"{rule.name} is not installed to: "
|
||||
f"{_NO_FOLDER.get(rule.name, 'no source names its BIOS folder')}.",
|
||||
" Name that folder: --platform retroarch "
|
||||
"--dest FOLDER")
|
||||
# Every name --list-platforms prints, so that spruce suggests spruceOS
|
||||
# and the count is the one the listing shows.
|
||||
known = list(AVAILABLE_PLATFORMS) + list(dict.fromkeys(
|
||||
rule.name for rule in _EMBEDDED_RULES
|
||||
if _name_key(rule.name) not in {_name_key(plat) for plat in AVAILABLE_PLATFORMS}
|
||||
)) + list(_NO_BIOS_FOLDER)
|
||||
refuse_name(name.strip(), f"Unknown platform '{name.strip()}'. "
|
||||
+ did_you_mean([name], known, "--list-platforms"))
|
||||
|
||||
|
||||
def _read_limited_json(
|
||||
@@ -3808,7 +3900,7 @@ def network_words(
|
||||
) -> 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
|
||||
The reason is clock, no_ca, no_ssl, 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.
|
||||
@@ -3820,7 +3912,14 @@ def network_words(
|
||||
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):
|
||||
if (isinstance(exc, urllib.error.URLError) and raw.startswith("unknown url type")
|
||||
and not hasattr(urllib.request, "HTTPSHandler")):
|
||||
# urllib defines no HTTPS handler in a Python built without ssl:
|
||||
# three attempts and a word on the connection blamed the network.
|
||||
code = "no_ssl"
|
||||
words = ("This Python was built without its ssl module and cannot use HTTPS. "
|
||||
"Run the installer with another Python 3.8 or newer")
|
||||
elif cert_error is not None and isinstance(inner, cert_error):
|
||||
today = today or datetime.date.today()
|
||||
if not _ca_bundle_found():
|
||||
code = "no_ca"
|
||||
@@ -3993,9 +4092,20 @@ def fetch_manifest(plat: str) -> dict:
|
||||
try:
|
||||
return _validate_manifest(_fetch_json(url, MAX_MANIFEST_BYTES, "file list"), plat)
|
||||
except ValueError as exc:
|
||||
words = f"the file list is not valid: {exc}"
|
||||
if isinstance(exc.__cause__, (json.JSONDecodeError, UnicodeDecodeError)):
|
||||
# The parser's "Expecting value: line 1 column 1" told no one
|
||||
# anything, where the full screen names the usual cause.
|
||||
words = ("what arrived is not a file list: a Wi-Fi sign-in page or a cut "
|
||||
"download looks like this")
|
||||
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)
|
||||
message=words)
|
||||
print(f" For {display_name(plat)}, {words}.", file=sys.stderr)
|
||||
if words.startswith("what arrived"):
|
||||
for line in _address_lines(url):
|
||||
print(line, file=sys.stderr)
|
||||
print(" Check the connection (sign in to the Wi-Fi if it asks), then run the "
|
||||
"same command again.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
EVENTS.emit(
|
||||
@@ -4047,13 +4157,52 @@ def fetch_manifest(plat: str) -> dict:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _pack_alone(plat: str) -> bool:
|
||||
"""A pack read without a file list for plat: one built before packs
|
||||
carried their install list records the systems of its files only."""
|
||||
return (LOCAL.pack is not None and LOCAL.manifest is None
|
||||
and (LOCAL.pack.install_list or {}).get("platform") != plat)
|
||||
|
||||
|
||||
def local_targets(plat: str) -> "dict | None":
|
||||
"""The cores per board this run has without the network: the list the
|
||||
pack carries, else targets/<platform>.json beside --manifest, where the
|
||||
repository keeps it (install/targets/). None when there is neither."""
|
||||
if LOCAL.pack is not None and LOCAL.pack.targets is not None \
|
||||
and plat in LOCAL.pack.platforms():
|
||||
return LOCAL.pack.targets
|
||||
if LOCAL.manifest is None:
|
||||
return None
|
||||
beside = LOCAL.manifest.parent / "targets" / f"{plat}.json"
|
||||
raw = _read_capped(beside, MAX_TARGETS_BYTES)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return _validate_targets(json.loads(raw.decode("utf-8")))
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
print(f" Error: the target list {beside} is not valid: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch_targets(plat: str) -> "dict | None":
|
||||
"""Download the target core list.
|
||||
"""The target core list: the one this run has at hand, else downloaded.
|
||||
|
||||
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.
|
||||
answering "no targets" for it sent users to the full pack. A run without
|
||||
the network and with no list at hand is refused, code 2, before any
|
||||
file is written.
|
||||
"""
|
||||
held = local_targets(plat)
|
||||
if held is not None:
|
||||
return held
|
||||
if LOCAL.offline:
|
||||
source = (f"{LOCAL.pack.name} carries none" if LOCAL.pack is not None
|
||||
else f"{offline_named()} makes no request")
|
||||
print(f"Error: --target reads the cores of each {display_name(plat)} board from a "
|
||||
f"list, and {source}. Put install/targets/{plat}.json beside the file list "
|
||||
"named with --manifest, or run with the network.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
url = TARGETS_URL.format(platform=plat)
|
||||
try:
|
||||
return _validate_targets(_fetch_json(url, MAX_TARGETS_BYTES, "target list"))
|
||||
@@ -6509,17 +6658,24 @@ def _needed_lines(entries: list[dict], heading: str, every: bool = False) -> lis
|
||||
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]:
|
||||
shown = systems if every else systems[:8]
|
||||
for system in shown:
|
||||
names, cores = by_system[system]
|
||||
readers = f" ({', '.join(sorted(cores))})" if cores else ""
|
||||
lines.append(f" {system}{readers}: {', '.join(sorted(names))}")
|
||||
# Each emulator's own page, one click away: the index alone sent the
|
||||
# user searching several hundred pages for theforceengine.
|
||||
lines += [f" {SITE_URL}emulators/{core}/" for core in sorted(cores)]
|
||||
if not every and len(systems) > 8:
|
||||
# A file several systems share is a group of its own, counted as one.
|
||||
unit = "groups" if any(", " in system for system in systems) else "systems"
|
||||
more = len(systems) - 8
|
||||
lines.append(f" ... and {more} more {unit if more > 1 else unit[:-1]} "
|
||||
"(--check --verbose lists them all)")
|
||||
lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/")
|
||||
if all(by_system[system][1] for system in shown):
|
||||
lines.append(" Each emulator's page, linked above, says what these files are.")
|
||||
else:
|
||||
lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/")
|
||||
return lines
|
||||
|
||||
|
||||
@@ -6541,6 +6697,8 @@ def installer_id() -> str:
|
||||
PLAN_FILES_PER_SYSTEM = 8
|
||||
PLAN_LARGEST_SYSTEMS = 10
|
||||
PLAN_FRAME_LINES = 6
|
||||
# Past this many systems a group sharing files is named by its first two.
|
||||
PLAN_GROUP_TITLES = 3
|
||||
|
||||
# Answers that stop: every menu before this one taught q as quit.
|
||||
_REFUSALS = ("n", "q", "quit", "exit", "stop", "abort")
|
||||
@@ -6608,22 +6766,34 @@ def _print_plan(
|
||||
if not parts:
|
||||
print(f"\n Nothing to {verb}.")
|
||||
by_system: dict[str, list[dict]] = {}
|
||||
titles: dict[str, str] = {}
|
||||
for entry in to_download:
|
||||
system = (", ".join(system_title(name, manifest) for name in entry_systems(entry))
|
||||
or OTHER_TITLE)
|
||||
names = [system_title(name, manifest) for name in entry_systems(entry)]
|
||||
system = ", ".join(names) or OTHER_TITLE
|
||||
by_system.setdefault(system, []).append(entry)
|
||||
# Batocera's group of systems sharing MAME's device ROMs was one line
|
||||
# of 732 characters, ten rows on an 80-column terminal.
|
||||
titles[system] = (system if verbose or len(names) <= PLAN_GROUP_TITLES
|
||||
else f"{', '.join(names[:2])} and {len(names) - 2} more systems")
|
||||
shown = None if verbose else PLAN_FILES_PER_SYSTEM
|
||||
listing: list[str] = []
|
||||
# (line, its text without styles), the second measuring the rows it takes.
|
||||
listing: list[tuple[str, 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)))}"
|
||||
listing.append((f" {term.bold(titles[system])} {_sized(entries)}",
|
||||
f" {titles[system]} {_sized(entries)}"))
|
||||
listing += [(f" {entry['dest']} {term.dim(format_size(entry.get('size', 0)))}",
|
||||
f" {entry['dest']} {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 = terminal_size()[1]
|
||||
if verbose or len(listing) + len(trees) + PLAN_FRAME_LINES <= screen:
|
||||
for line in listing:
|
||||
more = f" ... and {len(entries) - shown} more"
|
||||
listing.append((more, more))
|
||||
columns, screen = terminal_size()
|
||||
# A line wider than the terminal wraps: counted as one, the total the
|
||||
# listing must leave in view scrolled out of sight.
|
||||
rows = sum(-(-max(1, cell_width(plain)) // max(1, columns)) for _line, plain in listing)
|
||||
if verbose or rows + len(trees) + PLAN_FRAME_LINES <= screen:
|
||||
for line, _plain in listing:
|
||||
print(line)
|
||||
else:
|
||||
largest = sorted(by_system, key=lambda s: (-_bytes(by_system[s]), s))
|
||||
@@ -6635,7 +6805,7 @@ def _print_plan(
|
||||
else _plural(len(largest), 'system'))
|
||||
print(f" {groups}, the largest first:")
|
||||
for system in largest[:PLAN_LARGEST_SYSTEMS]:
|
||||
print(f" {term.bold(system)} {_sized(by_system[system])}")
|
||||
print(f" {term.bold(titles[system])} {_sized(by_system[system])}")
|
||||
if len(largest) > PLAN_LARGEST_SYSTEMS:
|
||||
# Counted as the line above counts: the groups, when it names them.
|
||||
unit = "group" if "groups" in groups else "system"
|
||||
@@ -8991,7 +9161,7 @@ def _older_list_advice(plat: str = "") -> str:
|
||||
the pack's: the one-line installer does not change it, and a current
|
||||
pack was said to come from an older release.
|
||||
"""
|
||||
if LOCAL.pack is not None and LOCAL.manifest is None:
|
||||
if _pack_alone(plat):
|
||||
return (f" The list in {LOCAL.pack.name} does not record it: add --manifest FILE, "
|
||||
f"a copy of install/{plat or '<platform>'}.json, or run without the option.")
|
||||
if RELEASE_REF != DEFAULT_RELEASE_REF:
|
||||
@@ -9042,8 +9212,7 @@ def _narrow_to_emulators(
|
||||
try:
|
||||
chosen = resolve_choices(names, known, "emulator")
|
||||
except ValueError as exc:
|
||||
print(f"Error: {plat}: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
refuse_name(plat, f"Error: {plat}: {exc}")
|
||||
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
|
||||
@@ -9070,7 +9239,7 @@ def _requested_narrowing(
|
||||
"""The systems, cores and regions named on the command line, checked."""
|
||||
if not (args.system or args.core or args.region):
|
||||
return None
|
||||
pack_alone = LOCAL.pack is not None and LOCAL.manifest is None
|
||||
pack_alone = _pack_alone(plat)
|
||||
recorded = recorded_axes(files, omitted)
|
||||
if pack_alone:
|
||||
# A pack records the systems of its files, not their cores or
|
||||
@@ -9099,8 +9268,7 @@ def _requested_narrowing(
|
||||
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)
|
||||
refuse_name(plat, f"Error: {plat}: {exc}")
|
||||
return systems, cores, regions
|
||||
|
||||
|
||||
@@ -9230,6 +9398,7 @@ class GamesData:
|
||||
raw = _read_capped(beside / "index.json", MAX_GAMES_INDEX_BYTES)
|
||||
cached = self.cached("index.json")
|
||||
reason = "offline"
|
||||
failure = ""
|
||||
if raw is None and network:
|
||||
try:
|
||||
raw = _fetch_json(GAMES_URL.format(path="index.json"), MAX_GAMES_INDEX_BYTES,
|
||||
@@ -9239,8 +9408,7 @@ class GamesData:
|
||||
except (urllib.error.URLError, OSError, ValueError,
|
||||
http.client.HTTPException) as exc:
|
||||
reason = "network"
|
||||
if os.environ.get("RETROBIOS_DEBUG"):
|
||||
print(f" Games index: {exc}", file=sys.stderr)
|
||||
failure = str(exc)
|
||||
else:
|
||||
if cached is not None:
|
||||
_cache_write(cached, raw)
|
||||
@@ -9249,7 +9417,8 @@ class GamesData:
|
||||
if raw is None:
|
||||
raise GamesUnavailable(reason, (
|
||||
f"the games index is not on this machine and {offline_words()}"
|
||||
if reason == "offline" else "the games index could not be downloaded"))
|
||||
if reason == "offline"
|
||||
else f"the games index could not be downloaded ({failure})"))
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
@@ -10765,6 +10934,11 @@ class PackSource:
|
||||
# The version of the platform's list the pack was built from, "" for
|
||||
# a pack that does not record it.
|
||||
self.list_version = ""
|
||||
# The platform's install list and its cores per board, which a pack
|
||||
# built since carries: with them a pack alone narrows to a board as
|
||||
# the network list does. None for a pack that carries none.
|
||||
self.install_list: dict | None = None
|
||||
self.targets: dict | None = None
|
||||
try:
|
||||
for path in self.files:
|
||||
archive = zipfile.ZipFile(path)
|
||||
@@ -10791,7 +10965,15 @@ class PackSource:
|
||||
elif path.is_file():
|
||||
given.append(path)
|
||||
else:
|
||||
raise ValueError(f"{path} does not exist")
|
||||
# "X does not exist" named neither the option nor the packs
|
||||
# beside it, where a typo in the name is the usual cause.
|
||||
near = sorted(p.name for p in path.parent.glob("*_BIOS_Pack*.zip")
|
||||
if p.is_file()) if path.parent.is_dir() else []
|
||||
beside = (f" The packs in {path.parent}: {', '.join(near[:3])}"
|
||||
+ (f" and {len(near) - 3} more" if len(near) > 3 else "") + "."
|
||||
if near else "")
|
||||
raise ValueError(f"{path} does not exist: --from-pack names a pack ZIP, "
|
||||
f"its parts or the folder holding them.{beside}")
|
||||
if not given:
|
||||
raise ValueError(f"no pack ZIP in {', '.join(map(str, paths))}")
|
||||
packs: dict[str, list[Path]] = {}
|
||||
@@ -10858,6 +11040,15 @@ class PackSource:
|
||||
version = data.get("list_version")
|
||||
if isinstance(version, str) and _VERSION_TEXT.fullmatch(version):
|
||||
self.list_version = version
|
||||
if isinstance(data.get("install_list"), dict):
|
||||
self.install_list = data["install_list"]
|
||||
if data.get("targets") is not None:
|
||||
try:
|
||||
self.targets = _validate_targets(data["targets"])
|
||||
except ValueError:
|
||||
# Read as a pack that carries none: --target is then refused
|
||||
# with the reason, never applied from a broken list.
|
||||
self.targets = None
|
||||
for key in ("system_titles", "system_names"):
|
||||
held = data.get(key)
|
||||
if isinstance(held, dict):
|
||||
@@ -10894,9 +11085,11 @@ class PackSource:
|
||||
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, with the
|
||||
systems it records."""
|
||||
"""A file list for plat: the install list the pack carries, else one
|
||||
built from the pack's own, the same files at the same places, as
|
||||
extracting the pack would leave them, with the systems it records."""
|
||||
if self.install_list is not None and self.install_list.get("platform") == plat:
|
||||
return self.install_list
|
||||
return {
|
||||
"manifest_version": 2,
|
||||
"platform": plat,
|
||||
@@ -11749,15 +11942,36 @@ def _copies(entry: dict) -> list[dict]:
|
||||
*entry.get("declared_choices", [])]
|
||||
|
||||
|
||||
# id(manifest) -> (manifest, the cores its entries name), read once per list.
|
||||
_LISTED_CORES: dict[int, tuple[dict, frozenset]] = {}
|
||||
|
||||
|
||||
def _listed_cores(manifest: dict) -> frozenset:
|
||||
"""Every core a copy of the list's entries names: the platform's own.
|
||||
|
||||
system_cores reaches every profile, the target rule needing the cores off
|
||||
the platform too, and the "read by" line named Recalbox's mame0258 on
|
||||
Batocera and BizHawk on RetroArch.
|
||||
"""
|
||||
cached = _LISTED_CORES.get(id(manifest))
|
||||
if cached is None or cached[0] is not manifest:
|
||||
cores = frozenset(
|
||||
core for entry in [*manifest.get("files", []), *manifest.get("omitted_files", [])]
|
||||
for copy in _copies(entry) for core in copy.get("cores") or [])
|
||||
cached = _LISTED_CORES[id(manifest)] = (manifest, cores)
|
||||
return cached[1]
|
||||
|
||||
|
||||
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."""
|
||||
"""The cores that open an entry: as its copies name them, else the cores
|
||||
of its systems that the platform's own list names."""
|
||||
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 {}
|
||||
listed = _listed_cores(manifest)
|
||||
return sorted({core for system in entry_systems(entry)
|
||||
for core in system_cores.get(system, [])})
|
||||
for core in system_cores.get(system, []) if core in listed})
|
||||
|
||||
|
||||
def opened_line(entry: dict, manifest: dict, bios_path: Path) -> str:
|
||||
@@ -13112,13 +13326,14 @@ def main() -> None:
|
||||
# The runs the full-screen interface starts read it from here, and
|
||||
# the words name the option given.
|
||||
os.environ[OFFLINE_VARIABLE] = OFFLINE_FLAG_VALUE
|
||||
if offline_requested() and (args.target or args.list_targets):
|
||||
if (offline_requested() and (args.target or args.list_targets)
|
||||
and not (args.manifest or args.from_pack)):
|
||||
# No list can be at hand: a pack carries one, and so can the folder
|
||||
# of a --manifest (targets/<platform>.json).
|
||||
parser.error("--target and --list-targets read a core list from the network, "
|
||||
f"which {offline_named()} does not use")
|
||||
f"which {offline_named()} does not use: name a pack with --from-pack, "
|
||||
"or a file list with --manifest and targets/<platform>.json beside it")
|
||||
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
|
||||
@@ -14054,6 +14269,12 @@ def _list_card_choice(plat: str, dest: Path) -> CardChoice:
|
||||
return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of "
|
||||
f"{names}: installing into it as named. Name the card's BIOS "
|
||||
"folder with --dest to write there.")
|
||||
if plat == "retrodeck" and dest.name.lower() == "bios" and not _is_dir(dest / "bios"):
|
||||
# RetroDECK's list writes bios/, roms/ and the rest below its own
|
||||
# folder: its BIOS folder, which --help asks for, got bios/bios.
|
||||
return CardChoice(plat, dest.parent, f"{shown_path(dest)} is RetroDECK's BIOS "
|
||||
f"folder: the files go below {shown_path(dest.parent)}, the "
|
||||
"RetroDECK folder holding it")
|
||||
return CardChoice(plat, dest)
|
||||
|
||||
|
||||
@@ -14367,13 +14588,13 @@ def _several_found(platforms: list[tuple[str, Path]], setups: list) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
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 _unknown_target(target: str, plat: str, targets: dict, *after: str) -> NoReturn:
|
||||
if not targets:
|
||||
# No value can work: "unknown target" read as a typo of the board.
|
||||
refuse_name(plat, f"Error: {display_name(plat)} has no hardware targets: run again "
|
||||
"without --target.", *after)
|
||||
refuse_name(plat, f"Error: unknown target '{target}' for {display_name(plat)}.",
|
||||
" " + did_you_mean([target], sorted(targets), "--list-targets"), *after)
|
||||
|
||||
|
||||
def _is_terminal(stream: TextIO) -> bool:
|
||||
@@ -14434,6 +14655,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
"(--platform takes either name):")
|
||||
for line in _system_lines():
|
||||
print(f" {line}")
|
||||
print("\nNo BIOS folder of their own, each emulator reading its own (run without "
|
||||
"--platform):")
|
||||
print(f" {', '.join(_NO_BIOS_FOLDER)}")
|
||||
detected = detect_platforms(os_type)
|
||||
if detected:
|
||||
print("\nDetected on this system:")
|
||||
@@ -14716,11 +14940,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> 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)
|
||||
_unknown_target(args.target, plat_name, targets, *(
|
||||
[" Nothing was installed. Name one platform with "
|
||||
"--platform to install it alone."] if len(platforms) > 1 else []))
|
||||
|
||||
if args.romm_firmware is not None:
|
||||
# Applied or refused, never ignored.
|
||||
@@ -14812,18 +15034,28 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
|
||||
# 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.
|
||||
# 5.5. Read from the device itself, else from the card mounted here:
|
||||
# a card prepared on a computer took every board's files, and the
|
||||
# full-screen interface, which always gives --dest, never applied one.
|
||||
target = args.target
|
||||
if not target and not args.every_board and args.dest is None and not LOCAL.offline:
|
||||
if (not target and not args.every_board
|
||||
and (not LOCAL.offline or local_targets(plat_name) is not None)):
|
||||
board = detect_board(plat_name)
|
||||
where = "detected"
|
||||
if not board and args.dest is not None:
|
||||
board, where = card_board(plat_name), "found on the card"
|
||||
if board:
|
||||
known = fetch_targets(plat_name) or {}
|
||||
if board in known:
|
||||
target = board
|
||||
print(f" Board {board} detected (--every-board installs all boards)")
|
||||
words = f"Board {board} {where} (--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")
|
||||
words = (f"Board {board} {where} has no "
|
||||
f"{PLATFORM_NAMES.get(plat_name, plat_name)} core list: "
|
||||
"installing the files of every board")
|
||||
print(f" {words}")
|
||||
EVENTS.emit("board", platform=plat_name, board=board,
|
||||
applied=target == board, message=words)
|
||||
else:
|
||||
derived = derivative_board(plat_name)
|
||||
if derived:
|
||||
@@ -14842,7 +15074,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
# 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: "
|
||||
@@ -15395,6 +15626,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
print(f" {shown_path(bios_path)} stays: {exc.strerror}",
|
||||
file=sys.stderr)
|
||||
bulk_lines += hint
|
||||
if plat_name == "romm" and progress.installed:
|
||||
bulk_lines.append(ROMM_SCAN_WORDS)
|
||||
total_downloaded += progress.installed
|
||||
# The end line tells BIOS files from game data, as the menu
|
||||
# counted them, over the files that landed: matched by the end
|
||||
@@ -16237,7 +16470,8 @@ def _folded(text: str) -> str:
|
||||
# --system plan: Back with the same ticks gives that plan back.
|
||||
_CHECK_FIELDS = ("check", "files", "systems", "choices", "set_aside", "found", "scanned",
|
||||
"unreadable", "saves", "check_summary", "checked_flags", "cli_systems",
|
||||
"facts", "ticked", "version", "checked_dests", "games", "games_unavailable")
|
||||
"facts", "ticked", "version", "board", "checked_dests", "games",
|
||||
"games_unavailable")
|
||||
|
||||
|
||||
class TuiState:
|
||||
@@ -16404,6 +16638,9 @@ class TuiState:
|
||||
self.version: str = ""
|
||||
# The note in one line, for a plan with no room for its sentences.
|
||||
self.version_short: str = ""
|
||||
# The board event of the check: the board read from the system or
|
||||
# its card, said on the plan.
|
||||
self.board: str = ""
|
||||
# The same of the confirm or preview run, and of the install: a
|
||||
# choice of systems can leave the save file out, or keep it.
|
||||
self.dry_saves: dict | None = None
|
||||
@@ -17929,7 +18166,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]:
|
||||
state.choices = state.set_aside = None
|
||||
state.found, state.scanned, state.unreadable = [], [], []
|
||||
state.check_summary = state.saves = None
|
||||
state.version = state.version_short = ""
|
||||
state.version = state.version_short = state.board = ""
|
||||
state.games = state.games_reading = state.games_unavailable = None
|
||||
state.checked_flags = tui_flags(state)
|
||||
elif run in ("confirm", "preview"):
|
||||
@@ -19851,6 +20088,8 @@ def _take_event(state: TuiState, event: dict) -> None:
|
||||
state.choices = list(event["systems"])
|
||||
elif kind == "set_aside" and state.run == "check":
|
||||
state.set_aside = set(event["dests"])
|
||||
elif kind == "board" and state.run == "check":
|
||||
state.board = str(event["message"])
|
||||
elif kind == "version" and state.run == "check":
|
||||
state.version = str(event["message"])
|
||||
state.version_short = (f"List for {display_name(state.platform, state.manifest)} "
|
||||
@@ -21065,6 +21304,10 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
|
||||
free = next((index for index, line in enumerate(lines)
|
||||
if str(line).startswith("Free space:")), len(lines))
|
||||
lines.insert(free, state.version)
|
||||
if state.board:
|
||||
free = next((index for index, line in enumerate(lines)
|
||||
if str(line).startswith("Free space:")), len(lines))
|
||||
lines.insert(free, state.board)
|
||||
if state.status:
|
||||
lines.append(state.status)
|
||||
if defined:
|
||||
@@ -21706,6 +21949,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
|
||||
# Said again with the result, as line mode ends: the system's own
|
||||
# BIOS check may mark the files just put in place.
|
||||
lines.append(state.version)
|
||||
if state.platform == "romm" and installed:
|
||||
lines.append(ROMM_SCAN_WORDS)
|
||||
fallback = (state.games or {}).get("fallback") or [] if state.for_games else []
|
||||
if fallback:
|
||||
# Named with the result, as line mode ends: they got every BIOS of
|
||||
@@ -22584,7 +22829,8 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None:
|
||||
f"(--from-pack PACK), or {offline_undo()}."], 1))
|
||||
return
|
||||
title = {"clock": "The clock is wrong",
|
||||
"no_ca": "No trusted certificates"}.get(code, "No connection")
|
||||
"no_ca": "No trusted certificates",
|
||||
"no_ssl": "This Python cannot use HTTPS"}.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") + "."]
|
||||
@@ -22898,6 +23144,14 @@ def _closing_lines(state: TuiState) -> list[str]:
|
||||
|
||||
TUI_QUIT_WORDS = "RetroBIOS: quit. Nothing was installed."
|
||||
|
||||
# RomM serves the firmware rows its library scan records (rommapp/romm 5.4.0,
|
||||
# backend/endpoints/firmware.py:148,175 read db_firmware_handler.list_firmware),
|
||||
# and its folder watcher is off unless ENABLE_RESCAN_ON_FILESYSTEM_CHANGE is
|
||||
# set (backend/config/__init__.py:284, backend/watcher.py:106): files put in
|
||||
# place stay unseen by the player until a scan.
|
||||
ROMM_SCAN_WORDS = ("RomM lists the firmware its library scan has recorded: start a scan "
|
||||
"in RomM so that its player finds these files.")
|
||||
|
||||
# The result's remedy for the save files left out, as the terminal says it
|
||||
# once the screen is gone: Options are not there to choose from
|
||||
# (code_audit_20 P3-4). Line mode's words.
|
||||
|
||||
+53
-3
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
|
||||
# curl or wget stop on a cut transfer before the hash is read: the site's
|
||||
# build rewrites this line there (scripts/sitebootstrap.py).
|
||||
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
DEFAULT_INSTALL_SHA256="6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547"
|
||||
DEFAULT_INSTALL_SHA256="993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
@@ -121,14 +121,29 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then
|
||||
INSTALLER="$TEMP_INSTALLER"
|
||||
fi
|
||||
|
||||
# A Python can be built without ssl, unicodedata or zlib, and the installer
|
||||
# needs all three: a python3 lacking ssl was chosen over a full python and
|
||||
# every download then failed as if the network were down.
|
||||
find_python() {
|
||||
PYTHON=""
|
||||
PYTHON_LACKING=""
|
||||
PYTHON_OLD=""
|
||||
for command_name in python3 python; do
|
||||
if command -v "$command_name" >/dev/null 2>&1 \
|
||||
&& "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' 2>/dev/null; then
|
||||
command -v "$command_name" >/dev/null 2>&1 || continue
|
||||
if ! "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' \
|
||||
2>/dev/null; then
|
||||
found_version=$("$command_name" -c \
|
||||
'import sys; print("%d.%d.%d" % tuple(sys.version_info[:3]))' 2>/dev/null)
|
||||
if [ -n "$found_version" ]; then
|
||||
PYTHON_OLD=${PYTHON_OLD:-"$command_name here is $found_version"}
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if "$command_name" -c 'import ssl, unicodedata, zlib' 2>/dev/null; then
|
||||
PYTHON=$command_name
|
||||
return 0
|
||||
fi
|
||||
PYTHON_LACKING=${PYTHON_LACKING:-$command_name}
|
||||
done
|
||||
return 1
|
||||
}
|
||||
@@ -172,6 +187,20 @@ if ! find_python; then
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
if [ -z "$PYTHON" ] && [ -n "$PYTHON_LACKING" ]; then
|
||||
echo "Error: $PYTHON_LACKING is built without a module the installer needs" >&2
|
||||
echo " (ssl, unicodedata or zlib). Run it with a full Python 3.8 or newer." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$PYTHON" ] && [ -n "$PYTHON_OLD" ]; then
|
||||
# Debian Buster ships 3.7: apt answers that python3 is already the newest
|
||||
# version, and the same advice looped.
|
||||
echo "Error: Python 3.8 or newer is required, and $PYTHON_OLD." >&2
|
||||
echo " Install a newer one: from this system's packages if they offer one, by" >&2
|
||||
echo " updating the system, or from https://www.python.org/downloads/." >&2
|
||||
echo " Then run this command again." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$PYTHON" ]; then
|
||||
echo "Error: Python 3.8 or newer is required." >&2
|
||||
if [ -n "$install_command" ]; then
|
||||
@@ -183,6 +212,27 @@ if [ -z "$PYTHON" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Termux reaches shared storage, where RetroArch keeps its system folder,
|
||||
# once termux-setup-storage has run: the installer told the user to run it
|
||||
# and start again. At a terminal the bootstrap runs it here. It only asks
|
||||
# the Termux app, which shows Android's permission dialog and creates
|
||||
# ~/storage once allowed: the wait gives the user time to answer.
|
||||
case "${TERMUX_VERSION:-}:${PREFIX:-}" in
|
||||
?*:*|*com.termux*)
|
||||
if [ ! -d "$HOME/storage" ] && command -v termux-setup-storage >/dev/null 2>&1 \
|
||||
&& (: </dev/tty) 2>/dev/null; then
|
||||
echo "Android asks for the storage permission: allow it, so that the files reach" >/dev/tty
|
||||
echo "the RetroArch folder." >/dev/tty
|
||||
termux-setup-storage </dev/tty >/dev/tty 2>&1 || true
|
||||
waited=0
|
||||
while [ ! -d "$HOME/storage" ] && [ "$waited" -lt 60 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Piped from curl, standard input is this script, not the terminal: the
|
||||
# installer would skip every question it can ask. Hand it the terminal when
|
||||
# there is one.
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1079846,
|
||||
"sha256": "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547",
|
||||
"size": 1093946,
|
||||
"sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -62,8 +62,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/tui.txt",
|
||||
"repo_path": "ports/shared/tui.txt",
|
||||
"size": 35984,
|
||||
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
|
||||
"size": 36434,
|
||||
"sha256": "fa05432a9a4325f3fd3220eec0548ad932dffc1888b93a08af2047cc78aa4d0c",
|
||||
"executable": false
|
||||
}
|
||||
]
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 1133023,
|
||||
"sha256": "ff64d48ae2d5bd2dcf341917c55da88f8ca7174a2ccbc4e7786b69444da11ff8"
|
||||
"size": 1147573,
|
||||
"sha256": "e0bc43ae6a7ce3e0db3b19392eba9dc1561db3a0c95bfe3933dd7c870bdf19a8"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1079846,
|
||||
"sha256": "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547",
|
||||
"size": 1093946,
|
||||
"sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -141,8 +141,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/tui.txt",
|
||||
"repo_path": "ports/shared/tui.txt",
|
||||
"size": 35984,
|
||||
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
|
||||
"size": 36434,
|
||||
"sha256": "fa05432a9a4325f3fd3220eec0548ad932dffc1888b93a08af2047cc78aa4d0c",
|
||||
"executable": false
|
||||
}
|
||||
]
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1079846,
|
||||
"sha256": "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547",
|
||||
"size": 1093946,
|
||||
"sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -208,8 +208,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/tui.txt",
|
||||
"repo_path": "ports/shared/tui.txt",
|
||||
"size": 35984,
|
||||
"sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1",
|
||||
"size": 36434,
|
||||
"sha256": "fa05432a9a4325f3fd3220eec0548ad932dffc1888b93a08af2047cc78aa4d0c",
|
||||
"executable": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -116,8 +116,8 @@ terminals:
|
||||
android:
|
||||
label: Android (Termux)
|
||||
how: >-
|
||||
Install **Termux**, a terminal app. Run `pkg install python` and `termux-setup-storage`
|
||||
once. Then paste the line. Press Enter.
|
||||
Install **Termux**, a terminal app. Paste the line. Press Enter. Accept the Python
|
||||
install and allow the storage access Android asks for.
|
||||
shell: sh
|
||||
device:
|
||||
label: On the device
|
||||
@@ -335,7 +335,8 @@ guides:
|
||||
Option C: Manual (SD card)
|
||||
--------------------------
|
||||
1. Put the SD card in your PC
|
||||
2. Navigate to /userdata/bios/ on the SHARE partition
|
||||
2. Open the bios folder at the root of the SHARE drive
|
||||
(Batocera itself names it /userdata/bios/)
|
||||
3. Extract all files from this archive directly into that folder
|
||||
|
||||
NOTE: Dreamcast flash memory is named dc_nvmem.bin
|
||||
@@ -368,7 +369,8 @@ guides:
|
||||
Option C: Manual (SD card)
|
||||
--------------------------
|
||||
1. Put the SD card in your PC
|
||||
2. Navigate to /recalbox/share/bios/
|
||||
2. Open the bios folder at the root of the SHARE drive
|
||||
(Recalbox itself names it /recalbox/share/bios/)
|
||||
3. Extract all files from this archive directly into that folder
|
||||
logo_owner: Recalbox
|
||||
logo_licence: file from homarr-labs/dashboard-icons, Apache-2.0
|
||||
|
||||
@@ -43,7 +43,7 @@ rb_fetch_runtime() {
|
||||
fi
|
||||
PortMasterDialogInit "no-check"
|
||||
PortMasterDialog "messages_begin"
|
||||
PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime."
|
||||
PortMasterDialog "message" "This system's Python is older than 3.8 or lacks a module install.py needs. Downloading PortMaster's $RB_RUNTIME runtime."
|
||||
answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs")
|
||||
PortMasterDialog "messages_end"
|
||||
PortMasterDialogExit
|
||||
|
||||
+11
-1
@@ -193,10 +193,17 @@ rb_open_log() {
|
||||
|
||||
# --- Python ---------------------------------------------------------------
|
||||
|
||||
rb_python_ok() {
|
||||
# The modules install.py needs that a Python can be built without: a python3
|
||||
# lacking ssl was chosen over PortMaster's runtime, and every download failed
|
||||
# as if the network were down.
|
||||
rb_python_new() {
|
||||
"$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
rb_python_ok() {
|
||||
rb_python_new "$@" && "$@" -c 'import ssl, unicodedata, zlib' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11
|
||||
# runtime on aarch64, mounted as PortMaster's own Python ports do. A launcher
|
||||
# that can download the runtime defines rb_fetch_runtime. Sets
|
||||
@@ -252,6 +259,9 @@ rb_release_python() {
|
||||
rb_python_text() {
|
||||
local found
|
||||
found=$(python3 --version 2>&1 | awk '{print $2}')
|
||||
if command -v python3 >/dev/null 2>&1 && rb_python_new python3; then
|
||||
found="$found, built without a module install.py needs (ssl, unicodedata or zlib)"
|
||||
fi
|
||||
case "$RB_PYTHON_PROBLEM" in
|
||||
old) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch). $RB_PC_ROUTE" ;;
|
||||
runtime_offline) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download. $RB_PC_ROUTE" ;;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"error",
|
||||
"scanned",
|
||||
"version",
|
||||
"board",
|
||||
"choices",
|
||||
"set_aside",
|
||||
"file",
|
||||
@@ -59,6 +60,9 @@
|
||||
{
|
||||
"$ref": "#/$defs/version"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/board"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/choices"
|
||||
},
|
||||
@@ -292,6 +296,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event",
|
||||
"platform",
|
||||
"board",
|
||||
"applied",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"event": {
|
||||
"const": "board"
|
||||
},
|
||||
"platform": {
|
||||
"type": "string"
|
||||
},
|
||||
"board": {
|
||||
"type": "string"
|
||||
},
|
||||
"applied": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
+36
-7
@@ -29,6 +29,15 @@ case "$API_BASE" in
|
||||
esac
|
||||
API="${API_BASE%/}/repos/${REPO}/releases/latest"
|
||||
|
||||
# Global, not local to download_pack: the EXIT trap runs once that function's
|
||||
# scope is gone, and under set -u a local would be unbound there.
|
||||
STAGING=""
|
||||
cleanup_staging() {
|
||||
if [ -n "$STAGING" ]; then
|
||||
rm -rf "$STAGING"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <platform> <destination>"
|
||||
echo " $0 --list"
|
||||
@@ -143,12 +152,14 @@ EOF
|
||||
fi
|
||||
|
||||
# Staged inside the destination: a pack is gigabytes, and /tmp is a RAM
|
||||
# disk on the appliances these packs target.
|
||||
# disk on the appliances these packs target. The folder is this run's own:
|
||||
# a fixed name let a second download into the same folder empty it while
|
||||
# the first was still writing its parts.
|
||||
mkdir -p "$dest"
|
||||
local staging="${dest%/}/.retrobios-download"
|
||||
rm -rf "$staging"
|
||||
mkdir -p "$staging"
|
||||
trap 'rm -rf "$staging"' EXIT
|
||||
local staging
|
||||
staging=$(mktemp -d "${dest%/}/.retrobios-download-XXXXXX")
|
||||
STAGING="$staging"
|
||||
trap cleanup_staging EXIT
|
||||
|
||||
local index=0
|
||||
local url name
|
||||
@@ -180,11 +191,29 @@ EOF
|
||||
done
|
||||
|
||||
echo "Extracting to ${dest}/..."
|
||||
# unzip -o unlinks a file and writes its replacement under the final
|
||||
# name, so a run stopped mid-file left a partial BIOS. Members land in
|
||||
# the staging folder, on the same filesystem, and each is renamed into
|
||||
# place: a file in the BIOS folder is the old one or the new one.
|
||||
local extracted="${staging}/extracted"
|
||||
for file in "${staging}"/*.zip; do
|
||||
unzip -o -q "$file" -d "$dest"
|
||||
unzip -o -q "$file" -d "$extracted"
|
||||
done
|
||||
local target
|
||||
target=$(cd "$dest" && pwd)
|
||||
(
|
||||
cd "$extracted"
|
||||
find . -type f -exec sh -c '
|
||||
target="$1"
|
||||
shift
|
||||
for member; do
|
||||
mkdir -p "$target/$(dirname "$member")"
|
||||
mv -f "$member" "$target/$member"
|
||||
done
|
||||
' sh "$target" {} +
|
||||
)
|
||||
|
||||
rm -rf "$staging"
|
||||
cleanup_staging
|
||||
trap - EXIT
|
||||
echo "Done! BIOS files extracted to ${dest}/"
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ KEY_TYPES: dict[str, dict] = {
|
||||
**dict.fromkeys((
|
||||
"installer", "os", "os_name", "stage", "reason", "platform", "message",
|
||||
"folder", "dest", "system", "name", "bios_dir", "path", "member",
|
||||
"list_version", "installed_version",
|
||||
"list_version", "installed_version", "board",
|
||||
), _TEXT),
|
||||
"mode": {"enum": ["install", "check", "dry-run", "list"]},
|
||||
"state": {"enum": ["present", "wrong_hash", "missing"]},
|
||||
@@ -45,7 +45,7 @@ KEY_TYPES: dict[str, dict] = {
|
||||
"bytes_needed", "data_missing", "not_installable", "installed", "up_to_date",
|
||||
"pending", "code", "games", "recognised", "not_games",
|
||||
), _COUNT),
|
||||
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only"), _FLAG),
|
||||
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied"), _FLAG),
|
||||
**dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES),
|
||||
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found",
|
||||
"fallback"), _RECORDS),
|
||||
|
||||
+80
-64
@@ -2256,67 +2256,7 @@ def generate_target_manifests(targets_dir: str, output_dir: str) -> None:
|
||||
for yml_file in sorted(targets_path.glob("*.yml")):
|
||||
if yml_file.name.startswith("_"):
|
||||
continue
|
||||
with open(yml_file) as f:
|
||||
data = yaml_load(f) or {}
|
||||
targets = data.get("targets", {})
|
||||
if not isinstance(targets, dict):
|
||||
raise ValueError(f"{yml_file}: targets must be a mapping")
|
||||
|
||||
# The pack builder accepts the aliases declared in _overrides.yml, so
|
||||
# --target switch works there. Emitting only canonical names left the
|
||||
# installer rejecting the same word and, worse, carrying on with every
|
||||
# file instead: the user asked for a filter and got the full pack.
|
||||
overrides_path = targets_path / "_overrides.yml"
|
||||
alias_map: dict[str, list[str]] = {}
|
||||
platform_overrides: dict = {}
|
||||
if overrides_path.is_file():
|
||||
with open(overrides_path) as f:
|
||||
all_overrides = yaml_load(f) or {}
|
||||
platform_overrides = (
|
||||
all_overrides.get(yml_file.stem, {}).get("targets", {}) or {}
|
||||
)
|
||||
for tname, ovr in platform_overrides.items():
|
||||
names = [a for a in (ovr or {}).get("aliases", []) if isinstance(a, str)]
|
||||
if names:
|
||||
alias_map[tname] = names
|
||||
|
||||
result: dict[str, list[str] | None] = {}
|
||||
for target_name, target_info in targets.items():
|
||||
if not isinstance(target_name, str) or not target_name:
|
||||
raise ValueError(f"{yml_file}: target names must be non-empty strings")
|
||||
if not isinstance(target_info, dict):
|
||||
raise ValueError(f"{yml_file}: target {target_name!r} must be a mapping")
|
||||
cores = target_info.get("cores")
|
||||
if cores is None:
|
||||
result[target_name] = None
|
||||
continue
|
||||
if (
|
||||
not isinstance(cores, list)
|
||||
or any(not isinstance(core, str) or not core for core in cores)
|
||||
or len(cores) != len(set(cores))
|
||||
):
|
||||
raise ValueError(
|
||||
f"{yml_file}: target {target_name!r} cores must be unique "
|
||||
"non-empty strings"
|
||||
)
|
||||
# The builder's own reading, overrides included: a list published
|
||||
# another way makes the installer serve what the pack does not.
|
||||
# An empty list is a board with no core, not one with every core.
|
||||
resolved = load_target_config(
|
||||
yml_file.stem, target_name, str(targets_path.parent)
|
||||
)
|
||||
result[target_name] = None if resolved is None else sorted(resolved)
|
||||
|
||||
for target_name, names in alias_map.items():
|
||||
if target_name not in result:
|
||||
continue
|
||||
for alias in names:
|
||||
if alias in result:
|
||||
raise ValueError(
|
||||
f"{yml_file}: alias {alias!r} collides with a target name"
|
||||
)
|
||||
result[alias] = result[target_name]
|
||||
|
||||
result = target_list(yml_file)
|
||||
out_path = Path(output_dir) / f"{yml_file.stem}.json"
|
||||
write_text_atomic(out_path, json.dumps(result, indent=2, sort_keys=True) + "\n")
|
||||
count += 1
|
||||
@@ -2324,6 +2264,73 @@ def generate_target_manifests(targets_dir: str, output_dir: str) -> None:
|
||||
print(f"Generated {count} target manifest(s) in {output_dir}")
|
||||
|
||||
|
||||
def target_list(yml_file: Path) -> dict[str, list[str] | None]:
|
||||
"""A platform's cores per hardware target, as install/targets/ publishes
|
||||
them and a pack carries them: aliases included, overrides applied."""
|
||||
targets_path = yml_file.parent
|
||||
with open(yml_file) as f:
|
||||
data = yaml_load(f) or {}
|
||||
targets = data.get("targets", {})
|
||||
if not isinstance(targets, dict):
|
||||
raise ValueError(f"{yml_file}: targets must be a mapping")
|
||||
|
||||
# The pack builder accepts the aliases declared in _overrides.yml, so
|
||||
# --target switch works there. Emitting only canonical names left the
|
||||
# installer rejecting the same word and, worse, carrying on with every
|
||||
# file instead: the user asked for a filter and got the full pack.
|
||||
overrides_path = targets_path / "_overrides.yml"
|
||||
alias_map: dict[str, list[str]] = {}
|
||||
platform_overrides: dict = {}
|
||||
if overrides_path.is_file():
|
||||
with open(overrides_path) as f:
|
||||
all_overrides = yaml_load(f) or {}
|
||||
platform_overrides = (
|
||||
all_overrides.get(yml_file.stem, {}).get("targets", {}) or {}
|
||||
)
|
||||
for tname, ovr in platform_overrides.items():
|
||||
names = [a for a in (ovr or {}).get("aliases", []) if isinstance(a, str)]
|
||||
if names:
|
||||
alias_map[tname] = names
|
||||
|
||||
result: dict[str, list[str] | None] = {}
|
||||
for target_name, target_info in targets.items():
|
||||
if not isinstance(target_name, str) or not target_name:
|
||||
raise ValueError(f"{yml_file}: target names must be non-empty strings")
|
||||
if not isinstance(target_info, dict):
|
||||
raise ValueError(f"{yml_file}: target {target_name!r} must be a mapping")
|
||||
cores = target_info.get("cores")
|
||||
if cores is None:
|
||||
result[target_name] = None
|
||||
continue
|
||||
if (
|
||||
not isinstance(cores, list)
|
||||
or any(not isinstance(core, str) or not core for core in cores)
|
||||
or len(cores) != len(set(cores))
|
||||
):
|
||||
raise ValueError(
|
||||
f"{yml_file}: target {target_name!r} cores must be unique "
|
||||
"non-empty strings"
|
||||
)
|
||||
# The builder's own reading, overrides included: a list published
|
||||
# another way makes the installer serve what the pack does not.
|
||||
# An empty list is a board with no core, not one with every core.
|
||||
resolved = load_target_config(
|
||||
yml_file.stem, target_name, str(targets_path.parent)
|
||||
)
|
||||
result[target_name] = None if resolved is None else sorted(resolved)
|
||||
|
||||
for target_name, names in alias_map.items():
|
||||
if target_name not in result:
|
||||
continue
|
||||
for alias in names:
|
||||
if alias in result:
|
||||
raise ValueError(
|
||||
f"{yml_file}: alias {alias!r} collides with a target name"
|
||||
)
|
||||
result[alias] = result[target_name]
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_systems(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
||||
"""Turn --system into the platform's system ids, before any work.
|
||||
|
||||
@@ -2966,6 +2973,7 @@ def _run_platform_packs(
|
||||
# The install list of the same choices names each file's
|
||||
# systems: the pack records them, so a pack alone (a
|
||||
# handheld without Wi-Fi) can be narrowed by system.
|
||||
board_list = Path(args.platforms_dir) / "targets" / f"{representative}.yml"
|
||||
systems_for[os.path.basename(zip_path)] = pack_systems(generate_manifest(
|
||||
representative, args.platforms_dir, db, args.bios_dir,
|
||||
registry_path, emulators_dir=args.emulators_dir,
|
||||
@@ -2975,7 +2983,7 @@ def _run_platform_packs(
|
||||
offline=args.offline, required_only=required_only,
|
||||
data_registry=data_registry,
|
||||
no_game_data=getattr(args, "no_game_data", False),
|
||||
))
|
||||
), target_list(board_list) if board_list.is_file() else None)
|
||||
|
||||
print("\nVerifying packs and generating manifests...")
|
||||
skip_conf = bool(system_filter or args.split)
|
||||
@@ -4810,7 +4818,7 @@ def _manifest_core_index(
|
||||
|
||||
|
||||
|
||||
def pack_systems(install_manifest: dict) -> dict:
|
||||
def pack_systems(install_manifest: dict, targets: dict | None = None) -> dict:
|
||||
"""What a pack's manifest.json records of its systems, from the install
|
||||
list built with the same choices: each file's systems, by its path in
|
||||
the pack, and the names a person reads for them.
|
||||
@@ -4818,6 +4826,11 @@ def pack_systems(install_manifest: dict) -> dict:
|
||||
A pack recorded none: the installer refused --system with a pack alone
|
||||
("the file list comes from an older release") and offered no Choose
|
||||
systems, the route of a handheld without Wi-Fi.
|
||||
|
||||
The install list itself and the platform's cores per board (targets, as
|
||||
install/targets/ publishes them) ride along: with the pack alone, a
|
||||
board was never applied and --target was refused, and a Batocera RPi4
|
||||
card took 8.9 GB where its board reads 5.8.
|
||||
"""
|
||||
files = install_manifest.get("files", [])
|
||||
entries = [*files, *install_manifest.get("omitted_files", [])]
|
||||
@@ -4843,6 +4856,9 @@ def pack_systems(install_manifest: dict) -> dict:
|
||||
# the one the machine runs when it has the pack alone.
|
||||
if install_manifest.get("version"):
|
||||
systems["list_version"] = install_manifest["version"]
|
||||
systems["install_list"] = install_manifest
|
||||
if targets is not None:
|
||||
systems["targets"] = targets
|
||||
return systems
|
||||
|
||||
|
||||
@@ -4858,7 +4874,7 @@ def annotate_pack_systems(manifest: dict, systems: dict | None) -> None:
|
||||
for key in ("system_titles", "system_names"):
|
||||
if systems.get(key):
|
||||
manifest[key] = systems[key]
|
||||
for key in ("list_records", "list_version"):
|
||||
for key in ("list_records", "list_version", "install_list", "targets"):
|
||||
if key in systems:
|
||||
manifest[key] = systems[key]
|
||||
|
||||
|
||||
@@ -575,7 +575,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
|
||||
"# Windows (PowerShell)",
|
||||
install_command("powershell"),
|
||||
"",
|
||||
"# Android (Termux, after pkg install python and termux-setup-storage)",
|
||||
"# Android (Termux: it installs Python and asks for storage access)",
|
||||
install_command("sh"),
|
||||
"",
|
||||
"# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Onion, spruceOS or MinUI card",
|
||||
|
||||
@@ -8654,7 +8654,7 @@ def install_commands(args: str = "", indent: str = "",
|
||||
for label, shell in (
|
||||
("Linux, macOS, Steam Deck:", "sh"),
|
||||
("Windows (PowerShell):", "powershell"),
|
||||
("Android (Termux, after `pkg install python` and `termux-setup-storage`):", "sh"),
|
||||
("Android (Termux, the command installs Python and asks for storage access):", "sh"),
|
||||
):
|
||||
lines += [f"{indent}{label}", "", *fence(shell, install_command(shell, args), indent), ""]
|
||||
return lines
|
||||
|
||||
@@ -23,6 +23,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -371,6 +372,10 @@ class ReleaseServer:
|
||||
]
|
||||
self.index.write_text(json.dumps(release))
|
||||
|
||||
def drop_last_asset(self) -> None:
|
||||
"""A part the release lists but no longer serves: its URL gives 404."""
|
||||
(self.root / "assets" / self.names[-1]).unlink()
|
||||
|
||||
def corrupt_last_volume(self) -> None:
|
||||
target = self.root / "assets" / self.names[-1]
|
||||
target.write_bytes(target.read_bytes()[:-16] + b"0" * 16)
|
||||
@@ -561,6 +566,72 @@ class TestDownloadShell(DownloaderCase):
|
||||
proc = self.run_cli("nintendo", str(self.dest), expect_success=False)
|
||||
self.assertIn(self.platform_label, proc.stdout + proc.stderr)
|
||||
|
||||
def test_a_failed_part_leaves_no_staging_behind(self):
|
||||
"""The EXIT trap read a variable local to the function that set it:
|
||||
under set -u it failed on `staging: unbound variable` and left the
|
||||
parts already downloaded in the BIOS folder."""
|
||||
self.server.drop_last_asset()
|
||||
proc = self.run_cli(self.platform, str(self.dest), expect_success=False)
|
||||
self.assertNotIn("unbound variable", proc.stderr)
|
||||
self.assertEqual(list(self.dest.iterdir()), [])
|
||||
|
||||
def test_an_interrupted_extraction_keeps_the_installed_file(self):
|
||||
"""unzip -o unlinks a file and writes the new one under its final
|
||||
name: a run killed mid-file left a partial BIOS where a valid one
|
||||
was. Extraction now writes beside the folder and renames."""
|
||||
shim = Path(_tempdir(self))
|
||||
(shim / "unzip").write_text(
|
||||
"#!/bin/sh\n"
|
||||
'for target; do :; done\n'
|
||||
'mkdir -p "$target/bios"\n'
|
||||
'printf partial > "$target/bios/scph5501.bin"\n'
|
||||
"exit 137\n"
|
||||
)
|
||||
(shim / "unzip").chmod(0o755)
|
||||
installed = self.dest / "bios" / "scph5501.bin"
|
||||
installed.parent.mkdir(parents=True)
|
||||
installed.write_bytes(b"valid")
|
||||
env = self.env()
|
||||
env["PATH"] = f"{shim}{os.pathsep}{env['PATH']}"
|
||||
proc = subprocess.run(
|
||||
["bash", str(SHELL), self.platform, str(self.dest)],
|
||||
env=env, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertEqual(installed.read_bytes(), b"valid")
|
||||
self.assertEqual(
|
||||
sorted(p.name for p in self.dest.iterdir()), ["bios"],
|
||||
"staging left behind",
|
||||
)
|
||||
|
||||
def test_two_runs_into_one_folder_both_finish(self):
|
||||
"""Both runs staged in `<dest>/.retrobios-download` and the second
|
||||
emptied it while the first was still writing its parts."""
|
||||
shim = Path(_tempdir(self))
|
||||
real = shutil.which("curl")
|
||||
(shim / "curl").write_text(
|
||||
"#!/bin/sh\n"
|
||||
'case "$*" in *-o*) sleep 1 ;; esac\n'
|
||||
f'exec "{real}" "$@"\n'
|
||||
)
|
||||
(shim / "curl").chmod(0o755)
|
||||
env = self.env()
|
||||
env["PATH"] = f"{shim}{os.pathsep}{env['PATH']}"
|
||||
command = ["bash", str(SHELL), self.platform, str(self.dest)]
|
||||
first = subprocess.Popen(
|
||||
command, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
second = subprocess.run(
|
||||
command, env=env, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
out, err = first.communicate(timeout=120)
|
||||
self.assertEqual(first.returncode, 0, out + err)
|
||||
self.assertEqual(second.returncode, 0, second.stdout + second.stderr)
|
||||
self.assert_extracted()
|
||||
self.assert_no_leftovers()
|
||||
|
||||
|
||||
class TestWholePackPython(WholePackCase, TestDownloadPython):
|
||||
def test_info_reports_every_volume(self):
|
||||
|
||||
@@ -1080,6 +1080,44 @@ class ThePortMasterPort(unittest.TestCase):
|
||||
self.assertIn("--dest", screen)
|
||||
self.assertEqual(device.calls(), [])
|
||||
|
||||
def _python_without_ssl(self, device) -> None:
|
||||
"""A python3 3.8 or newer whose ssl import fails, as on a CFW image
|
||||
built without OpenSSL."""
|
||||
real = shutil.which("python3.8") or sys.executable
|
||||
shadow = device.root / "no-ssl"
|
||||
shadow.mkdir()
|
||||
(shadow / "ssl.py").write_text('raise ImportError("built without ssl")\n')
|
||||
device.tool("python3", f'#!/bin/sh\nPYTHONPATH="{shadow}" exec {real} "$@"\n')
|
||||
|
||||
def test_a_python_without_ssl_is_told_on_the_console(self):
|
||||
"""Only the version was probed: a python3 without ssl was chosen and
|
||||
every download failed as if the network were down."""
|
||||
device = self.device()
|
||||
self._python_without_ssl(device)
|
||||
device.tool("uname", "#!/bin/sh\necho armv7l\n")
|
||||
proc = device.run(["bash", self.script])
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
screen = " ".join(device.screen.split())
|
||||
self.assertIn("built without a module install.py needs", screen)
|
||||
self.assertEqual(device.calls(), [])
|
||||
|
||||
def test_a_python_without_ssl_gives_way_to_the_runtime(self):
|
||||
device = self.device()
|
||||
self._python_without_ssl(device)
|
||||
real = shutil.which("python3.8") or sys.executable
|
||||
device.tool("uname", "#!/bin/sh\necho aarch64\n")
|
||||
device.tool("mount", '#!/bin/sh\nprintf "mount %s\\n" "$*" >> "$FAKE_STATE/tools.log"\n'
|
||||
'mkdir -p "$2/bin"\n'
|
||||
f'printf \'#!/bin/sh\\nunset PYTHONHOME\\nexec {real} "$@"\\n\' > "$2/bin/python3"\n'
|
||||
'chmod +x "$2/bin/python3"\n')
|
||||
device.tool("umount", FAKE_RECORDER)
|
||||
(self.control / "libs").mkdir()
|
||||
(self.control / "libs" / "python_3.11.squashfs").write_bytes(b"")
|
||||
proc = device.run(["bash", self.script])
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertTrue(any(t.startswith("mount") for t in device.tools()))
|
||||
|
||||
def test_the_runtime_is_mounted_then_unmounted(self):
|
||||
"""ArkOS's Python 3.7: PortMaster's python_3.11 runtime, mounted for
|
||||
the run and unmounted after it."""
|
||||
|
||||
+417
-6
@@ -2404,6 +2404,22 @@ class FileListsAreRetried(unittest.TestCase):
|
||||
self.assertIs(result, missing)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_a_sign_in_page_is_named(self):
|
||||
"""A Wi-Fi sign-in page read as "invalid file list: Expecting value:
|
||||
line 1 column 1 (char 0)", with no cause and no next step."""
|
||||
page = io.BytesIO(b"<html>Sign in to Hotel WiFi</html>")
|
||||
page.headers = {}
|
||||
err = io.StringIO()
|
||||
with unittest.mock.patch.object(
|
||||
install.urllib.request, "urlopen",
|
||||
lambda url, timeout=None: contextlib.nullcontext(page)), \
|
||||
contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
|
||||
install.fetch_manifest("retroarch")
|
||||
said = err.getvalue()
|
||||
self.assertIn("a Wi-Fi sign-in page or a cut download looks like this", said)
|
||||
self.assertIn("run the same command again", said)
|
||||
self.assertNotIn("Expecting value", said)
|
||||
|
||||
def test_three_failures_end_with_advice(self):
|
||||
import urllib.error
|
||||
|
||||
@@ -2575,6 +2591,63 @@ class NoPythonNamesThisSystemsCommand(unittest.TestCase):
|
||||
self.assertNotIn("apt", proc.stderr)
|
||||
|
||||
|
||||
class ABootstrapPythonCanDoHttps(unittest.TestCase):
|
||||
"""find_python checked the version only: a python3 built without ssl
|
||||
was chosen over a full python, and every download then failed."""
|
||||
|
||||
def _bin(self) -> tuple[Path, Path]:
|
||||
root = Path(_tempdir(self))
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
for tool in ("sed", "tr", "dirname", "cat", "awk", "uname"):
|
||||
found = shutil.which(tool)
|
||||
if found:
|
||||
(fake_bin / tool).symlink_to(found)
|
||||
shadow = root / "no-ssl"
|
||||
shadow.mkdir()
|
||||
(shadow / "ssl.py").write_text('raise ImportError("built without ssl")\n')
|
||||
(fake_bin / "python3").write_text(
|
||||
f'#!/bin/sh\nPYTHONPATH="{shadow}" exec {shlex.quote(sys.executable)} "$@"\n')
|
||||
(fake_bin / "python3").chmod(0o755)
|
||||
(root / "install.py").write_text("import ssl; print('installer ran')\n")
|
||||
shutil.copy(REPO_ROOT / "install.sh", root / "install.sh")
|
||||
return root, fake_bin
|
||||
|
||||
def _run(self, root: Path, fake_bin: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["/bin/sh", str(root / "install.sh")], cwd=root,
|
||||
env={"PATH": str(fake_bin), "HOME": str(root)},
|
||||
capture_output=True, text=True, timeout=30, check=False,
|
||||
stdin=subprocess.DEVNULL, start_new_session=True,
|
||||
)
|
||||
|
||||
def test_a_full_python_is_preferred(self):
|
||||
root, fake_bin = self._bin()
|
||||
(fake_bin / "python").symlink_to(sys.executable)
|
||||
proc = self._run(root, fake_bin)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("installer ran", proc.stdout)
|
||||
|
||||
def test_an_old_python_is_named_with_its_version(self):
|
||||
"""Debian Buster's python3 3.7 got "Install it with: sudo apt install
|
||||
python3", which apt answers with "already the newest version"."""
|
||||
root, fake_bin = self._bin()
|
||||
(fake_bin / "python3").write_text(
|
||||
'#!/bin/sh\ncase "$2" in *print*) echo 3.7.3 ;; esac\nexit 1\n')
|
||||
proc = self._run(root, fake_bin)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("Python 3.8 or newer is required, and python3 here is 3.7.3.",
|
||||
proc.stderr)
|
||||
self.assertNotIn("Install it with: sudo apt", proc.stderr)
|
||||
|
||||
def test_the_missing_module_is_named(self):
|
||||
root, fake_bin = self._bin()
|
||||
proc = self._run(root, fake_bin)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertNotIn("installer ran", proc.stdout)
|
||||
self.assertIn("python3 is built without a module the installer needs", proc.stderr)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("pwsh") and shutil.which("script"), "needs PowerShell and a terminal")
|
||||
class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase):
|
||||
"""A Windows machine without Python was told to install it, open a new
|
||||
@@ -2621,6 +2694,74 @@ class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase):
|
||||
self.assertFalse((fake_bin / "python3").exists())
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("script"), "needs a terminal")
|
||||
class TermuxStorageIsSetUpByTheBootstrap(unittest.TestCase):
|
||||
"""The installer told an Android user to run termux-setup-storage and
|
||||
start the whole run over: the bootstrap runs it and waits for ~/storage."""
|
||||
|
||||
def _run(self, storage_exists: bool) -> tuple[subprocess.CompletedProcess, Path]:
|
||||
root = Path(_tempdir(self))
|
||||
shutil.copy(REPO_ROOT / "install.sh", root / "install.sh")
|
||||
(root / "install.py").write_text("print('installer ran')\n")
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
if storage_exists:
|
||||
(home / "storage").mkdir()
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
for tool in ("sed", "tr", "dirname", "cat", "awk", "uname", "sleep", "mkdir"):
|
||||
found = shutil.which(tool)
|
||||
if found:
|
||||
(fake_bin / tool).symlink_to(found)
|
||||
(fake_bin / "python3").symlink_to(sys.executable)
|
||||
# The app creates ~/storage once Android's dialog is answered.
|
||||
(fake_bin / "termux-setup-storage").write_text(
|
||||
f'#!/bin/sh\necho ran >> "{root}/setup.log"\nmkdir "$HOME/storage"\n')
|
||||
(fake_bin / "termux-setup-storage").chmod(0o755)
|
||||
command = (f"PATH={shlex.quote(str(fake_bin))} HOME={shlex.quote(str(home))} "
|
||||
f"TERMUX_VERSION=0.118 /bin/sh {shlex.quote(str(root / 'install.sh'))}")
|
||||
proc = subprocess.run(
|
||||
[shutil.which("script"), "-qec", command, "/dev/null"],
|
||||
capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
return proc, root
|
||||
|
||||
def test_it_runs_once_then_the_installer(self):
|
||||
proc, root = self._run(storage_exists=False)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
self.assertEqual((root / "setup.log").read_text(), "ran\n")
|
||||
self.assertIn("allow it", proc.stdout)
|
||||
self.assertIn("installer ran", proc.stdout)
|
||||
|
||||
def test_a_set_up_storage_is_left_alone(self):
|
||||
proc, root = self._run(storage_exists=True)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
self.assertFalse((root / "setup.log").exists())
|
||||
self.assertIn("installer ran", proc.stdout)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell")
|
||||
class APyLauncherWithoutARuntimeIsPassed(unittest.TestCase):
|
||||
"""findPython returned the py launcher as soon as it existed: one left
|
||||
with no usable runtime hid a Python 3.12 on PATH, and the bootstrap
|
||||
offered a second install or gave up."""
|
||||
|
||||
def test_python_on_path_runs_the_installer(self):
|
||||
folder = Path(_tempdir(self))
|
||||
shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1")
|
||||
(folder / "install.py").write_text("print('installer ran')\n")
|
||||
fake_bin = folder / "bin"
|
||||
fake_bin.mkdir()
|
||||
(fake_bin / "py").write_text("#!/bin/sh\necho 'No installed Python found!' >&2\nexit 1\n")
|
||||
(fake_bin / "py").chmod(0o755)
|
||||
(fake_bin / "python3").symlink_to(sys.executable)
|
||||
proc = subprocess.run(
|
||||
[shutil.which("pwsh"), "-NoProfile", "-File", str(folder / "install.ps1")],
|
||||
env={**os.environ, "PATH": f"{fake_bin}{os.pathsep}/usr/bin/nonexistent"},
|
||||
capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("installer ran", proc.stdout)
|
||||
|
||||
|
||||
class ThePowerShellWrapperPassesTheStatusOn(unittest.TestCase):
|
||||
"""Every non-zero exit became a red "installer failed" exception and
|
||||
code 1: a --check that found work read as a crash, 2 and 130 were lost."""
|
||||
@@ -3049,6 +3190,81 @@ class AndroidEmulatorsOutOfReachAreSaid(unittest.TestCase):
|
||||
self.assertIn("DraStic is installed", err.getvalue())
|
||||
|
||||
|
||||
class ACardPreparedOnAComputerGetsItsBoard(unittest.TestCase):
|
||||
"""A Batocera RPi4 card prepared on a PC took every board's files (8.9 GB
|
||||
where bcm2711 reads 5.8): the board was read only on the device, and
|
||||
only without --dest, which the full-screen interface always gives."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(_tempdir(self))
|
||||
self.boot = self.root / "BATOCERA"
|
||||
(self.boot / "boot").mkdir(parents=True)
|
||||
(self.boot / "boot" / "batocera.board").write_text("bcm2711\n")
|
||||
self.share = self.root / "SHARE"
|
||||
(self.share / "system").mkdir(parents=True)
|
||||
(self.share / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n")
|
||||
lists = self.root / "lists"
|
||||
(lists / "targets").mkdir(parents=True)
|
||||
one, two = b"pi core file", b"pc core file"
|
||||
self.list = lists / "batocera.json"
|
||||
self.list.write_text(json.dumps({
|
||||
"manifest_version": 2, "platform": "batocera", "base_destination": "bios",
|
||||
"files": [
|
||||
{"dest": "pi.bin", "sha1": hashlib.sha1(one).hexdigest(), "size": len(one),
|
||||
"repo_path": "bios/pi.bin", "cores": ["picore"], "systems": ["pi"]},
|
||||
{"dest": "pc.bin", "sha1": hashlib.sha1(two).hexdigest(), "size": len(two),
|
||||
"repo_path": "bios/pc.bin", "cores": ["pccore"], "systems": ["pc"]},
|
||||
],
|
||||
"system_cores": {"pi": ["picore"], "pc": ["pccore"]},
|
||||
}))
|
||||
(lists / "targets" / "batocera.json").write_text(
|
||||
json.dumps({"bcm2711": ["picore"], "x86_64": ["pccore"]}))
|
||||
|
||||
def run_main(self, *extra: str) -> tuple[str, list[dict]]:
|
||||
# A child process: main() changes the run's globals (the file list,
|
||||
# the event stream), which leaked into the next tests.
|
||||
code = ("import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); "
|
||||
"import install; card = Path(sys.argv[2]); "
|
||||
"install._mounted_cards = lambda: [card]; "
|
||||
"sys.argv = ['install.py', *sys.argv[3:]]; install.main()")
|
||||
env = {**os.environ, "RETROBIOS_OS": "linux", "HOME": str(self.root)}
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code, str(REPO_ROOT), str(self.boot),
|
||||
"--platform", "batocera", "--dest", str(self.share), "--manifest",
|
||||
str(self.list), "--offline", "--check", "--events", *extra],
|
||||
env=env, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
events = [json.loads(line) for line in proc.stdout.splitlines()
|
||||
if line.startswith("{")]
|
||||
return proc.stdout + proc.stderr, events
|
||||
|
||||
def test_the_boot_partition_names_the_board(self):
|
||||
with unittest.mock.patch.object(install, "_mounted_cards", return_value=[self.boot]):
|
||||
self.assertEqual(install.card_board("batocera"), "bcm2711")
|
||||
self.assertIsNone(install.card_board("retroarch"))
|
||||
|
||||
def test_the_run_keeps_that_boards_files(self):
|
||||
_out, events = self.run_main()
|
||||
[board] = [event for event in events if event["event"] == "board"]
|
||||
self.assertEqual((board["board"], board["applied"]), ("bcm2711", True))
|
||||
self.assertIn("found on the card", board["message"])
|
||||
files = {event["dest"] for event in events if event["event"] == "file"}
|
||||
self.assertEqual(files, {"pi.bin"})
|
||||
|
||||
def test_every_board_keeps_them_all(self):
|
||||
_out, events = self.run_main("--every-board")
|
||||
self.assertFalse([event for event in events if event["event"] == "board"])
|
||||
files = {event["dest"] for event in events if event["event"] == "file"}
|
||||
self.assertEqual(files, {"pi.bin", "pc.bin"})
|
||||
|
||||
def test_the_plan_says_it(self):
|
||||
state = install.TuiState()
|
||||
state.run = "check"
|
||||
install._take_event(state, {"event": "board", "platform": "batocera",
|
||||
"board": "bcm2711", "applied": True,
|
||||
"message": "Board bcm2711 found on the card (x)"})
|
||||
self.assertEqual(state.board, "Board bcm2711 found on the card (x)")
|
||||
|
||||
|
||||
class TheBoardTheSystemNamesIsTheTarget(unittest.TestCase):
|
||||
"""Batocera, Recalbox and ROCKNIX each write the board they were built
|
||||
for, but the installer read none of them, so a Batocera RPi4 installed
|
||||
@@ -3907,6 +4123,72 @@ def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess
|
||||
)
|
||||
|
||||
|
||||
class AMissingPackNamesTheOptionAndThePacksBeside(unittest.TestCase):
|
||||
"""A typo in --from-pack said "Error: X does not exist": not the option,
|
||||
not the pack sitting next to the name typed."""
|
||||
|
||||
def test_the_pack_beside_is_named(self):
|
||||
folder = Path(_tempdir(self))
|
||||
(folder / "RetroArch_BIOS_Pack.zip").write_bytes(b"")
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
install.PackSource._resolve([folder / "RetroArch_BIOS_Pak.zip"])
|
||||
said = str(raised.exception)
|
||||
self.assertIn("--from-pack names a pack ZIP", said)
|
||||
self.assertIn(f"The packs in {folder}: RetroArch_BIOS_Pack.zip.", said)
|
||||
|
||||
|
||||
class RomMIsToldToScan(unittest.TestCase, _LoopbackRun):
|
||||
"""RomM serves the firmware its library scan records, and the run ended
|
||||
on "installed" with no word of the scan the player waits on."""
|
||||
|
||||
def test_the_end_of_an_install_names_the_scan(self):
|
||||
env = self.serve(self)
|
||||
dest = self.home / "library" / "bios"
|
||||
dest.mkdir(parents=True)
|
||||
proc = _install_run(env, "--platform", "romm", "--dest", str(dest),
|
||||
"--system", "atari-lynx", "--no-input")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn(install.ROMM_SCAN_WORDS, " ".join(proc.stdout.split()))
|
||||
|
||||
|
||||
class AnUnknownNameIsAnEvent(unittest.TestCase):
|
||||
"""--events carried start then end with code 1 for an unknown platform,
|
||||
target or system: the reason was on stderr only, which readers ignore."""
|
||||
|
||||
def _events(self, *argv: str) -> tuple[int, list[dict]]:
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if key != "RETROBIOS_BASE_URL"}
|
||||
env[install.OFFLINE_VARIABLE] = "1"
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as dest:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--dest", dest,
|
||||
"--check", "--events", "--no-input", *argv],
|
||||
env=env, capture_output=True, text=True, timeout=120,
|
||||
stdin=subprocess.DEVNULL)
|
||||
return proc.returncode, [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
|
||||
def test_each_refusal_says_why(self):
|
||||
manifest = str(REPO_ROOT / "install" / "retroarch.json")
|
||||
for argv, words in (
|
||||
(["--platform", "nope"], "Unknown platform 'nope'"),
|
||||
(["--platform", "retroarch", "--manifest", manifest, "--system", "nope"],
|
||||
"unknown system: nope"),
|
||||
(["--platform", "retroarch", "--manifest", manifest, "--core", "nope"],
|
||||
"unknown core: nope"),
|
||||
(["--platform", "retroarch", "--manifest", manifest, "--emulator", "nope"],
|
||||
"unknown emulator: nope")):
|
||||
with self.subTest(argv=argv):
|
||||
code, events = self._events(*argv)
|
||||
self.assertEqual(code, 2)
|
||||
errors = [event for event in events if event["event"] == "error"]
|
||||
self.assertEqual(len(errors), 1, events)
|
||||
self.assertEqual((errors[0]["stage"], errors[0]["reason"]),
|
||||
("option", "unknown"))
|
||||
self.assertIn(words, errors[0]["message"])
|
||||
self.assertFalse(errors[0]["message"].startswith("Error"))
|
||||
self.assertEqual(events[-1]["event"], "end")
|
||||
|
||||
|
||||
class ATypoGetsTheClosestName(unittest.TestCase):
|
||||
"""--platform batocra, --system playstation or --core swanstaton were
|
||||
answered with every name the platform has, up to 400 on one line, and
|
||||
@@ -3929,14 +4211,45 @@ class ATypoGetsTheClosestName(unittest.TestCase):
|
||||
self.assertIn(f"--platform {plat}", out.getvalue())
|
||||
|
||||
def test_a_system_that_gets_no_list_is_still_refused(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
||||
"""TrimUI, which --list-platforms prints, was answered "Unknown
|
||||
platform" with no way forward."""
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
|
||||
install.normalize_platform("TrimUI")
|
||||
self.assertNotIn("Unknown platform", err.getvalue())
|
||||
self.assertIn("--platform retroarch --dest FOLDER", err.getvalue())
|
||||
|
||||
def test_a_frontend_without_a_bios_folder_says_what_to_do(self):
|
||||
"""ES-DE, LaunchBox and Android have no BIOS folder: each emulator
|
||||
reads its own, and --platform es-de was "Unknown platform"."""
|
||||
for typed, said in (("es-de", "ES-DE"), ("esde", "ES-DE"),
|
||||
("launchbox", "LaunchBox"), ("android", "Android")):
|
||||
err = io.StringIO()
|
||||
with self.subTest(typed=typed), contextlib.redirect_stderr(err), \
|
||||
self.assertRaises(SystemExit) as raised:
|
||||
install.normalize_platform(typed)
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
self.assertIn(f"{said} has no BIOS folder of its own: run again without "
|
||||
"--platform", err.getvalue())
|
||||
|
||||
def test_a_system_name_is_suggested(self):
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
|
||||
install.normalize_platform("spruce")
|
||||
self.assertIn("Did you mean: spruceOS?", err.getvalue())
|
||||
# The distinct names --list-platforms prints: Batocera is both a
|
||||
# platform and a system it recognises.
|
||||
names = [*install.AVAILABLE_PLATFORMS,
|
||||
*(line.split(":")[0] for line in install._system_lines()),
|
||||
*install._NO_BIOS_FOLDER]
|
||||
listed = len({install._name_key(name) for name in names})
|
||||
self.assertIn(f"lists all {listed}.", err.getvalue())
|
||||
|
||||
def test_a_platform_typo_is_answered_with_one_name(self):
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as raised:
|
||||
install.normalize_platform("batocra")
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
self.assertIn("Did you mean: batocera?", err.getvalue())
|
||||
self.assertNotIn("recalbox", err.getvalue())
|
||||
|
||||
@@ -3968,7 +4281,7 @@ class ATypoGetsTheClosestName(unittest.TestCase):
|
||||
|
||||
def test_a_target_typo(self):
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
|
||||
install._unknown_target("swich", "retroarch", {"switch": {}, "desktop": {}})
|
||||
self.assertIn("Did you mean: switch?", err.getvalue())
|
||||
self.assertIn("--list-targets", err.getvalue())
|
||||
@@ -4232,6 +4545,59 @@ class AQuestionLeftWithCtrlDOrCtrlCIsAStop(unittest.TestCase):
|
||||
self.assertIn("Stopped", out)
|
||||
|
||||
|
||||
class APlanGroupFitsItsLine(unittest.TestCase):
|
||||
"""Batocera's group of systems sharing MAME's device ROMs was one plan
|
||||
line of 732 characters: counted as one row, it wrapped to ten and the
|
||||
total scrolled out of sight."""
|
||||
|
||||
def _plan(self, files: list[dict], size: tuple[int, int], verbose: bool = False) -> str:
|
||||
out = io.StringIO()
|
||||
with unittest.mock.patch.object(install.shutil, "get_terminal_size",
|
||||
return_value=os.terminal_size(size)), \
|
||||
contextlib.redirect_stdout(out):
|
||||
install._print_plan(files, Path("dest"), install.Terminal(io.StringIO()),
|
||||
verbose=verbose)
|
||||
return out.getvalue()
|
||||
|
||||
def test_a_wide_group_is_named_by_its_first_two(self):
|
||||
shared = [f"system-number-{n:02d}" for n in range(40)]
|
||||
text = self._plan([{"dest": "dev.rom", "size": 1, "systems": shared}], (80, 40))
|
||||
self.assertIn("system-number-00, system-number-01 and 38 more systems", text)
|
||||
self.assertLess(max(map(len, text.splitlines())), 80)
|
||||
self.assertIn("system-number-39", self._plan(
|
||||
[{"dest": "dev.rom", "size": 1, "systems": shared}], (80, 40), verbose=True))
|
||||
|
||||
def test_wrapped_rows_count_against_the_screen(self):
|
||||
# Six lines that each wrap to three rows on a 20-column terminal of
|
||||
# 20 rows: 18 rows of listing, past what the screen leaves it.
|
||||
files = [{"dest": f"{'x' * 50}{n}.bin", "size": 1, "systems": [f"s{n}"]}
|
||||
for n in range(3)]
|
||||
text = self._plan(files, (20, 20))
|
||||
self.assertIn("the largest first", text)
|
||||
|
||||
|
||||
class ReadersAreThePlatformsCores(unittest.TestCase):
|
||||
"""--check said bbcb.zip is "read by clk, mame, mame0258, mame0278" on
|
||||
Batocera: system_cores reaches every profile, Recalbox's builds too."""
|
||||
|
||||
def test_an_off_platform_core_is_not_named(self):
|
||||
manifest = {
|
||||
"system_cores": {"bbc": ["clk", "mame", "mame0258"]},
|
||||
"files": [{"dest": "bbcb.zip", "systems": ["bbc"], "cores": None},
|
||||
{"dest": "other.rom", "systems": ["x"], "cores": ["clk", "mame"]}],
|
||||
}
|
||||
self.assertEqual(install.readers(manifest["files"][0], manifest), ["clk", "mame"])
|
||||
|
||||
def test_on_the_real_lists(self):
|
||||
for plat, dest, absent in (("batocera", "bbcb.zip", "mame0258"),
|
||||
("retroarch", "prboom.wad", "bizhawk")):
|
||||
manifest = json.loads((REPO_ROOT / "install" / f"{plat}.json").read_text())
|
||||
[entry] = [e for e in manifest["files"] + manifest["omitted_files"]
|
||||
if e["dest"] == dest]
|
||||
with self.subTest(platform=plat):
|
||||
self.assertNotIn(absent, install.readers(entry, manifest))
|
||||
|
||||
|
||||
class WhatTheCollectionLacksSaysForWhom(unittest.TestCase):
|
||||
"""Required files the installer cannot supply were listed by bare name:
|
||||
DARK.GOB, Data.rsdk, ... and 69 more, with no system and no emulator."""
|
||||
@@ -4246,6 +4612,16 @@ class WhatTheCollectionLacksSaysForWhom(unittest.TestCase):
|
||||
self.assertIn(" dark-forces (theforceengine): DARK.GOB, SOUNDS.GOB", lines)
|
||||
self.assertIn(" sonic-mania (sonicmania): Data.rsdk", lines)
|
||||
self.assertIn("(3 files)", lines[0])
|
||||
# The page of each emulator under its group: the index alone left a
|
||||
# search over several hundred pages.
|
||||
self.assertEqual(lines[lines.index(" dark-forces (theforceengine): DARK.GOB, "
|
||||
"SOUNDS.GOB") + 1],
|
||||
f" {install.SITE_URL}emulators/theforceengine/")
|
||||
self.assertIn(f" {install.SITE_URL}emulators/sonicmania/", lines)
|
||||
self.assertNotIn(f"{install.SITE_URL}emulators/\n", "\n".join(lines) + "\n")
|
||||
|
||||
def test_a_group_with_no_emulator_keeps_the_index(self):
|
||||
lines = install._needed_lines([{"dest": "x.bin", "system": "x"}], "Needed")
|
||||
self.assertIn(f"{install.SITE_URL}emulators/", lines[-1])
|
||||
|
||||
|
||||
@@ -4521,7 +4897,7 @@ class TheEmulatorNamesHaveTheirOwnListing(unittest.TestCase, _LoopbackRun):
|
||||
def test_the_hint_counts_what_the_listing_prints(self):
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--no-input",
|
||||
"--emulator", "nope")
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout)
|
||||
self.assertEqual(proc.returncode, 2, proc.stdout)
|
||||
found = re.search(r"--list-emulators lists all (\d+)\.", proc.stderr)
|
||||
self.assertIsNotNone(found, proc.stderr)
|
||||
listing = _install_run(self.env, "--platform", "retroarch", "--no-input",
|
||||
@@ -5513,6 +5889,39 @@ class TheInstallerPageIsWrittenFromTheCode(unittest.TestCase):
|
||||
self.assertIn("route-keys", said)
|
||||
|
||||
|
||||
class AStrippedPythonIsNamed(unittest.TestCase):
|
||||
"""A Python built without ssl blamed the network three times, and one
|
||||
without zlib or unicodedata stopped on a ModuleNotFoundError traceback."""
|
||||
|
||||
def _run(self, hidden: str, *argv: str) -> subprocess.CompletedProcess:
|
||||
code = (f"import runpy, sys; sys.modules[{hidden!r}] = None; "
|
||||
"sys.argv = sys.argv[1:]; "
|
||||
"runpy.run_path(sys.argv[0], run_name='__main__')")
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if key not in ("RETROBIOS_BASE_URL", install.OFFLINE_VARIABLE)}
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", code, str(REPO_ROOT / "install.py"), *argv],
|
||||
env=env, capture_output=True, text=True, timeout=60,
|
||||
stdin=subprocess.DEVNULL)
|
||||
|
||||
def test_a_missing_c_module_is_named(self):
|
||||
for hidden in ("zlib", "unicodedata"):
|
||||
with self.subTest(module=hidden):
|
||||
proc = self._run(hidden, "--help")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
self.assertIn(f"built without its {hidden} module", proc.stderr)
|
||||
|
||||
def test_no_ssl_is_said_once_not_retried(self):
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as dest:
|
||||
proc = self._run("ssl", "--platform", "retroarch", "--dest", dest,
|
||||
"--check", "--no-input")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("built without its ssl module and cannot use HTTPS", proc.stderr)
|
||||
self.assertNotIn("trying again", proc.stderr)
|
||||
self.assertNotIn("HTTPS_PROXY", proc.stderr)
|
||||
|
||||
|
||||
class NetworkFailuresInPlainWords(unittest.TestCase):
|
||||
"""Offline, the run printed "[Errno -2] Name or service not known". A
|
||||
beginner needs "no internet connection", and the raw text only under
|
||||
@@ -5934,7 +6343,8 @@ class TheListVersionIsComparedWithTheSystems(unittest.TestCase):
|
||||
self.assertEqual((listed, installed), ("43.1", "41"))
|
||||
self.assertEqual(
|
||||
text, "This list was built for Batocera 43.1, this machine runs 41. "
|
||||
"Batocera's own BIOS check may report some files as wrong though they work.")
|
||||
"Batocera's own BIOS check reads the list of the version it runs, and "
|
||||
"may show some files as not matching it.")
|
||||
self.assertIsNone(self.note("batocera", "41"))
|
||||
|
||||
def test_recalbox_reads_only_a_release(self):
|
||||
@@ -6045,7 +6455,8 @@ class TheListVersionIsSaidBeforeAndAfter(unittest.TestCase):
|
||||
)
|
||||
|
||||
SAID = ("This list was built for RetroBat 8.3.1, this machine runs 8.2.0. "
|
||||
"RetroBat's own BIOS check may report some files as wrong though they work.")
|
||||
"RetroBat's own BIOS check reads the list of the version it runs, and "
|
||||
"may show some files as not matching it.")
|
||||
|
||||
def test_line_mode_says_it_before_the_install_and_with_the_result(self):
|
||||
proc = self.run_installer()
|
||||
|
||||
@@ -415,12 +415,25 @@ class AnUnknownTargetStopsTheRunFirst(unittest.TestCase):
|
||||
contextlib.redirect_stderr(io.StringIO()) as err:
|
||||
with self.assertRaises(SystemExit) as stop:
|
||||
install.main()
|
||||
self.assertEqual(stop.exception.code, 1)
|
||||
self.assertIn("unknown target 'steamos' for retroarch", err.getvalue())
|
||||
self.assertEqual(stop.exception.code, 2)
|
||||
self.assertIn("unknown target 'steamos' for RetroArch", err.getvalue())
|
||||
self.assertIn("Nothing was installed", err.getvalue())
|
||||
fetch.assert_not_called()
|
||||
|
||||
|
||||
class APlatformWithoutTargetsSaysSo(unittest.TestCase):
|
||||
"""`--target switch` on MiSTer FPGA read "unknown target", a typo to the
|
||||
user, when no value can work there."""
|
||||
|
||||
def test_the_way_forward_is_named(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()) as err, \
|
||||
self.assertRaises(SystemExit):
|
||||
install._unknown_target("switch", "misterfpga", {})
|
||||
said = err.getvalue()
|
||||
self.assertIn("MiSTer FPGA has no hardware targets: run again without --target.", said)
|
||||
self.assertNotIn("unknown target", said)
|
||||
|
||||
|
||||
class _CoreRepository(FakeRepository):
|
||||
"""The fake repository with the two maps and a portable RetroArch."""
|
||||
|
||||
|
||||
@@ -823,6 +823,59 @@ class APartlyClosedHome(unittest.TestCase):
|
||||
self.assertEqual([entry["bios_dir"] for entry in detect["platforms"]], [str(system)])
|
||||
|
||||
|
||||
class RetroDeckBiosFolderNamedAsDest(unittest.TestCase):
|
||||
"""`--platform retrodeck --dest ~/retrodeck/bios`, as --help asks for a
|
||||
BIOS folder, put every file under bios/bios, which RetroDECK never reads."""
|
||||
|
||||
def test_the_files_go_below_the_retrodeck_folder(self):
|
||||
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
self.addCleanup(shutil.rmtree, root, True)
|
||||
(root / "bios").mkdir()
|
||||
choice = install.card_choice("retrodeck", "retrodeck", root / "bios")
|
||||
self.assertEqual(choice.folder, root)
|
||||
self.assertIn("RetroDECK's BIOS folder", choice.note)
|
||||
self.assertFalse(choice.refused)
|
||||
|
||||
def test_the_retrodeck_folder_itself_is_kept(self):
|
||||
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
self.addCleanup(shutil.rmtree, root, True)
|
||||
(root / "bios").mkdir()
|
||||
choice = install.card_choice("retrodeck", "retrodeck", root)
|
||||
self.assertEqual((choice.folder, choice.note), (root, ""))
|
||||
|
||||
|
||||
class AnUnreadableRetroArchConfigIsSaid(unittest.TestCase):
|
||||
"""A retroarch.cfg a `sudo retroarch` left at 0600 for root was read as
|
||||
no config at all: RetroArch was not found and nothing said why."""
|
||||
|
||||
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
|
||||
"a file without read permission is POSIX")
|
||||
def test_the_closed_config_is_named(self):
|
||||
with FakeRepository() as repo:
|
||||
home = repo.root / "home"
|
||||
cfg = home / ".config" / "retroarch" / "retroarch.cfg"
|
||||
cfg.parent.mkdir(parents=True)
|
||||
cfg.write_text('system_directory = "/media/card/bios"\n')
|
||||
env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux"}
|
||||
os.chmod(cfg, 0o000)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--check",
|
||||
"--no-input", "--events"],
|
||||
env=env, capture_output=True, text=True, timeout=60,
|
||||
stdin=subprocess.DEVNULL)
|
||||
finally:
|
||||
os.chmod(cfg, 0o644)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
refusal = (f"Could not look for setups: this user cannot open {cfg} "
|
||||
"(Permission denied)")
|
||||
self.assertEqual(" ".join(proc.stderr.split()).count(refusal), 1, proc.stderr)
|
||||
events = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
unsearched = [event for event in events if event["event"] == "unsearched"]
|
||||
self.assertEqual([(event["folder"], event["reason"]) for event in unsearched],
|
||||
[(str(cfg), "Permission denied")])
|
||||
|
||||
|
||||
class OutputToAFileAsksNothing(unittest.TestCase):
|
||||
"""`install.py > log.txt` at a terminal: the menu went into the file
|
||||
and the run waited on an answer to a prompt nobody could see."""
|
||||
|
||||
@@ -22,6 +22,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import zipfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
@@ -777,6 +778,18 @@ class Unavailable(GamesTestCase):
|
||||
install.plan_games("retroarch", self.card.bios, manifest(), os_type="linux")
|
||||
self.assertEqual(caught.exception.reason, "offline")
|
||||
|
||||
def test_a_failed_download_says_why(self):
|
||||
"""The cause was printed only under RETROBIOS_DEBUG: a proxy's 403
|
||||
or a TLS refusal read as a bare "could not be downloaded"."""
|
||||
shutil.rmtree(self.card.lists / "games")
|
||||
refusal = urllib.error.HTTPError(
|
||||
"https://example.invalid/index.json", 403, "Forbidden", {}, None)
|
||||
with mock.patch.object(install, "_fetch_json", side_effect=refusal), \
|
||||
self.assertRaises(install.GamesUnavailable) as caught:
|
||||
install.GamesData().load_index(network=True, attempts=1)
|
||||
self.assertEqual(caught.exception.reason, "network")
|
||||
self.assertIn("403", caught.exception.words)
|
||||
|
||||
def test_a_document_that_is_not_the_one_listed_is_refused(self):
|
||||
(self.card.lists / "games" / "setups" / "retroarch.json").write_text("{}")
|
||||
with self.assertRaises(install.GamesUnavailable) as caught:
|
||||
|
||||
@@ -386,6 +386,75 @@ class SplitParts(Scratch):
|
||||
self.assertFalse(self.dest.exists())
|
||||
|
||||
|
||||
def _carrying(pack: Path, extra: dict) -> None:
|
||||
"""Add what a pack built since carries to its manifest.json."""
|
||||
with zipfile.ZipFile(pack) as archive:
|
||||
members = {info.filename: archive.read(info) for info in archive.infolist()}
|
||||
listed = {**json.loads(members["manifest.json"]), **extra}
|
||||
members["manifest.json"] = json.dumps(listed).encode()
|
||||
with zipfile.ZipFile(pack, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, data in members.items():
|
||||
archive.writestr(name, data)
|
||||
|
||||
|
||||
# A GBA board and a PlayStation board: PAYLOAD's files split between them.
|
||||
BOARDS = {"gba-board": ["mgba"], "psx-board": ["pcsx_rearmed"]}
|
||||
|
||||
|
||||
class ABoardWithoutTheNetwork(Scratch):
|
||||
"""A pack alone, or --offline, never applied a board: --target was
|
||||
refused, and a board found on the device was ignored. A Batocera RPi4
|
||||
card took 8.9 GB from its pack where its board reads 5.8."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.downloads = self.root / "downloads"
|
||||
self.downloads.mkdir()
|
||||
self.pack = _pack(self.downloads, "RetroArch_v1_BIOS_Pack.zip", PAYLOAD)
|
||||
|
||||
def installed(self) -> set[str]:
|
||||
return {p.relative_to(self.dest).as_posix() for p in self.dest.rglob("*")
|
||||
if p.is_file()}
|
||||
|
||||
def test_the_builder_writes_both_into_the_pack_list(self):
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
import generate_pack # noqa: PLC0415
|
||||
|
||||
install_list = manifest()
|
||||
board_file = REPO_ROOT / "platforms" / "targets" / "batocera.yml"
|
||||
boards = generate_pack.target_list(board_file)
|
||||
self.assertEqual(boards, json.loads(
|
||||
(REPO_ROOT / "install" / "targets" / "batocera.json").read_text()))
|
||||
pack_list = {"files": []}
|
||||
generate_pack.annotate_pack_systems(
|
||||
pack_list, generate_pack.pack_systems(install_list, boards))
|
||||
self.assertEqual(pack_list["install_list"], install_list)
|
||||
self.assertEqual(pack_list["targets"], boards)
|
||||
|
||||
def test_the_pack_applies_its_board_list(self):
|
||||
_carrying(self.pack, {"install_list": manifest(), "targets": BOARDS})
|
||||
proc = self.run_installer("--from-pack", str(self.pack), "--target", "gba-board",
|
||||
"--no-input")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(self.installed(), {"gba_bios.bin"})
|
||||
|
||||
def test_a_list_beside_the_manifest_applies_offline(self):
|
||||
(self.root / "targets").mkdir()
|
||||
(self.root / "targets" / "retroarch.json").write_text(json.dumps(BOARDS))
|
||||
self.complete()
|
||||
proc = self.run_installer("--offline", "--check", "--manifest", str(self.list),
|
||||
"--target", "psx-board", "--no-input")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("for target psx-board", proc.stdout)
|
||||
|
||||
def test_a_pack_without_a_list_says_why(self):
|
||||
proc = self.run_installer("--from-pack", str(self.pack), "--target", "gba-board",
|
||||
"--no-input")
|
||||
self.assertEqual(proc.returncode, 2)
|
||||
self.assertIn(f"{self.pack.name} carries none", proc.stderr)
|
||||
self.assertFalse(self.dest.exists())
|
||||
|
||||
|
||||
class FromPackInstallsOffline(Scratch):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
@@ -648,7 +648,7 @@ class TheOneLinerNarrows(unittest.TestCase):
|
||||
def test_an_unknown_system_is_refused_before_any_download(self):
|
||||
with FakeRepository() as repo:
|
||||
proc = repo.run("--system", "sega-saturn")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertEqual(proc.returncode, 2)
|
||||
self.assertIn("unknown system: sega-saturn", proc.stderr)
|
||||
self.assertIn("nintendo-gba", proc.stderr)
|
||||
self.assertEqual(repo.installed(), [])
|
||||
@@ -1239,7 +1239,7 @@ class ThePlatformsOwnSystemNamesAreAccepted(unittest.TestCase):
|
||||
listing = repo.run("--list-systems", "--no-input")
|
||||
self.assertIn("nintendo-gba (gba)", listing.stdout)
|
||||
typo = repo.run("--system", "psxx", "--no-input")
|
||||
self.assertEqual(typo.returncode, 1)
|
||||
self.assertEqual(typo.returncode, 2)
|
||||
self.assertIn("Did you mean: sony-playstation", typo.stderr)
|
||||
|
||||
|
||||
@@ -1570,7 +1570,7 @@ class AnIdAnotherSetupUses(unittest.TestCase):
|
||||
"--dest", str(never), "--system", "snk-neogeo", "--dry-run", "--no-input"],
|
||||
capture_output=True, text=True, timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr)
|
||||
self.assertIn("Did you mean: arcade?", proc.stderr)
|
||||
self.assertNotIn("snk-neogeo-cd", proc.stderr)
|
||||
self.assertFalse(never.exists())
|
||||
|
||||
@@ -4591,6 +4591,20 @@ def _installed_state(dest: Path, code: int = 0, failed: list | None = None) -> i
|
||||
return state
|
||||
|
||||
|
||||
class TheRomMResultNamesTheScan(unittest.TestCase):
|
||||
"""RomM's player sees firmware its scan recorded: the result said
|
||||
"installed" and left the scan unsaid."""
|
||||
|
||||
def test_the_result_and_its_farewell_say_it(self):
|
||||
state = _installed_state(SYSTEM_DIR)
|
||||
state.platform = "romm"
|
||||
_title, lines = install.tui_outcome(state)
|
||||
self.assertIn(install.ROMM_SCAN_WORDS, lines)
|
||||
self.assertIn(install.ROMM_SCAN_WORDS, install._farewell_lines(state))
|
||||
state.platform = "retroarch"
|
||||
self.assertNotIn(install.ROMM_SCAN_WORDS, install.tui_outcome(state)[1])
|
||||
|
||||
|
||||
class AppOnTheCard(unittest.TestCase):
|
||||
"""The RetroBIOS app offered on the result screen and in the options."""
|
||||
|
||||
@@ -7162,10 +7176,10 @@ class _LineModeFindings(_CardFixtures):
|
||||
by, and each remedy changes the outcome."""
|
||||
listing, pack, listed = self.minui()
|
||||
self.assertEqual(listed["list_records"], ["cores", "regions"])
|
||||
code, text, _kept = self.narrowed("minui", from_pack=[pack], core=["mgba"])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("Add --manifest FILE, a copy of install/minui.json, which records them",
|
||||
text)
|
||||
# The pack carries its install list since 2026-11: --core narrows it
|
||||
# alone, as with the list named beside it.
|
||||
code, text, kept = self.narrowed("minui", from_pack=[pack], core=["mgba"])
|
||||
self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text)
|
||||
code, text, kept = self.narrowed("minui", from_pack=[pack], manifest=listing,
|
||||
core=["mgba"])
|
||||
self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text)
|
||||
@@ -7174,8 +7188,9 @@ class _LineModeFindings(_CardFixtures):
|
||||
self.assertEqual(listed["list_records"], ["regions", "systems"])
|
||||
code, text, _kept = self.narrowed("misterfpga", from_pack=[mister], core=["x"])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("MiSTer FPGA's own file list records no cores either. Run it without "
|
||||
"--core, or narrow with --system.", text)
|
||||
# Its own list, which the pack carries, refuses it as --manifest would.
|
||||
self.assertIn("the misterfpga file list records no cores, so --core cannot narrow it",
|
||||
text)
|
||||
self.assertNotIn("--manifest", text)
|
||||
code, text, kept = self.narrowed("misterfpga", from_pack=[mister],
|
||||
system=["nintendo-gba"])
|
||||
@@ -10920,7 +10935,8 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
|
||||
"""code_audit_19 P2-1, tui_audit_12 P2-4: line mode said it, the
|
||||
screen dropped the version event."""
|
||||
said = ("This list was built for RetroBat 8.3.1, this machine runs 8.2.0.1. "
|
||||
"RetroBat's own BIOS check may report some files as wrong though they work.")
|
||||
"RetroBat's own BIOS check reads the list of the version it runs, and "
|
||||
"may show some files as not matching it.")
|
||||
state = fresh_state()
|
||||
state.stack = ["setup"]
|
||||
with mock.patch.object(install.Path, "exists", return_value=True):
|
||||
|
||||
@@ -44,6 +44,22 @@ class GuidesPointAtTheReadPath(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class ACardOpenedOnAComputerShowsItsShareDrive(unittest.TestCase):
|
||||
"""The SD card steps said /userdata/bios/ and /recalbox/share/bios/,
|
||||
paths of the running system. On a computer the SHARE drive opens at its
|
||||
root, bios/ beside roms/ and system/, and no such folder exists."""
|
||||
|
||||
def test_the_card_steps_name_the_drive_folder(self):
|
||||
for name in ("batocera", "recalbox"):
|
||||
text = _build_readme(name, name, "", 1, 1)
|
||||
card = text[text.index("Manual (SD card)"):]
|
||||
card = card[:card.index("\n\n", card.index("1."))]
|
||||
with self.subTest(platform=name):
|
||||
self.assertIn("bios folder at the root of the SHARE drive", card)
|
||||
self.assertNotIn("/userdata/bios/ on", card)
|
||||
self.assertNotIn("Navigate to /recalbox", card)
|
||||
|
||||
|
||||
class GuidesNameShippedInstallers(unittest.TestCase):
|
||||
def test_every_installer_named_exists(self):
|
||||
"""RetroBat's guide sent users to an install.bat no release carries."""
|
||||
|
||||
@@ -53,11 +53,10 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh
|
||||
</TabItem>
|
||||
<TabItem label="Android (Termux)">
|
||||
|
||||
Install **Termux**, a terminal app, and run these lines.
|
||||
Install **Termux**, a terminal app, and run this line. It installs Python
|
||||
and asks Android for storage access: allow both.
|
||||
|
||||
```sh title="Terminal"
|
||||
pkg install python
|
||||
termux-setup-storage
|
||||
curl -fsSL https://abdess.github.io/retrobios/i | sh
|
||||
```
|
||||
|
||||
|
||||
+26
-15
@@ -124,9 +124,12 @@ The platform is read from the pack's name when `--platform` is not given. The
|
||||
file list is the one the pack carries (`manifest.json`), with each file's
|
||||
SHA-1 and size, and every file is checked against it before it takes its
|
||||
place. A data tree (Dolphin's `Sys/`, PPSSPP's assets) comes from the pack
|
||||
too, not from its source archive. `--target` reads a core list from the
|
||||
network and is refused with `--from-pack`. A board found on the device is not
|
||||
applied either: every board's files are installed. At a terminal the run
|
||||
too, not from its source archive. A pack built since 2026-11 also carries
|
||||
its platform's install list and the cores of each board (the
|
||||
`install/targets/<platform>.json` list): `--target` applies with the pack
|
||||
alone, and so does a board found on the device. An older pack carries
|
||||
neither, and `--target` is then refused before anything is written. At a
|
||||
terminal the run
|
||||
finds a card mounted here, asks the folder when nothing is found, and names
|
||||
the BIOS folder on its menu before anything is copied.
|
||||
|
||||
@@ -355,8 +358,8 @@ The file list names the version of the platform it was built for (Batocera
|
||||
43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the
|
||||
installer reads it, and when the two differ it says so before the install and
|
||||
again with the result: "This list was built for Batocera 43.1, this machine
|
||||
runs 41. Batocera's own BIOS check may report some files as wrong though they
|
||||
work." The install goes on. The files read are
|
||||
runs 41. Batocera's own BIOS check reads the list of the version it runs, and
|
||||
may show some files as not matching it." The install goes on. The files read are
|
||||
`/usr/share/batocera/batocera.version` on Batocera, `/recalbox/recalbox.version`
|
||||
on Recalbox (a release, not a branch build), `system\version.info` beside the
|
||||
`bios` folder of RetroBat, and `VERSION_ID` in `/etc/os-release` on Lakka and
|
||||
@@ -507,7 +510,8 @@ BIOS folder: /userdata/bios
|
||||
427 files were not installed: the collection does not hold them, or its copy fails their check.
|
||||
Needed to start an emulator (1 file):
|
||||
capcom-cps3 (fbneo): sfiii2a.zip
|
||||
Each emulator's page says what these files are: https://abdess.github.io/retrobios/emulators/
|
||||
https://abdess.github.io/retrobios/emulators/fbneo/
|
||||
Each emulator's page, linked above, says what these files are.
|
||||
Not available here: DARK.GOB, DARKWAR.RTL, DARKWAR.WAD and 76 more, game data their games read. Copy them from your own copy of the game into /userdata/bios/, /userdata/bios/Source/, /userdata/bios/base/ and 10 more folders.
|
||||
Game data: files a game or a game engine reads, of no use to the other cores.
|
||||
To check this later, run the same command with --check.
|
||||
@@ -952,9 +956,9 @@ what the options name, everything by default.
|
||||
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| <span id="option-platform"></span>`--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. Unknown names are refused with the closest ones |
|
||||
| <span id="option-platform"></span>`--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. ES-DE, LaunchBox and Android, which keep no BIOS folder, are answered with the run that fills each emulator's folder. Unknown names are refused with the closest ones |
|
||||
| <span id="option-dest"></span>`--dest PATH` | Destination directory, overriding detection. Without `--platform`, the file list is the platform detected here: at a terminal the installer asks, Enter taking the one found, and otherwise it uses the one found, RetroArch's when nothing is found, and refuses when several are. The run prints whose list it is. A folder that does not exist is named with the nearest one that does, then created, except for a system that runs from its SD card, which refuses it. When more than its last level is missing (a mistyped path), a run at a terminal asks first, and the answer defaults to no |
|
||||
| <span id="option-target"></span>`--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given |
|
||||
| <span id="option-target"></span>`--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given, from a pack alone too when it carries the core list per board. On a computer, the board a Batocera card names on its BATOCERA partition (`boot/batocera.board`) is applied the same way, in line mode and on the full screen |
|
||||
| <span id="option-every-board"></span>`--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices |
|
||||
| <span id="option-installed-cores"></span>`--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) |
|
||||
| <span id="option-cores-dir"></span>`--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores` |
|
||||
@@ -979,7 +983,7 @@ what the options name, everything by default.
|
||||
| <span id="option-events"></span>`--events` | Write the run as JSON events on standard output, one object per line, and the usual text on standard error. Implies `--no-input`. See [Machine-readable events](#machine-readable-events) |
|
||||
| <span id="option-from"></span>`--from FOLDER` | Copy the BIOS files a folder holds (an old SD card, a backup, a ZIP) instead of downloading them, each one recognised by its hash and copied, never moved. Repeat for several folders. See [Copy from an old card](#copy-from-an-old-card) |
|
||||
| <span id="option-from-pack"></span>`--from-pack PATH` | Install from a pack already downloaded: its ZIP, one of its parts (the others are found in the same folder), or the folder holding them. Makes no network request and checks every file's hash. See [Without a network](#without-a-network-offline-install) |
|
||||
| <span id="option-offline"></span>`--offline` | Make no network request. `--check`, `--from`, `--from-pack` and `--manifest` work without one, the file list stops the run at once when none of them gives it, and a file the run would download fails at once. `RETROBIOS_INSTALL_OFFLINE=1` does the same. Refused with `--target` and `--list-targets`, which read a core list from the network |
|
||||
| <span id="option-offline"></span>`--offline` | Make no network request. `--check`, `--from`, `--from-pack` and `--manifest` work without one, the file list stops the run at once when none of them gives it, and a file the run would download fails at once. `RETROBIOS_INSTALL_OFFLINE=1` does the same. `--target` and `--list-targets` read the core list per board a pack carries, or `targets/<platform>.json` beside `--manifest`, and are refused when neither gives one |
|
||||
| <span id="option-manifest"></span>`--manifest FILE` | Read the file list from FILE, a copy of `install/<platform>.json`, instead of downloading it. See [Without a network](#without-a-network-offline-install) |
|
||||
| <span id="option-restore"></span>`--restore FOLDER` | Put back the files a run replaced and kept in FOLDER, a dated folder inside `retrobios-backup`. Takes no other option but `--dry-run` and `--no-input`. See [Files you already had](#files-you-already-had) |
|
||||
| <span id="option-tui"></span>`--tui` | Open the full-screen interface, or stop with the reason it cannot open here (code 2) instead of falling back to the questions. Wins over `RETROBIOS_PLAIN` and `ACCESSIBLE`. See [The full-screen interface](#the-full-screen-interface) |
|
||||
@@ -1355,6 +1359,11 @@ folder, whose parent is the library. Run from the host of a container, where
|
||||
library laid out as `{platform}/roms` with no template found is refused
|
||||
rather than filled in the wrong place, and the message gives the command.
|
||||
|
||||
RomM's player reads the firmware its library scan has recorded, and RomM
|
||||
watches the library folder only when `ENABLE_RESCAN_ON_FILESYSTEM_CHANGE` is
|
||||
set. An install that put files in place ends by saying so: start a scan in
|
||||
RomM so that the player finds them.
|
||||
|
||||
## Android
|
||||
|
||||
Android reports itself as Linux, so the runtime is named from its own
|
||||
@@ -1398,11 +1407,12 @@ writable (Android 10 and earlier), it is a standalone copy target.
|
||||
|
||||
Under scoped storage the two RetroArch config paths are unreadable from Termux on
|
||||
Android 11 and later, so on current devices the folders are what answers.
|
||||
Running `termux-setup-storage` once is what makes the shared one reachable:
|
||||
Running `termux-setup-storage` once is what makes the shared one reachable.
|
||||
The one-line command runs it when `~/storage` is missing, then waits up to a
|
||||
minute for the permission Android asks for, as it offers `pkg install python`
|
||||
when Python is missing:
|
||||
|
||||
```bash
|
||||
pkg install python
|
||||
termux-setup-storage
|
||||
curl -fsSL https://abdess.github.io/retrobios/i | sh
|
||||
```
|
||||
|
||||
@@ -1716,8 +1726,8 @@ fields, the installer refuses to narrow rather than install everything.
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | everything asked for is installed or already in place |
|
||||
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit |
|
||||
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`, `--target` with `--from-pack` or `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set) when no core list per board is at hand (the pack carries none and none is beside `--manifest` as `targets/<platform>.json`), `--list-targets` with `--offline` and neither, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit |
|
||||
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on |
|
||||
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
|
||||
|
||||
@@ -1763,9 +1773,10 @@ other schemas under `api/v1/schemas/`.
|
||||
| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` |
|
||||
| `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence |
|
||||
| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found |
|
||||
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart |
|
||||
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart |
|
||||
| `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered |
|
||||
| `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on |
|
||||
| `board` | `platform`, `board`, `applied`, `message` | Before `choices`, with no `--target` and no `--every-board`, when the system names its board or a Batocera card mounted here does (`boot/batocera.board` on its BATOCERA partition): `applied` is true when the run keeps that board's files only, false for a board the platform's list does not name, which keeps every board's files. `message` says it |
|
||||
| `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of |
|
||||
| `set_aside` | `platform`, `dests` | Right after `choices`: the files of the list the run sets aside before any choice, in list order, those read from a save directory whose place is not known among them. A screen chooses its cores among the rest, the cores `--core` accepts |
|
||||
| `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` |
|
||||
|
||||
Reference in new issue
Block a user