mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 04:39:06 -05:00
fix: guide installer users with their own command
Names are matched on letters and digits, so the site's display names work, and a typo gets the closest names instead of a full list. Hints and advice give back the command the user ran (curl or PowerShell form, every flag kept), --dest alone follows detection and says whose list it installs, Ctrl+C at a question exits 130, and --cores-dir names a core folder. install.ps1 runs in its own scope so irm | iex leaves the session alone, and an override that causes a fingerprint mismatch is named. --check lists hash mismatches first, missing required files are grouped by system, and --help ends with examples.
This commit is contained in:
1 parent
7095aa61e7
commit
82acd55ebc
7 files changed
+1045
-240
No files matched your search
+109
-86
@@ -1,96 +1,119 @@
|
||||
# One-line bootstrap and local wrapper. The downloaded installer is accepted
|
||||
# only when it matches the SHA-256 embedded in this wrapper.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$InstallerArguments
|
||||
)
|
||||
# Everything runs in a script block of its own: piped into iex, the script
|
||||
# would otherwise leave its variables and $ErrorActionPreference = "Stop"
|
||||
# in the user's session, where every later error would stop their commands.
|
||||
& {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$InstallerArguments
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "22737fbb470a63a8d3630f52ffee1535e57f96a2f1fea969e58368e06ca35f06"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
$temporary = $null
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "376edf794195d74f21e598854b5f3e90fb1955513835264bc9326ad4d2acf9c9"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
$temporary = $null
|
||||
|
||||
try {
|
||||
if (-not $installer -or -not (Test-Path -LiteralPath $installer -PathType Leaf)) {
|
||||
$url = if ($env:RETROBIOS_INSTALL_URL) { $env:RETROBIOS_INSTALL_URL } else { $defaultInstallUrl }
|
||||
$expected = if ($env:RETROBIOS_INSTALL_SHA256) { $env:RETROBIOS_INSTALL_SHA256 } else { $defaultInstallSha256 }
|
||||
$uri = [Uri]$url
|
||||
if ($uri.Scheme -ne "https") {
|
||||
throw "RETROBIOS_INSTALL_URL must use HTTPS."
|
||||
try {
|
||||
if (-not $installer -or -not (Test-Path -LiteralPath $installer -PathType Leaf)) {
|
||||
$url = if ($env:RETROBIOS_INSTALL_URL) { $env:RETROBIOS_INSTALL_URL } else { $defaultInstallUrl }
|
||||
$expected = if ($env:RETROBIOS_INSTALL_SHA256) { $env:RETROBIOS_INSTALL_SHA256 } else { $defaultInstallSha256 }
|
||||
$uri = [Uri]$url
|
||||
if ($uri.Scheme -ne "https") {
|
||||
throw "RETROBIOS_INSTALL_URL must use HTTPS."
|
||||
}
|
||||
if ($expected -notmatch '^[0-9a-fA-F]{64}$') {
|
||||
throw "Installer SHA-256 must contain exactly 64 hexadecimal characters."
|
||||
}
|
||||
# Windows PowerShell 5.1 still negotiates SSL 3.0 / TLS 1.0 by default
|
||||
# and GitHub refuses both, so the download fails before any hash is
|
||||
# checked. install.sh pins the same floor with curl --tlsv1.2.
|
||||
if ($PSVersionTable.PSEdition -ne "Core") {
|
||||
[Net.ServicePointManager]::SecurityProtocol = `
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
$temporary = Join-Path ([IO.Path]::GetTempPath()) ("retrobios-install-{0}.py" -f [Guid]::NewGuid())
|
||||
try {
|
||||
Invoke-WebRequest -Uri $uri -OutFile $temporary -UseBasicParsing
|
||||
} catch {
|
||||
# The raw WebException read as a crash and named no next step.
|
||||
throw ("The installer could not be downloaded from {0}: {1} " +
|
||||
"Check the network connection and any proxy, then run the same command again. " +
|
||||
"Nothing was run and nothing was written.") -f $url, $_.Exception.Message
|
||||
}
|
||||
if ((Get-Item -LiteralPath $temporary).Length -gt $maximumInstallerBytes) {
|
||||
throw "Downloaded installer exceeds the size limit."
|
||||
}
|
||||
$actual = (Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected.ToLowerInvariant()) {
|
||||
$mismatch = "The downloaded installer does not match its expected fingerprint. Nothing was run and nothing was written. "
|
||||
$overrides = @("RETROBIOS_INSTALL_URL", "RETROBIOS_INSTALL_SHA256") |
|
||||
Where-Object { [Environment]::GetEnvironmentVariable($_) }
|
||||
if ($overrides) {
|
||||
# A pin or a URL of the user's own refuses the installer:
|
||||
# retrying, as the advice below says, would never help.
|
||||
throw ($mismatch + "{0} is set in this session and decides which installer is accepted: " +
|
||||
"remove it to use the installer this script names, or set it to match.") -f ($overrides -join " and ")
|
||||
}
|
||||
throw ($mismatch + "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.")
|
||||
}
|
||||
$installer = $temporary
|
||||
}
|
||||
if ($expected -notmatch '^[0-9a-fA-F]{64}$') {
|
||||
throw "Installer SHA-256 must contain exactly 64 hexadecimal characters."
|
||||
}
|
||||
# Windows PowerShell 5.1 still negotiates SSL 3.0 / TLS 1.0 by default
|
||||
# and GitHub refuses both, so the download fails before any hash is
|
||||
# checked. install.sh pins the same floor with curl --tlsv1.2.
|
||||
if ($PSVersionTable.PSEdition -ne "Core") {
|
||||
[Net.ServicePointManager]::SecurityProtocol = `
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
$temporary = Join-Path ([IO.Path]::GetTempPath()) ("retrobios-install-{0}.py" -f [Guid]::NewGuid())
|
||||
try {
|
||||
Invoke-WebRequest -Uri $uri -OutFile $temporary -UseBasicParsing
|
||||
} catch {
|
||||
# The raw WebException read as a crash and named no next step.
|
||||
throw ("The installer could not be downloaded from {0}: {1} " +
|
||||
"Check the network connection and any proxy, then run the same command again. " +
|
||||
"Nothing was run and nothing was written.") -f $url, $_.Exception.Message
|
||||
}
|
||||
if ((Get-Item -LiteralPath $temporary).Length -gt $maximumInstallerBytes) {
|
||||
throw "Downloaded installer exceeds the size limit."
|
||||
}
|
||||
$actual = (Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected.ToLowerInvariant()) {
|
||||
throw "The downloaded installer does not match its expected fingerprint. Nothing was run and nothing was written. 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."
|
||||
}
|
||||
$installer = $temporary
|
||||
}
|
||||
|
||||
$launcher = @()
|
||||
$python = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($python) {
|
||||
$launcher = @("-3")
|
||||
} else {
|
||||
$python = Get-Command python3 -ErrorAction SilentlyContinue
|
||||
$launcher = @()
|
||||
$python = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($python) {
|
||||
$launcher = @("-3")
|
||||
} else {
|
||||
$python = Get-Command python3 -ErrorAction SilentlyContinue
|
||||
if (-not $python) {
|
||||
$python = Get-Command python -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
$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."
|
||||
if (-not $python) {
|
||||
$python = Get-Command python -ErrorAction SilentlyContinue
|
||||
throw $needPython
|
||||
}
|
||||
& $python.Source @launcher -c "import sys; raise SystemExit(sys.version_info < (3, 8))"
|
||||
if ($LASTEXITCODE -ne 0) { throw $needPython }
|
||||
# The installer rebuilds this command in its hints, and the user has
|
||||
# no install.py of their own to run.
|
||||
$previousBootstrap = $env:RETROBIOS_BOOTSTRAP
|
||||
$env:RETROBIOS_BOOTSTRAP = if ($temporary) { "ps1" } else { $null }
|
||||
try {
|
||||
& $python.Source @launcher $installer @InstallerArguments
|
||||
} finally {
|
||||
$env:RETROBIOS_BOOTSTRAP = $previousBootstrap
|
||||
}
|
||||
# The installer has already said what happened: its status is passed on
|
||||
# as it is (1 for files left to fetch under --check, 2 when nothing was
|
||||
# installed, 130 on Ctrl+C), not turned into a red exception that reads
|
||||
# as a crash and flattens every code to one.
|
||||
$installerExit = $LASTEXITCODE
|
||||
}
|
||||
catch {
|
||||
# One line saying what failed and what to do, rather than the red
|
||||
# exception block PowerShell prints for an uncaught throw.
|
||||
[Console]::Error.WriteLine("Error: " + $_.Exception.Message)
|
||||
$installerExit = 1
|
||||
}
|
||||
finally {
|
||||
if ($temporary -and (Test-Path -LiteralPath $temporary)) {
|
||||
Remove-Item -LiteralPath $temporary -Force
|
||||
}
|
||||
}
|
||||
$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."
|
||||
if (-not $python) {
|
||||
throw $needPython
|
||||
}
|
||||
& $python.Source @launcher -c "import sys; raise SystemExit(sys.version_info < (3, 8))"
|
||||
if ($LASTEXITCODE -ne 0) { throw $needPython }
|
||||
& $python.Source @launcher $installer @InstallerArguments
|
||||
# The installer has already said what happened: its status is passed on
|
||||
# as it is (1 for files left to fetch under --check, 2 when nothing was
|
||||
# installed, 130 on Ctrl+C), not turned into a red exception that reads
|
||||
# as a crash and flattens every code to one.
|
||||
$installerExit = $LASTEXITCODE
|
||||
}
|
||||
catch {
|
||||
# One line saying what failed and what to do, rather than the red
|
||||
# exception block PowerShell prints for an uncaught throw.
|
||||
[Console]::Error.WriteLine("Error: " + $_.Exception.Message)
|
||||
$installerExit = 1
|
||||
}
|
||||
finally {
|
||||
if ($temporary -and (Test-Path -LiteralPath $temporary)) {
|
||||
Remove-Item -LiteralPath $temporary -Force
|
||||
}
|
||||
}
|
||||
|
||||
if ($installerExit) {
|
||||
# Run from a file, the script ends with that status. Piped into iex it
|
||||
# runs in the user's own session, which exit would close.
|
||||
if ($PSScriptRoot) { exit $installerExit }
|
||||
$global:LASTEXITCODE = $installerExit
|
||||
}
|
||||
if ($installerExit) {
|
||||
# Run from a file, the script ends with that status. Piped into iex it
|
||||
# runs in the user's own session, which exit would close.
|
||||
if ($PSScriptRoot) { exit $installerExit }
|
||||
$global:LASTEXITCODE = $installerExit
|
||||
}
|
||||
} @args
|
||||
+459
-125
@@ -24,6 +24,7 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -156,6 +157,22 @@ AVAILABLE_PLATFORMS = (
|
||||
"retropie",
|
||||
)
|
||||
|
||||
# The names the frontends give themselves, as each manifest's display_name
|
||||
# spells them (a test holds the two equal). Detection and the platform menu
|
||||
# come before any manifest is fetched, and str.capitalize() wrote Retroarch,
|
||||
# Retrodeck and Misterfpga.
|
||||
PLATFORM_NAMES = {
|
||||
"retroarch": "RetroArch", "batocera": "Batocera", "recalbox": "Recalbox",
|
||||
"retrobat": "RetroBat", "emudeck": "EmuDeck", "lakka": "Lakka",
|
||||
"retrodeck": "RetroDECK", "rocknix": "ROCKNIX", "romm": "RomM",
|
||||
"bizhawk": "BizHawk", "misterfpga": "MiSTer FPGA", "retropie": "RetroPie",
|
||||
}
|
||||
|
||||
SITE_URL = "https://abdess.github.io/retrobios/"
|
||||
INSTALLER_PAGE = f"{SITE_URL}wiki/installer/"
|
||||
TROUBLESHOOTING_PAGE = f"{SITE_URL}wiki/troubleshooting/"
|
||||
BOOTSTRAP_URL = "https://raw.githubusercontent.com/Abdess/retrobios/main/install"
|
||||
|
||||
# Fallback BIOS destination per platform when --platform is forced
|
||||
# but auto-detection finds nothing on the machine.
|
||||
DEFAULT_DESTS = {
|
||||
@@ -1374,15 +1391,64 @@ def installed_cores(dirs: list[Path]) -> list[str]:
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def display_name(plat: str, manifest: dict | None = None) -> str:
|
||||
"""A platform's name as the frontend writes it, the manifest's when it has one."""
|
||||
named = (manifest or {}).get("display_name")
|
||||
if isinstance(named, str) and named.strip().isprintable() and 0 < len(named.strip()) <= 40:
|
||||
return named.strip()
|
||||
return PLATFORM_NAMES.get(plat, plat)
|
||||
|
||||
|
||||
def _name_key(name: str) -> str:
|
||||
"""Letters and digits only: MiSTer FPGA, misterfpga and Mister-FPGA are one name."""
|
||||
return re.sub(r"[^a-z0-9]", "", name.lower())
|
||||
|
||||
|
||||
# A list this short is printed whole rather than pointed at.
|
||||
SHORT_LIST = 12
|
||||
|
||||
|
||||
def did_you_mean(typed: list[str], known: list[str], listing: str) -> str:
|
||||
"""The closest known names to what was typed, and the option listing them all.
|
||||
|
||||
A typo was answered with every name the platform has, up to 400 on one
|
||||
line, with no hint that playstation is sony-playstation.
|
||||
"""
|
||||
import difflib # noqa: PLC0415
|
||||
|
||||
by_key: dict[str, str] = {}
|
||||
for name in known:
|
||||
by_key.setdefault(_name_key(name), name)
|
||||
close: list[str] = []
|
||||
for word in typed:
|
||||
key = _name_key(word)
|
||||
if not key:
|
||||
continue
|
||||
matches = difflib.get_close_matches(key, list(by_key), n=3, cutoff=0.6)
|
||||
if len(key) >= 4:
|
||||
matches += sorted(k for k in by_key if key in k and k not in matches)
|
||||
close += [by_key[k] for k in matches if by_key[k] not in close]
|
||||
if close:
|
||||
return f"Did you mean: {', '.join(close[:3])}? {listing} lists all {len(known)}."
|
||||
if len(known) <= SHORT_LIST:
|
||||
return f"Known: {', '.join(known)}."
|
||||
return f"{listing} lists all {len(known)}."
|
||||
|
||||
|
||||
def normalize_platform(name: str) -> str:
|
||||
"""Return the canonical platform id for user-supplied input."""
|
||||
plat = name.strip().lower()
|
||||
if plat not in AVAILABLE_PLATFORMS:
|
||||
print(f"Unknown platform '{name}'. Available platforms:", file=sys.stderr)
|
||||
for p in AVAILABLE_PLATFORMS:
|
||||
print(f" {p}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return plat
|
||||
"""Return the canonical platform id for user-supplied input.
|
||||
|
||||
The site writes MiSTer FPGA and RetroArch: letters and digits decide,
|
||||
as they do for verify.py and generate_pack.py.
|
||||
"""
|
||||
key = _name_key(name)
|
||||
for plat in AVAILABLE_PLATFORMS:
|
||||
if key and key in (_name_key(plat), _name_key(display_name(plat))):
|
||||
return plat
|
||||
print(f"Unknown platform '{name.strip()}'. "
|
||||
+ did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"),
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _read_limited_json(
|
||||
@@ -1862,7 +1928,21 @@ def _validate_targets(data: object) -> dict[str, dict]:
|
||||
|
||||
|
||||
NETWORK_ADVICE = (" Check the connection, and HTTPS_PROXY if this network needs a "
|
||||
"proxy, then run the same command again.")
|
||||
"proxy, then run the same command again.\n"
|
||||
f" More help: {TROUBLESHOOTING_PAGE}")
|
||||
|
||||
|
||||
def _address_lines(url: str) -> list[str]:
|
||||
"""The address that failed, and the override that chose it if one did.
|
||||
|
||||
A RETROBIOS_BASE_URL left from a mirror that is down read as a network
|
||||
fault: neither the host nor the variable was named.
|
||||
"""
|
||||
lines = [f" Address: {url}"]
|
||||
if os.environ.get("RETROBIOS_BASE_URL"):
|
||||
lines.append(" RETROBIOS_BASE_URL is set in this environment and chose that "
|
||||
"address: unset it to read from the project itself.")
|
||||
return lines
|
||||
|
||||
|
||||
def _fetch_json(url: str, limit: int, label: str) -> object:
|
||||
@@ -1904,6 +1984,11 @@ def fetch_manifest(plat: str) -> dict:
|
||||
print(f" The file list for {plat} is not valid: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404 and os.environ.get("RETROBIOS_BASE_URL"):
|
||||
print(f" No file list for {plat} there.", file=sys.stderr)
|
||||
for line in _address_lines(url):
|
||||
print(line, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if exc.code == 404:
|
||||
# Asked once, a 4xx being final. The name was checked before any
|
||||
# request, so the revision is what has no such list.
|
||||
@@ -1917,11 +2002,15 @@ def fetch_manifest(plat: str) -> dict:
|
||||
"again in a few minutes.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f" Could not fetch the file list for {plat}: HTTP {exc.code}", file=sys.stderr)
|
||||
for line in _address_lines(url):
|
||||
print(line, file=sys.stderr)
|
||||
print(NETWORK_ADVICE, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f" Could not fetch the file list for {plat} after {MAX_RETRIES} "
|
||||
f"attempts: {getattr(exc, 'reason', None) or exc}", file=sys.stderr)
|
||||
for line in _address_lines(url):
|
||||
print(line, file=sys.stderr)
|
||||
print(NETWORK_ADVICE, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1940,6 +2029,8 @@ def fetch_targets(plat: str) -> "dict | None":
|
||||
if exc.code == 404:
|
||||
return {}
|
||||
print(f" Error: failed to fetch targets for {plat}: {exc}", file=sys.stderr)
|
||||
for line in _address_lines(url):
|
||||
print(line, file=sys.stderr)
|
||||
print(NETWORK_ADVICE, file=sys.stderr)
|
||||
return None
|
||||
except ValueError as exc:
|
||||
@@ -1948,6 +2039,8 @@ def fetch_targets(plat: str) -> "dict | None":
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f" Error: failed to fetch targets for {plat}: "
|
||||
f"{getattr(exc, 'reason', None) or exc}", file=sys.stderr)
|
||||
for line in _address_lines(url):
|
||||
print(line, file=sys.stderr)
|
||||
print(NETWORK_ADVICE, file=sys.stderr)
|
||||
return None
|
||||
|
||||
@@ -2270,6 +2363,12 @@ def resolve_choices(values: list[str], known: list[str], label: str) -> list[str
|
||||
carrying on would install something other than what was asked.
|
||||
"""
|
||||
by_lower = {name.lower(): name for name in known}
|
||||
# Spelled as the site writes it (Sony PlayStation), a name is still one
|
||||
# name when no other one reads the same once case and spacing go.
|
||||
by_key: dict[str, str | None] = {}
|
||||
for name in known:
|
||||
key = _name_key(name)
|
||||
by_key[key] = None if key in by_key else name
|
||||
chosen: list[str] = []
|
||||
unknown: list[str] = []
|
||||
for value in values:
|
||||
@@ -2278,15 +2377,17 @@ def resolve_choices(values: list[str], known: list[str], label: str) -> list[str
|
||||
for token in (t.strip() for t in tokens):
|
||||
if not token:
|
||||
continue
|
||||
name = by_lower.get(token.lower())
|
||||
name = by_lower.get(token.lower()) or by_key.get(_name_key(token))
|
||||
if name is None:
|
||||
unknown.append(token)
|
||||
elif name not in chosen:
|
||||
chosen.append(name)
|
||||
if unknown:
|
||||
available = ", ".join(known) if known else "none"
|
||||
if not known:
|
||||
raise ValueError(f"unknown {label}: {', '.join(unknown)}. This list records none.")
|
||||
listing = "--list-systems" if label == "system" else "--list-cores"
|
||||
raise ValueError(
|
||||
f"unknown {label}: {', '.join(unknown)} (available: {available})"
|
||||
f"unknown {label}: {', '.join(unknown)}. {did_you_mean(unknown, known, listing)}"
|
||||
)
|
||||
if values and not chosen:
|
||||
raise ValueError(f"--{label} needs at least one name")
|
||||
@@ -2739,24 +2840,120 @@ def _folder_writable(path: Path) -> bool:
|
||||
return os.access(probe, os.W_OK)
|
||||
|
||||
|
||||
# Options that take a value, as a hint gives them back.
|
||||
_VALUED_OPTIONS = ("--system", "--core", "--emulator", "--region", "--target", "--cores-dir")
|
||||
|
||||
|
||||
class Invocation:
|
||||
"""How this run was started, so that a hint gives the command back whole.
|
||||
|
||||
A one-liner user has no install.py to run: install.sh and install.ps1 run
|
||||
a temporary copy and say so in RETROBIOS_BOOTSTRAP. And a hint that
|
||||
dropped --system turned a retry of 2 files into one of 8069.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.os_type = ""
|
||||
self.options: list[tuple[str, str | None]] = []
|
||||
# Per platform, what was chosen at the questions.
|
||||
self.chosen: dict[str, list[tuple[str, str | None]]] = {}
|
||||
|
||||
def record(self, args: argparse.Namespace) -> None:
|
||||
"""Keep every option that decides what a run installs or reports."""
|
||||
options: list[tuple[str, str | None]] = []
|
||||
for flag in _VALUED_OPTIONS:
|
||||
value = getattr(args, flag[2:].replace("-", "_"), None)
|
||||
values = value if isinstance(value, list) else [value] if value else []
|
||||
options += [(flag, str(v)) for v in values]
|
||||
for flag, on in (
|
||||
("--every-board", args.every_board),
|
||||
("--installed-cores", args.installed_cores and not args.cores_dir),
|
||||
("--standalone-copies", args.standalone_copies),
|
||||
("--check", args.check),
|
||||
("--dry-run", args.dry_run),
|
||||
):
|
||||
if on:
|
||||
options.append((flag, None))
|
||||
self.options = options
|
||||
|
||||
def powershell(self) -> bool:
|
||||
return os.environ.get("RETROBIOS_BOOTSTRAP") == "ps1" or self.os_type == "windows"
|
||||
|
||||
def launcher(self, elevated: bool = False) -> str:
|
||||
"""The command without its options, as the user typed it."""
|
||||
bootstrap = os.environ.get("RETROBIOS_BOOTSTRAP", "")
|
||||
if bootstrap == "ps1":
|
||||
return f"&([scriptblock]::Create((irm {BOOTSTRAP_URL}.ps1)))"
|
||||
if bootstrap == "sh":
|
||||
return f"curl -fsSL {BOOTSTRAP_URL}.sh | {'sudo ' if elevated else ''}sh -s --"
|
||||
python = "python" if self.os_type == "windows" else "python3"
|
||||
return f"{'sudo ' if elevated else ''}{python} install.py"
|
||||
|
||||
def word(self, value: str) -> str:
|
||||
if self.powershell():
|
||||
# A comma makes an array and @ a splat in PowerShell arguments.
|
||||
if re.fullmatch(r"[A-Za-z0-9_./:\\-]+", value):
|
||||
return value
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
return shlex.quote(value)
|
||||
|
||||
def command(
|
||||
self, plat: str, dest: Path | str | None, elevated: bool = False,
|
||||
without: tuple[str, ...] = (),
|
||||
) -> str:
|
||||
"""The full command for plat and dest, a str dest being a placeholder."""
|
||||
words = [self.launcher(elevated)]
|
||||
if plat:
|
||||
words += ["--platform", plat]
|
||||
if isinstance(dest, Path):
|
||||
words += ["--dest", self.word(str(dest.absolute()))]
|
||||
elif dest:
|
||||
words += ["--dest", dest]
|
||||
for flag, value in [*self.options, *self.chosen.get(plat, [])]:
|
||||
if flag in without:
|
||||
continue
|
||||
words += [flag] if value is None else [flag, self.word(value)]
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
_INVOCATION = Invocation()
|
||||
|
||||
|
||||
def _write_advice(path: Path, assumed: bool, plat: str = "") -> str:
|
||||
"""What to do about a folder this run could not write.
|
||||
|
||||
sudo fixes a root-owned BIOS folder on the console itself. On another
|
||||
machine it would create that folder on the wrong disk: a platform named
|
||||
but not found here is a card or share mounted elsewhere, named by --dest.
|
||||
Windows has no sudo, and Termux reaches shared storage once
|
||||
termux-setup-storage has run.
|
||||
"""
|
||||
if assumed:
|
||||
return (f" {path} is where the platform keeps its BIOS on its own system, "
|
||||
"and it was not found here. If this machine prepares its card or "
|
||||
"share, run the same command with --dest pointing at that folder.")
|
||||
"share, run it again with --dest pointing at that folder:\n"
|
||||
f" {_INVOCATION.command(plat, '<that folder>')}")
|
||||
if plat and _INVOCATION.os_type == "windows":
|
||||
return (" This user cannot write there. Run it again from a PowerShell window "
|
||||
"opened as administrator:\n"
|
||||
f" {_INVOCATION.command(plat, path)}\n"
|
||||
" Or choose another folder with --dest.")
|
||||
if plat and _INVOCATION.os_type == "android":
|
||||
return (" This user cannot write there. Run termux-setup-storage once to give "
|
||||
"Termux the shared storage, then run it again:\n"
|
||||
f" {_INVOCATION.command(plat, path)}\n"
|
||||
" Or choose another folder with --dest.")
|
||||
if plat:
|
||||
# sudo resets HOME to root's on Debian, Ubuntu, Arch and SteamOS:
|
||||
# the same command detected nothing there, fell back to a folder
|
||||
# under /root and reported success.
|
||||
return (" This user cannot write there. Run it again with sudo, naming the "
|
||||
f"folder so root does not look in its own home: --platform {plat} "
|
||||
f"--dest '{path.absolute()}'. Or choose another folder with --dest.")
|
||||
"folder so root does not look in its own home:\n"
|
||||
f" {_INVOCATION.command(plat, path, elevated=True)}\n"
|
||||
" Or choose another folder with --dest.")
|
||||
if _INVOCATION.os_type == "windows":
|
||||
return (" This user cannot write there: run the same command from a PowerShell "
|
||||
"window opened as administrator, or choose another folder with --dest.")
|
||||
return (" This user cannot write there: run the same command with sudo, "
|
||||
"or choose another folder with --dest.")
|
||||
|
||||
@@ -2866,17 +3063,14 @@ def _omission_lines(count: int, entries: list[dict]) -> list[str]:
|
||||
lines = [f"{_plural(count, 'file')} {'was' if one else 'were'} not installed: "
|
||||
f"the collection does not hold {'it' if one else 'them'}, or its copy "
|
||||
f"fails {'its' if one else 'their'} check."]
|
||||
required = sorted({e["name"] for e in entries if e.get("required")})
|
||||
if required:
|
||||
shown = ", ".join(required[:10])
|
||||
more = f" and {len(required) - 10} more" if len(required) > 10 else ""
|
||||
lines.append(f" Needed to start an emulator: {shown}{more}.")
|
||||
lines += _needed_lines([e for e in entries if e.get("required")],
|
||||
"Needed to start an emulator")
|
||||
return lines
|
||||
|
||||
|
||||
def _absent_required(omitted: list[dict], bios_path: Path) -> list[str]:
|
||||
"""Required files the manifest leaves out that the user has not placed."""
|
||||
names = set()
|
||||
def _absent_required(omitted: list[dict], bios_path: Path) -> list[dict]:
|
||||
"""Required entries the manifest leaves out that the user has not placed."""
|
||||
absent = []
|
||||
for entry in omitted:
|
||||
dest = entry.get("dest") or entry.get("name", "")
|
||||
if not entry.get("required", True) or not dest:
|
||||
@@ -2886,8 +3080,35 @@ def _absent_required(omitted: list[dict], bios_path: Path) -> list[str]:
|
||||
except ValueError:
|
||||
placed = False # a path climbing out of the folder is never one we read
|
||||
if not placed:
|
||||
names.add(dest)
|
||||
return sorted(names)
|
||||
absent.append(entry)
|
||||
return absent
|
||||
|
||||
|
||||
def _needed_lines(entries: list[dict], heading: str, every: bool = False) -> list[str]:
|
||||
"""Required files the collection lacks, by system with the emulators reading them.
|
||||
|
||||
A bare list (DARK.GOB, Data.rsdk, ... and 69 more) left a Batocera user
|
||||
unable to tell whether their PlayStation was concerned.
|
||||
"""
|
||||
by_system: dict[str, tuple[set[str], set[str]]] = {}
|
||||
for entry in entries:
|
||||
system = str(entry.get("system") or ", ".join(entry_systems(entry)) or OTHER_SYSTEMS)
|
||||
names, cores = by_system.setdefault(system, (set(), set()))
|
||||
names.add(str(entry.get("dest") or entry.get("name")))
|
||||
cores.update(str(core) for core in entry.get("cores") or [])
|
||||
if not by_system:
|
||||
return []
|
||||
count = sum(len(names) for names, _cores in by_system.values())
|
||||
lines = [f" {heading} ({_plural(count, 'file')}):"]
|
||||
systems = sorted(by_system)
|
||||
for system in systems if every else systems[:8]:
|
||||
names, cores = by_system[system]
|
||||
readers = f" ({', '.join(sorted(cores))})" if cores else ""
|
||||
lines.append(f" {system}{readers}: {', '.join(sorted(names))}")
|
||||
if not every and len(systems) > 8:
|
||||
lines.append(f" ... and {len(systems) - 8} more systems (--check --verbose lists them all)")
|
||||
lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/")
|
||||
return lines
|
||||
|
||||
|
||||
# Asked before, in an interactive run: past this size a download is worth a
|
||||
@@ -2913,14 +3134,30 @@ def _confirm(prompt: str) -> bool:
|
||||
return not (answer.startswith("n") or answer in _REFUSALS)
|
||||
|
||||
|
||||
def _print_plan(to_download: list[dict], bios_path: Path, term: Terminal) -> None:
|
||||
"""What a real run would fetch, by system, and nothing written."""
|
||||
def _print_plan(
|
||||
to_download: list[dict], bios_path: Path, term: Terminal,
|
||||
trees: list[tuple[dict, dict[str, dict]]] = (),
|
||||
) -> None:
|
||||
"""What a real run would fetch, by system, and nothing written.
|
||||
|
||||
The data trees count in the total and come before the closing line:
|
||||
printed after it, they left 2636 RetroArch files out of the figure a
|
||||
user plans an SD card from.
|
||||
"""
|
||||
total = sum(f.get("size", 0) for f in to_download)
|
||||
if not to_download:
|
||||
trees = [(entry, needed) for entry, needed in trees if needed]
|
||||
tree_files = sum(len(needed) for _entry, needed in trees)
|
||||
tree_size = sum(meta.get("size", 0) for _entry, needed in trees for meta in needed.values())
|
||||
if not to_download and not trees:
|
||||
print("\n All files up to date. A real run would download nothing.")
|
||||
return
|
||||
print(f"\nWould download {_plural(len(to_download), 'file')} "
|
||||
f"({format_size(total)}) to {bios_path}:")
|
||||
parts = []
|
||||
if to_download:
|
||||
parts.append(f"{_plural(len(to_download), 'file')} ({format_size(total)})")
|
||||
if trees:
|
||||
parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_size)}, "
|
||||
f"extracted from {_plural(len(trees), 'archive')})")
|
||||
print(f"\nWould download {' and '.join(parts)} to {bios_path}:")
|
||||
by_system: dict[str, list[dict]] = {}
|
||||
for entry in to_download:
|
||||
system = ", ".join(entry_systems(entry)) or "other"
|
||||
@@ -2931,10 +3168,13 @@ def _print_plan(to_download: list[dict], bios_path: Path, term: Terminal) -> Non
|
||||
print(f" {entry['dest']} {term.dim(format_size(entry.get('size', 0)))}")
|
||||
if len(by_system[system]) > 8:
|
||||
print(f" ... and {len(by_system[system]) - 8} more")
|
||||
for entry, needed in trees:
|
||||
print(f" {term.bold(entry['ref'] + ' data')} {_sized(list(needed.values()))}, "
|
||||
f"from {entry['url']}")
|
||||
print("\n Nothing was written. Run without --dry-run to install.")
|
||||
|
||||
|
||||
def _check_free_space(bios_path: Path, needed: int) -> None:
|
||||
def _check_free_space(bios_path: Path, needed: int, plat: str = "") -> None:
|
||||
"""Stop before writing when the volume cannot hold the download.
|
||||
|
||||
Reported against the nearest existing ancestor, since the destination
|
||||
@@ -2952,8 +3192,15 @@ def _check_free_space(bios_path: Path, needed: int) -> None:
|
||||
print(f"\nNot enough space on the drive holding {bios_path}.", file=sys.stderr)
|
||||
print(f" Needed: {format_size(needed)}. Free: {format_size(free)}.",
|
||||
file=sys.stderr)
|
||||
print(f" Free up {format_size(needed - free)}, or use --dest to install "
|
||||
"to another drive.", file=sys.stderr)
|
||||
if not plat:
|
||||
print(f" Free up {format_size(needed - free)}, or use --dest to install "
|
||||
"to another drive.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# --dest alone used to mean RetroArch's list: the platform is named, so
|
||||
# a Batocera install moved to a USB drive stays Batocera's.
|
||||
print(f" Free up {format_size(needed - free)}, or install to another drive:\n"
|
||||
f" {_INVOCATION.command(plat, '<folder on another drive>')}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -3280,40 +3527,40 @@ def format_size(n: int) -> str:
|
||||
return f"{n / (1024 * 1024 * 1024):.1f} GB"
|
||||
|
||||
|
||||
def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]:
|
||||
"""Ask for a platform and a BIOS directory when nothing was detected."""
|
||||
print("\nChoose the platform to install for:")
|
||||
def _choose_platform(title: str, default: str = "") -> str:
|
||||
"""One platform from the numbered list, Enter taking the default."""
|
||||
print(f"\n{title}")
|
||||
for i, name in enumerate(AVAILABLE_PLATFORMS, 1):
|
||||
print(f" {i}) {name}")
|
||||
print(f" {i}) {display_name(name)}")
|
||||
print(" q) quit")
|
||||
print()
|
||||
plat = ""
|
||||
while not plat:
|
||||
try:
|
||||
choice = input("> ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
sys.exit(0)
|
||||
if choice == "q":
|
||||
prompt = f"> [Enter for {display_name(default)}] " if default else "> "
|
||||
while True:
|
||||
choice = _ask(prompt)
|
||||
if not choice and default:
|
||||
return default
|
||||
if choice.lower() == "q":
|
||||
sys.exit(0)
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(AVAILABLE_PLATFORMS):
|
||||
plat = AVAILABLE_PLATFORMS[int(choice) - 1]
|
||||
elif choice in AVAILABLE_PLATFORMS:
|
||||
plat = choice
|
||||
else:
|
||||
print(f" Type a number from 1 to {len(AVAILABLE_PLATFORMS)}, a platform name, "
|
||||
"or q to quit.")
|
||||
return AVAILABLE_PLATFORMS[int(choice) - 1]
|
||||
key = _name_key(choice)
|
||||
for plat in AVAILABLE_PLATFORMS:
|
||||
if key and key in (_name_key(plat), _name_key(display_name(plat))):
|
||||
return plat
|
||||
print(f" Type a number from 1 to {len(AVAILABLE_PLATFORMS)}, a platform name, "
|
||||
"or q to quit.")
|
||||
|
||||
|
||||
def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]:
|
||||
"""Ask for a platform and a BIOS directory when nothing was detected."""
|
||||
plat = _choose_platform("Choose the platform to install for:")
|
||||
default_dest = _default_dest(os_type, plat)
|
||||
raw = ""
|
||||
while not raw:
|
||||
try:
|
||||
raw = input(
|
||||
f"BIOS directory [{default_dest}]: " if default_dest
|
||||
else "BIOS directory (no default on this system): "
|
||||
).strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
sys.exit(0)
|
||||
raw = _ask(
|
||||
f"BIOS directory [{default_dest}]: " if default_dest
|
||||
else "BIOS directory (no default on this system): "
|
||||
)
|
||||
if default_dest is not None:
|
||||
break
|
||||
dest = Path(raw).expanduser() if raw else default_dest
|
||||
@@ -3355,23 +3602,21 @@ def _default_dest(os_type: str, platform_name: str) -> Path | None:
|
||||
|
||||
|
||||
def _manual_usage_hint(os_type: str) -> list[str]:
|
||||
"""How to name the platform and directory when nothing is detected."""
|
||||
if os_type == "windows":
|
||||
return [
|
||||
" Use --platform and --dest to specify manually, for example:",
|
||||
" & ([ScriptBlock]::Create((iwr -useb "
|
||||
"https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1))) "
|
||||
"--platform retroarch --dest 'C:\\RetroArch\\system'",
|
||||
]
|
||||
if os_type == "android":
|
||||
return [
|
||||
" Use --platform and --dest to specify manually, for example:",
|
||||
" python3 install.py --platform retroarch "
|
||||
"--dest /storage/emulated/0/RetroArch/system",
|
||||
]
|
||||
"""How to name the platform and directory when nothing is detected.
|
||||
|
||||
In the form the run was started with: a one-liner user has no
|
||||
install.py, and `python3 install.py` failed with "can't open file".
|
||||
"""
|
||||
example = {
|
||||
"windows": "C:\\RetroArch-Win64\\system",
|
||||
"android": "/storage/emulated/0/RetroArch/system",
|
||||
}.get(os_type, "~/.config/retroarch/system")
|
||||
hint = Invocation()
|
||||
hint.os_type, hint.options = os_type, _INVOCATION.options
|
||||
return [
|
||||
" Use --platform and --dest to specify manually, for example:",
|
||||
" python3 install.py --platform retroarch --dest ~/.config/retroarch/system",
|
||||
f" {hint.command('retroarch', example)}",
|
||||
f" Where each platform keeps its BIOS: {INSTALLER_PAGE}",
|
||||
]
|
||||
|
||||
|
||||
@@ -3381,7 +3626,7 @@ def _prompt_platform_choice(
|
||||
"""Prompt user to choose among detected platforms."""
|
||||
print("\nInstall for:")
|
||||
for i, (name, path) in enumerate(platforms, 1):
|
||||
print(f" {i}) {name.capitalize()} ({path})")
|
||||
print(f" {i}) {display_name(name)} ({path})")
|
||||
last = len(platforms) + (1 if len(platforms) > 1 else 0)
|
||||
if len(platforms) > 1:
|
||||
print(f" {last}) All")
|
||||
@@ -3403,11 +3648,16 @@ def _prompt_platform_choice(
|
||||
|
||||
|
||||
def _ask(prompt: str) -> str:
|
||||
"""An answer, or a stop: Ctrl+D at a question is Ctrl+C.
|
||||
|
||||
Both used to leave with 0, so `install.sh && retroarch` went on with no
|
||||
BIOS. They now end where Ctrl+C ends everywhere else, on 130.
|
||||
"""
|
||||
try:
|
||||
return input(prompt).strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
except EOFError:
|
||||
print()
|
||||
sys.exit(0)
|
||||
raise KeyboardInterrupt from None
|
||||
|
||||
|
||||
def _sized(files: list[dict]) -> str:
|
||||
@@ -3445,6 +3695,7 @@ INSTALLED_ONLY = "installed"
|
||||
def _prompt_custom_selection(
|
||||
plat: str, files: list[dict], omitted: list[dict],
|
||||
installed: tuple[list[dict], list[dict]] | None = None,
|
||||
name: str = "",
|
||||
) -> tuple[list[str], list[str], list[str]] | str | None:
|
||||
"""Offer to narrow the install by system, core and region.
|
||||
|
||||
@@ -3453,7 +3704,7 @@ def _prompt_custom_selection(
|
||||
installed read) holds, or the three choices. Each empty list keeps
|
||||
everything on its axis.
|
||||
"""
|
||||
print(f"\n{plat.capitalize()}: {_sized(files)}.")
|
||||
print(f"\n{name or display_name(plat)}: {_sized(files)}.")
|
||||
print(" Enter) install everything")
|
||||
if installed is not None:
|
||||
print(f" i) only what the installed cores read: {_sized(installed[0])}")
|
||||
@@ -3623,11 +3874,33 @@ def _requested_narrowing(
|
||||
return systems, cores, regions
|
||||
|
||||
|
||||
def _help_epilog() -> str:
|
||||
"""Examples in the form the run was started with, and where the rest is."""
|
||||
run = _INVOCATION.launcher()
|
||||
card = "E:\\bios" if _INVOCATION.powershell() else "/media/SHARE/bios"
|
||||
examples = (
|
||||
("install for the platforms found here", run),
|
||||
("prepare a card or a share for another device",
|
||||
f"{run} --platform batocera --dest {card}"),
|
||||
("only some systems (names: --list-systems)",
|
||||
f"{run} --platform retroarch --system sony-playstation,nintendo-gba"),
|
||||
("check what is installed, writing nothing", f"{run} --check"),
|
||||
)
|
||||
lines = ["examples:"]
|
||||
for what, command in examples:
|
||||
lines += [f" {what}", f" {command}"]
|
||||
lines += ["", f"Every option, the environment variables and the exit codes: {INSTALLER_PAGE}"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point."""
|
||||
_INVOCATION.os_type = detect_os()
|
||||
parser = argparse.ArgumentParser(
|
||||
allow_abbrev=False,
|
||||
description="Download missing BIOS files for retrogaming emulators.",
|
||||
epilog=_help_epilog(),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
@@ -3636,7 +3909,9 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--dest",
|
||||
type=Path,
|
||||
help="override BIOS destination directory",
|
||||
help="BIOS folder to install into. Without --platform, the file list is "
|
||||
"the platform detected here, asked at a terminal, RetroArch's when "
|
||||
"nothing is detected",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
@@ -3692,6 +3967,13 @@ def main() -> None:
|
||||
help="install only what the cores already installed read "
|
||||
"(RetroArch, Lakka, RetroPie)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cores-dir",
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="the folder RetroArch loads its cores from, when it is not found "
|
||||
"(implies --installed-cores)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--system",
|
||||
action="append",
|
||||
@@ -3750,6 +4032,9 @@ def main() -> None:
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.cores_dir:
|
||||
args.installed_cores = True
|
||||
_INVOCATION.record(args)
|
||||
if not 1 <= args.jobs <= 32:
|
||||
parser.error("--jobs must be between 1 and 32")
|
||||
if args.region:
|
||||
@@ -3790,6 +4075,64 @@ def main() -> None:
|
||||
sys.stdout = real_stdout
|
||||
|
||||
|
||||
def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given: bool) -> None:
|
||||
"""Refuse --installed-cores with no core, and say how to point at them.
|
||||
|
||||
With --dest, detection does not run, and without a retroarch.cfg beside
|
||||
the folder nothing names the cores folder: the run stopped on "no known
|
||||
directory" with no way forward.
|
||||
"""
|
||||
where = (f"none in {', '.join(map(str, searched))}" if searched
|
||||
else f"no retroarch.cfg was found for {bios_path} to name their folder")
|
||||
print(f"Error: no installed core found for {display_name(plat)}: {where}.",
|
||||
file=sys.stderr)
|
||||
print(" Name the folder RetroArch loads cores from (libretro_directory in "
|
||||
"retroarch.cfg, cores/ beside RetroArch by default):", file=sys.stderr)
|
||||
command = _INVOCATION.command(plat, bios_path, without=("--installed-cores", "--cores-dir"))
|
||||
print(f" {command} --cores-dir <cores folder>", file=sys.stderr)
|
||||
if dest_given:
|
||||
print(" Or run it without --dest: detection reads retroarch.cfg, which names "
|
||||
"that folder.", file=sys.stderr)
|
||||
print(" Or run it without --installed-cores to install every file.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str:
|
||||
"""The file list for a --dest given without --platform.
|
||||
|
||||
A folder says where, not for whom, and a card is often prepared on a PC
|
||||
for a console: --dest alone installed RetroArch's names and layout into
|
||||
a Batocera share. The platforms detected here are the evidence. At a
|
||||
terminal the user picks, Enter taking the one found. Otherwise the one
|
||||
found is used, RetroArch when none is, and several found are refused.
|
||||
"""
|
||||
print("Detecting platform...")
|
||||
found = list(dict.fromkeys(name for name, _path in detect_platforms(os_type)))
|
||||
default = found[0] if len(found) == 1 else "" if found else "retroarch"
|
||||
if interactive:
|
||||
plat = _choose_platform(f"Which platform's files go to {dest}?", default)
|
||||
print(f" File list: {display_name(plat)}. Same choice without questions: "
|
||||
f"--platform {plat}")
|
||||
return plat
|
||||
if not default:
|
||||
print(f"Error: {' and '.join(display_name(n) for n in found)} found here. "
|
||||
f"Name the platform {dest} is for with --platform.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
why = "the platform found here" if found else "nothing was detected here"
|
||||
print(f" File list: {display_name(default)} ({why}). For another platform's "
|
||||
"list, add --platform (--list-platforms names them).")
|
||||
return default
|
||||
|
||||
|
||||
def _unknown_target(target: str, plat: str, targets: dict) -> None:
|
||||
print(f"Error: unknown target '{target}' for {plat}.", file=sys.stderr)
|
||||
if targets:
|
||||
print(" " + did_you_mean([target], sorted(targets), "--list-targets"),
|
||||
file=sys.stderr)
|
||||
else:
|
||||
print(f" {plat} publishes no target list", file=sys.stderr)
|
||||
|
||||
|
||||
def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
"""One run of the installer, once the options are read."""
|
||||
term = Terminal(real_stdout, quiet=args.quiet)
|
||||
@@ -3859,7 +4202,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
assumed_dests.add(default_dest)
|
||||
elif args.dest:
|
||||
print(f" Using destination: {args.dest}")
|
||||
platforms = [("retroarch", args.dest)]
|
||||
platforms = [(_platform_for_dest(os_type, args.dest, interactive and not listing),
|
||||
args.dest)]
|
||||
else:
|
||||
print("Detecting platform...")
|
||||
frontends = detect_frontends(os_type)
|
||||
@@ -3876,7 +4220,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
sys.exit(1)
|
||||
platforms = _prompt_manual_platform(os_type)
|
||||
for name, path in platforms:
|
||||
print(f" Found {name.capitalize()} at {path}")
|
||||
print(f" Found {display_name(name)} at {path}")
|
||||
|
||||
if len(platforms) > 1 and not listing and interactive:
|
||||
platforms = _prompt_platform_choice(platforms)
|
||||
@@ -3905,13 +4249,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
sys.exit(1)
|
||||
target_lists[plat_name] = targets
|
||||
if args.target not in targets:
|
||||
print(f"Error: unknown target '{args.target}' for {plat_name}.",
|
||||
file=sys.stderr)
|
||||
print(
|
||||
f" available: {', '.join(sorted(targets))}" if targets
|
||||
else f" {plat_name} publishes no target list",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_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)
|
||||
@@ -3922,7 +4260,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
cores_found: dict[tuple[str, Path], list[str]] = {}
|
||||
for plat_name, bios_path in platforms:
|
||||
if plat_name in CORE_PLATFORMS:
|
||||
searched = core_dirs(plat_name, bios_path)
|
||||
searched = [args.cores_dir] if args.cores_dir else core_dirs(plat_name, bios_path)
|
||||
cores_found[(plat_name, bios_path)] = installed_cores(searched)
|
||||
elif args.installed_cores:
|
||||
print(
|
||||
@@ -3932,12 +4270,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
if args.installed_cores and not cores_found[(plat_name, bios_path)]:
|
||||
print(
|
||||
f"Error: no installed core found for {plat_name} in: "
|
||||
f"{', '.join(map(str, searched)) or 'no known directory'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
_no_core_found(plat_name, bios_path, searched, bool(args.dest))
|
||||
|
||||
for plat_name, bios_path in platforms:
|
||||
if bios_path not in installed_paths:
|
||||
@@ -3999,18 +4332,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
# Carrying on would install every file, which is the opposite
|
||||
# of what a filter was asked for: a 4 GB download instead of
|
||||
# the subset the target needs. Refuse rather than ignore.
|
||||
print(
|
||||
f"Error: unknown target '{target}' for {plat_name}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if targets:
|
||||
print(
|
||||
f" available: {', '.join(sorted(targets))}", file=sys.stderr
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" {plat_name} publishes no target list", file=sys.stderr
|
||||
)
|
||||
_unknown_target(target, plat_name, targets)
|
||||
sys.exit(1)
|
||||
elif target_info.get("cores") is None:
|
||||
print(
|
||||
@@ -4062,16 +4384,24 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
if found_cores and not args.installed_cores else None
|
||||
)
|
||||
selection = _prompt_custom_selection(
|
||||
plat_name, files, omitted_files, installed
|
||||
plat_name, files, omitted_files, installed,
|
||||
display_name(plat_name, manifest),
|
||||
)
|
||||
if selection == INSTALLED_ONLY:
|
||||
files, omitted_files = installed
|
||||
print("\n Same choice without questions: "
|
||||
f"--platform {plat_name} --installed-cores")
|
||||
_INVOCATION.chosen[plat_name] = [("--installed-cores", None)]
|
||||
selection = None
|
||||
elif selection is not None:
|
||||
print("\n Same choice without questions: "
|
||||
+ _same_choice_hint(plat_name, *selection))
|
||||
systems, cores, regions = selection
|
||||
_INVOCATION.chosen[plat_name] = [
|
||||
*(("--system", name) for name in systems),
|
||||
*(("--core", name) for name in cores),
|
||||
*([("--region", ",".join(regions))] if regions else []),
|
||||
]
|
||||
if selection is not None:
|
||||
before = len(files)
|
||||
files, omitted_files = narrow(files, omitted_files, *selection)
|
||||
@@ -4138,14 +4468,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
if to_download:
|
||||
# Named, not only counted: a user looking for the one bad
|
||||
# BIOS learned "1 files need downloading" and nothing else.
|
||||
# A wrong hash comes first and always in full: listed after
|
||||
# 8000 missing files, it hid behind "... and 8059 more".
|
||||
wrong = {id(entry) for entry in mismatched}
|
||||
shown = to_download if args.verbose else to_download[:10]
|
||||
missing = [entry for entry in to_download if id(entry) not in wrong]
|
||||
shown = missing if args.verbose else missing[:10]
|
||||
print(f"\n {_plural(len(to_download), 'file')} to download:")
|
||||
for entry in mismatched:
|
||||
print(f" wrong hash: {entry['dest']}")
|
||||
for entry in shown:
|
||||
state = "wrong hash" if id(entry) in wrong else "missing"
|
||||
print(f" {state}: {entry['dest']}")
|
||||
if len(shown) < len(to_download):
|
||||
print(f" ... and {len(to_download) - len(shown)} more "
|
||||
print(f" missing: {entry['dest']}")
|
||||
if len(shown) < len(missing):
|
||||
print(f" ... and {len(missing) - len(shown)} more missing "
|
||||
"(--verbose lists them all)")
|
||||
print(" Run the same command without --check to fetch them; "
|
||||
"a file with a wrong hash is replaced.")
|
||||
@@ -4153,13 +4487,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
else:
|
||||
print("\n All files up to date.")
|
||||
absent = _absent_required(omitted_files, bios_path)
|
||||
if absent:
|
||||
# The collection cannot supply these, so they leave the exit
|
||||
# code alone, but a user asking why a game will not start
|
||||
# needs their names.
|
||||
print(f" Not in the collection and absent here, needed to start "
|
||||
f"an emulator: {', '.join(absent[:10])}"
|
||||
+ (f" and {len(absent) - 10} more" if len(absent) > 10 else ""))
|
||||
# The collection cannot supply these, so they leave the exit
|
||||
# code alone, but a user asking why a game will not start needs
|
||||
# their names, and which system and emulator each one is for.
|
||||
for line in _needed_lines(
|
||||
absent, "Not in the collection and absent here, needed to start an emulator",
|
||||
every=args.verbose,
|
||||
):
|
||||
print(line)
|
||||
for entry in data_dirs:
|
||||
_root, needed, present = data_directory_state(entry, bios_path)
|
||||
if needed:
|
||||
@@ -4169,10 +4504,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
_print_plan(to_download, bios_path, term)
|
||||
for entry in data_dirs:
|
||||
print(f" and the {entry['ref']} tree ({len(entry['files'])} files) "
|
||||
f"from {entry['url']}")
|
||||
_print_plan(to_download, bios_path, term, [
|
||||
(entry, data_directory_state(entry, bios_path)[1]) for entry in data_dirs
|
||||
])
|
||||
continue
|
||||
|
||||
if to_download:
|
||||
@@ -4205,7 +4539,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
# as when it exists read-only; 1 once an earlier platform
|
||||
# of the run installed something.
|
||||
sys.exit(1 if total_downloaded else 2)
|
||||
_check_free_space(bios_path, dl_size)
|
||||
_check_free_space(bios_path, dl_size, plat_name)
|
||||
progress = Progress(len(to_download), dl_size, term)
|
||||
failed, interrupted = download_files(
|
||||
to_download, bios_path, jobs=args.jobs, verbose=args.verbose,
|
||||
|
||||
+19
-1
@@ -18,7 +18,7 @@ esac
|
||||
TEMP_INSTALLER=""
|
||||
TEMP_DIRECTORY=""
|
||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
DEFAULT_INSTALL_SHA256="22737fbb470a63a8d3630f52ffee1535e57f96a2f1fea969e58368e06ca35f06"
|
||||
DEFAULT_INSTALL_SHA256="376edf794195d74f21e598854b5f3e90fb1955513835264bc9326ad4d2acf9c9"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
@@ -91,6 +91,18 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "Error: the downloaded installer does not match its expected fingerprint." >&2
|
||||
echo " Nothing was run and nothing was written." >&2
|
||||
overrides=""
|
||||
[ -n "${RETROBIOS_INSTALL_URL:-}" ] && overrides="RETROBIOS_INSTALL_URL"
|
||||
if [ -n "${RETROBIOS_INSTALL_SHA256:-}" ]; then
|
||||
overrides="${overrides:+$overrides and }RETROBIOS_INSTALL_SHA256"
|
||||
fi
|
||||
if [ -n "$overrides" ]; then
|
||||
# A pin or a URL of the user's own refuses the installer: retrying,
|
||||
# as the advice below says, would never help.
|
||||
echo " $overrides is set in this environment and decides which installer is" >&2
|
||||
echo " accepted: unset it to use the installer this script names, or set it to match." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " The download was most likely cut short: try again. Within minutes of an update" >&2
|
||||
echo " of this project the two files can come from caches of different ages." >&2
|
||||
echo " Waiting five minutes settles that." >&2
|
||||
@@ -116,6 +128,12 @@ fi
|
||||
# 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.
|
||||
# A downloaded installer is a temporary copy the user cannot run again: its
|
||||
# hints rebuild the one-line command instead.
|
||||
if [ -n "$TEMP_INSTALLER" ]; then
|
||||
RETROBIOS_BOOTSTRAP=sh
|
||||
export RETROBIOS_BOOTSTRAP
|
||||
fi
|
||||
if [ ! -t 0 ] && (: </dev/tty) 2>/dev/null; then
|
||||
"$PYTHON" "$INSTALLER" "$@" </dev/tty
|
||||
else
|
||||
|
||||
@@ -3784,12 +3784,15 @@ class TheInstallerNamesWhatItSkipped(unittest.TestCase):
|
||||
def test_count_agrees_and_required_files_are_named(self):
|
||||
one = install._omission_lines(1, [{"name": "diabdat.mpq", "required": True}])
|
||||
self.assertTrue(one[0].startswith("1 file was not installed"))
|
||||
self.assertIn("diabdat.mpq", one[1])
|
||||
self.assertTrue(any("diabdat.mpq" in line for line in one[1:]))
|
||||
many = install._omission_lines(
|
||||
12, [{"name": f"f{i:02}", "required": True} for i in range(12)]
|
||||
12,
|
||||
[{"name": f"f{i:02}", "required": True, "system": f"s{i:02}",
|
||||
"cores": ["core"]} for i in range(12)],
|
||||
)
|
||||
self.assertTrue(many[0].startswith("12 files were not installed"))
|
||||
self.assertIn("and 2 more", many[1])
|
||||
self.assertIn(" s00 (core): f00", many)
|
||||
self.assertTrue(any("and 4 more systems" in line for line in many))
|
||||
|
||||
|
||||
|
||||
|
||||
+407
-4
@@ -1735,7 +1735,8 @@ class TestManualPlatformPrompt(unittest.TestCase):
|
||||
self.assertIn("or q to quit", out.getvalue())
|
||||
|
||||
def test_windows_hint_shows_the_one_liner_with_arguments(self):
|
||||
hint = "\n".join(install._manual_usage_hint("windows"))
|
||||
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "ps1"}):
|
||||
hint = "\n".join(install._manual_usage_hint("windows"))
|
||||
self.assertIn("--platform retroarch --dest", hint)
|
||||
self.assertIn("install.ps1", hint)
|
||||
|
||||
@@ -1809,7 +1810,7 @@ class TestLaunchboxDetectionPowershell(unittest.TestCase):
|
||||
|
||||
def _assert_resolved(self, proc, ra_dir):
|
||||
self.assertIn("Found LaunchBox", proc.stdout)
|
||||
self.assertIn("Found Retroarch at", proc.stdout)
|
||||
self.assertIn("Found RetroArch at", proc.stdout)
|
||||
self.assertIn(str(ra_dir), proc.stdout)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("Done.", proc.stdout)
|
||||
@@ -1850,7 +1851,7 @@ class TestLaunchboxDetectionPowershell(unittest.TestCase):
|
||||
seed=seed,
|
||||
)
|
||||
self.assertIn("Found LaunchBox", proc.stdout)
|
||||
self.assertIn("Found Retroarch at", proc.stdout)
|
||||
self.assertIn("Found RetroArch at", proc.stdout)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn(str(ra_dir / "bios"), proc.stdout)
|
||||
|
||||
@@ -2313,7 +2314,7 @@ class SudoAdviceNamesTheFolder(unittest.TestCase):
|
||||
def test_the_advice_carries_platform_and_folder(self):
|
||||
advice = install._write_advice(Path("/media/card/Emulation/bios"), False, "emudeck")
|
||||
self.assertIn("--platform emudeck", advice)
|
||||
self.assertIn("--dest '/media/card/Emulation/bios'", advice)
|
||||
self.assertIn("--dest /media/card/Emulation/bios", advice)
|
||||
|
||||
def test_the_closing_report_passes_the_platform(self):
|
||||
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
@@ -3183,5 +3184,407 @@ class EveryStandaloneCopyNamesItsMacFolder(unittest.TestCase):
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
|
||||
|
||||
class _LoopbackRun:
|
||||
"""install.py against the repository's own manifests over loopback."""
|
||||
|
||||
def serve(self, test: unittest.TestCase) -> dict:
|
||||
handler = functools.partial(
|
||||
http.server.SimpleHTTPRequestHandler, directory=str(REPO_ROOT)
|
||||
)
|
||||
handler.log_message = lambda *args: None
|
||||
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
test.addCleanup(httpd.server_close)
|
||||
test.addCleanup(httpd.shutdown)
|
||||
self.home = Path(_tempdir(test))
|
||||
return {
|
||||
**os.environ, "HOME": str(self.home), "RETROBIOS_OS": "linux",
|
||||
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{httpd.server_address[1]}",
|
||||
}
|
||||
|
||||
|
||||
def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "install.py", *argv], cwd=REPO_ROOT, env=env,
|
||||
capture_output=True, text=True, timeout=300, check=False, **kwargs,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
MiSTer FPGA as the site writes it was refused."""
|
||||
|
||||
def test_the_names_the_site_writes_are_platforms(self):
|
||||
self.assertEqual(install.normalize_platform("MiSTer FPGA"), "misterfpga")
|
||||
self.assertEqual(install.normalize_platform("RetroArch "), "retroarch")
|
||||
self.assertEqual(install.normalize_platform("RetroDECK"), "retrodeck")
|
||||
|
||||
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.assertIn("Did you mean: batocera?", err.getvalue())
|
||||
self.assertNotIn("recalbox", err.getvalue())
|
||||
|
||||
def test_a_system_is_found_inside_its_full_id(self):
|
||||
known = ["3do", "nintendo-gba", "sega-saturn", "sony-playstation", "sony-playstation-2"]
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
install.resolve_choices(["playstation"], known, "system")
|
||||
text = str(raised.exception)
|
||||
self.assertIn("unknown system: playstation", text)
|
||||
self.assertIn("sony-playstation", text)
|
||||
self.assertIn("--list-systems", text)
|
||||
self.assertNotIn("3do", text)
|
||||
|
||||
def test_a_core_typo_points_at_the_listing(self):
|
||||
known = [f"core{i}" for i in range(200)] + ["swanstation"]
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
install.resolve_choices(["swanstaton"], known, "core")
|
||||
text = str(raised.exception)
|
||||
self.assertIn("Did you mean: swanstation?", text)
|
||||
self.assertIn("--list-cores lists all 201", text)
|
||||
self.assertNotIn("core17", text)
|
||||
|
||||
def test_a_name_spelled_as_the_site_writes_it(self):
|
||||
known = ["sony-playstation", "sony-playstation-2"]
|
||||
self.assertEqual(
|
||||
install.resolve_choices(["Sony PlayStation"], known, "system"),
|
||||
["sony-playstation"],
|
||||
)
|
||||
|
||||
def test_a_target_typo(self):
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
install._unknown_target("swich", "retroarch", {"switch": {}, "desktop": {}})
|
||||
self.assertIn("Did you mean: switch?", err.getvalue())
|
||||
self.assertIn("--list-targets", err.getvalue())
|
||||
|
||||
|
||||
class PlatformsAreNamedAsTheyNameThemselves(unittest.TestCase):
|
||||
"""str.capitalize() printed Retroarch, Retrodeck and Misterfpga on the
|
||||
first screen a user sees."""
|
||||
|
||||
def test_the_table_is_the_manifests(self):
|
||||
for plat in install.AVAILABLE_PLATFORMS:
|
||||
manifest = json.loads((REPO_ROOT / "install" / f"{plat}.json").read_text())
|
||||
with self.subTest(plat=plat):
|
||||
self.assertEqual(install.PLATFORM_NAMES[plat], manifest["display_name"])
|
||||
|
||||
def test_the_menus_use_them(self):
|
||||
out = io.StringIO()
|
||||
with unittest.mock.patch("builtins.input", lambda _prompt="": "1"), \
|
||||
contextlib.redirect_stdout(out):
|
||||
install._prompt_platform_choice(
|
||||
[("misterfpga", Path("/m")), ("retrodeck", Path("/r"))])
|
||||
self.assertIn("MiSTer FPGA (/m)", out.getvalue())
|
||||
self.assertIn("RetroDECK (/r)", out.getvalue())
|
||||
self.assertEqual(install.display_name("romm", {"display_name": "RomM"}), "RomM")
|
||||
self.assertEqual(install.display_name("romm", {"display_name": "\x1b[2J"}), "RomM")
|
||||
|
||||
|
||||
class HintsGiveTheUsersOwnCommandBack(unittest.TestCase):
|
||||
"""A one-liner user was told to run python3 install.py, a file they do
|
||||
not have, and the write advice dropped --system, turning a retry of 2
|
||||
files into 8069, with sudo on Windows."""
|
||||
|
||||
def setUp(self):
|
||||
patcher = unittest.mock.patch.object(install, "_INVOCATION", install.Invocation())
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def _record(self, *argv: str) -> None:
|
||||
args = SimpleNamespace(
|
||||
system=None, core=None, emulator=None, region=None, target=None,
|
||||
cores_dir=None, every_board=False, installed_cores=False,
|
||||
standalone_copies=False, check=False, dry_run=False,
|
||||
)
|
||||
flags = list(argv)
|
||||
while flags:
|
||||
flag = flags.pop(0)
|
||||
attr = flag[2:].replace("-", "_")
|
||||
if attr in ("system", "core", "emulator", "region"):
|
||||
setattr(args, attr, [*(getattr(args, attr) or []), flags.pop(0)])
|
||||
else:
|
||||
setattr(args, attr, True)
|
||||
install._INVOCATION.record(args)
|
||||
|
||||
def test_the_shell_one_liner_is_rebuilt(self):
|
||||
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
|
||||
hint = "\n".join(install._manual_usage_hint("linux"))
|
||||
self.assertIn("install.sh | sh -s -- --platform retroarch", hint)
|
||||
self.assertNotIn("python3 install.py", hint)
|
||||
self.assertIn(install.INSTALLER_PAGE, hint)
|
||||
|
||||
def test_a_clone_keeps_python(self):
|
||||
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": ""}):
|
||||
hint = "\n".join(install._manual_usage_hint("linux"))
|
||||
self.assertIn("python3 install.py --platform retroarch", hint)
|
||||
|
||||
def test_the_write_advice_keeps_the_narrowing(self):
|
||||
self._record("--system", "atari-lynx", "--region", "us")
|
||||
install._INVOCATION.os_type = "linux"
|
||||
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
|
||||
advice = install._write_advice(Path("/media/ro"), False, "retroarch")
|
||||
self.assertIn("| sudo sh -s -- --platform retroarch --dest /media/ro "
|
||||
"--system atari-lynx --region us", advice)
|
||||
|
||||
def test_windows_has_no_sudo(self):
|
||||
self._record("--system", "atari-lynx")
|
||||
install._INVOCATION.os_type = "windows"
|
||||
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "ps1"}):
|
||||
advice = install._write_advice(Path("C:/Program Files/RetroArch/system"),
|
||||
False, "retroarch")
|
||||
self.assertNotIn("sudo", advice)
|
||||
self.assertIn("administrator", advice)
|
||||
self.assertIn("install.ps1", advice)
|
||||
self.assertIn("--system atari-lynx", advice)
|
||||
|
||||
def test_a_full_drive_keeps_the_platform(self):
|
||||
install._INVOCATION.os_type = "linux"
|
||||
err = io.StringIO()
|
||||
with unittest.mock.patch.object(install.shutil, "disk_usage",
|
||||
return_value=SimpleNamespace(free=1)), \
|
||||
contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
|
||||
install._check_free_space(Path(_tempdir(self)), 10, "batocera")
|
||||
self.assertIn("--platform batocera --dest <folder on another drive>", err.getvalue())
|
||||
|
||||
def test_the_one_liner_tells_the_installer_how_it_was_started(self):
|
||||
scratch = REPO_ROOT / "tmp" / "tests"
|
||||
scratch.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
||||
root = Path(directory)
|
||||
probe = root / "probe.py"
|
||||
probe.write_text("import os\nprint('bootstrap:', os.environ.get('RETROBIOS_BOOTSTRAP'))\n")
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
(fake_bin / "curl").write_text(
|
||||
"#!/bin/sh\noutput=\n"
|
||||
"while [ \"$#\" -gt 0 ]; do\n"
|
||||
" if [ \"$1\" = --output ]; then output=$2; shift 2; else shift; fi\n"
|
||||
"done\n"
|
||||
f"cp -- {shlex.quote(str(probe))} \"$output\"\n")
|
||||
(fake_bin / "curl").chmod(0o755)
|
||||
env = {**os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}",
|
||||
"TMPDIR": str(root),
|
||||
"RETROBIOS_INSTALL_URL": "https://example.invalid/install.py",
|
||||
"RETROBIOS_INSTALL_SHA256": hashlib.sha256(probe.read_bytes()).hexdigest()}
|
||||
proc = subprocess.run(["sh", "-s"], cwd=root, env=env, capture_output=True,
|
||||
input=(REPO_ROOT / "install.sh").read_text(),
|
||||
text=True, timeout=30, check=False)
|
||||
self.assertIn("bootstrap: sh", proc.stdout, proc.stderr)
|
||||
|
||||
def test_the_help_has_examples_and_the_page(self):
|
||||
env = {**os.environ, "RETROBIOS_BOOTSTRAP": "sh"}
|
||||
proc = _install_run(env, "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
self.assertIn("examples:", proc.stdout)
|
||||
self.assertIn("install.sh | sh -s -- --platform batocera --dest", proc.stdout)
|
||||
self.assertIn(install.INSTALLER_PAGE, proc.stdout)
|
||||
|
||||
|
||||
class AnOverrideIsNamedWhenItIsTheCause(unittest.TestCase):
|
||||
"""A RETROBIOS_INSTALL_SHA256 of the user's own read as a cut download,
|
||||
and a dead RETROBIOS_BASE_URL as a network fault naming no host."""
|
||||
|
||||
def test_an_unreachable_base_url_is_named(self):
|
||||
error = install.urllib.error.URLError("timed out")
|
||||
with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BASE_URL": "https://mirror.invalid"}), \
|
||||
unittest.mock.patch.object(install, "_fetch_json", side_effect=error), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as err, \
|
||||
self.assertRaises(SystemExit):
|
||||
install.fetch_manifest("retroarch")
|
||||
text = err.getvalue()
|
||||
self.assertIn("Address: " + install.MANIFEST_URL.format(platform="retroarch"), text)
|
||||
self.assertIn("RETROBIOS_BASE_URL is set", text)
|
||||
|
||||
def test_a_pinned_fingerprint_is_named(self):
|
||||
scratch = REPO_ROOT / "tmp" / "tests"
|
||||
scratch.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
||||
root = Path(directory)
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
(fake_bin / "curl").write_text(
|
||||
"#!/bin/sh\noutput=\n"
|
||||
"while [ \"$#\" -gt 0 ]; do\n"
|
||||
" if [ \"$1\" = --output ]; then output=$2; shift 2; else shift; fi\n"
|
||||
"done\n"
|
||||
"echo 'print(1)' > \"$output\"\n")
|
||||
(fake_bin / "curl").chmod(0o755)
|
||||
env = {**os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}",
|
||||
"TMPDIR": str(root), "RETROBIOS_INSTALL_SHA256": "0" * 64}
|
||||
proc = subprocess.run(["sh", "-s"], cwd=root, env=env, capture_output=True,
|
||||
input=(REPO_ROOT / "install.sh").read_text(),
|
||||
text=True, timeout=30, check=False)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("RETROBIOS_INSTALL_SHA256 is set", proc.stderr)
|
||||
self.assertNotIn("cut short", proc.stderr)
|
||||
ps1 = (REPO_ROOT / "install.ps1").read_text()
|
||||
self.assertIn('"RETROBIOS_INSTALL_URL", "RETROBIOS_INSTALL_SHA256"', ps1)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell")
|
||||
class ThePowerShellOneLinerLeavesTheSessionAlone(unittest.TestCase):
|
||||
"""irm | iex left $ErrorActionPreference = Stop and the script's
|
||||
variables in the user's session: their next error stopped everything."""
|
||||
|
||||
def test_nothing_is_left_behind(self):
|
||||
script = (
|
||||
"Get-Content install.ps1 -Raw | Invoke-Expression; "
|
||||
"\"EAP=$ErrorActionPreference temporary=[$temporary] installer=[$installer] "
|
||||
"bootstrap=[$env:RETROBIOS_BOOTSTRAP]\""
|
||||
)
|
||||
# Refused before any request, so the run needs no network.
|
||||
env = {**os.environ, "RETROBIOS_INSTALL_URL": "http://127.0.0.1/install.py"}
|
||||
env.pop("RETROBIOS_BOOTSTRAP", None)
|
||||
proc = subprocess.run(["pwsh", "-NoProfile", "-Command", script], cwd=REPO_ROOT,
|
||||
env=env, capture_output=True, text=True, timeout=120)
|
||||
self.assertIn("must use HTTPS", proc.stderr)
|
||||
self.assertIn("EAP=Continue temporary=[] installer=[] bootstrap=[]", proc.stdout)
|
||||
|
||||
|
||||
class AQuestionLeftWithCtrlDOrCtrlCIsAStop(unittest.TestCase):
|
||||
"""Ctrl+C or Ctrl+D at a question left with 0, so `install.sh && retroarch`
|
||||
carried on with no BIOS, while the page documents 130."""
|
||||
|
||||
def test_eof_raises_the_stop(self):
|
||||
def closed(_prompt=""):
|
||||
raise EOFError
|
||||
|
||||
with unittest.mock.patch("builtins.input", closed), \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
self.assertRaises(KeyboardInterrupt):
|
||||
install._ask("> ")
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "needs a pseudo-terminal")
|
||||
def test_ctrl_d_at_the_platform_question_exits_130(self):
|
||||
import pty # noqa: PLC0415
|
||||
import time # noqa: PLC0415
|
||||
|
||||
home = Path(_tempdir(self))
|
||||
master, slave = pty.openpty()
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "install.py"], cwd=REPO_ROOT, stdin=slave,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
env={**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux"},
|
||||
)
|
||||
os.close(slave)
|
||||
self.addCleanup(os.close, master)
|
||||
time.sleep(3)
|
||||
os.write(master, b"\x04")
|
||||
out, _ = proc.communicate(timeout=60)
|
||||
if "Choose the platform" not in out:
|
||||
self.skipTest("a platform was detected on this machine")
|
||||
self.assertEqual(proc.returncode, 130, out)
|
||||
self.assertIn("Stopped", out)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def test_grouped_by_system_with_its_emulators(self):
|
||||
entries = [
|
||||
{"dest": "DARK.GOB", "system": "dark-forces", "cores": ["theforceengine"]},
|
||||
{"dest": "SOUNDS.GOB", "system": "dark-forces", "cores": ["theforceengine"]},
|
||||
{"dest": "Data.rsdk", "system": "sonic-mania", "cores": ["sonicmania"]},
|
||||
]
|
||||
lines = install._needed_lines(entries, "Needed to start an emulator")
|
||||
self.assertIn(" dark-forces (theforceengine): DARK.GOB, SOUNDS.GOB", lines)
|
||||
self.assertIn(" sonic-mania (sonicmania): Data.rsdk", lines)
|
||||
self.assertIn("(3 files)", lines[0])
|
||||
self.assertIn(f"{install.SITE_URL}emulators/", lines[-1])
|
||||
|
||||
|
||||
class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
|
||||
"""Behaviour read end to end over loopback."""
|
||||
|
||||
def setUp(self):
|
||||
self.env = self.serve(self)
|
||||
|
||||
def test_a_wrong_hash_is_listed_first(self):
|
||||
dest = self.home / "system"
|
||||
dest.mkdir()
|
||||
(dest / "scph5501.bin").write_bytes(b"junk")
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(dest),
|
||||
"--check", "--no-input")
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
listing = proc.stdout.split("to download:\n", 1)[1].splitlines()
|
||||
self.assertEqual(listing[0].strip(), "wrong hash: scph5501.bin")
|
||||
|
||||
def test_a_dry_run_counts_the_data_trees_and_ends_on_its_last_line(self):
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
||||
str(self.home / "s"), "--dry-run", "--no-input")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
lines = [line for line in proc.stdout.splitlines() if line.strip()]
|
||||
self.assertIn("Nothing was written", lines[-1])
|
||||
header = next(line for line in lines if line.startswith("Would download"))
|
||||
self.assertIn("data files", header)
|
||||
|
||||
def test_dest_alone_says_whose_list_it_is(self):
|
||||
proc = _install_run(self.env, "--dest", str(self.home / "card"), "--dry-run",
|
||||
"--no-input", "--system", "atari-lynx")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("File list: RetroArch", proc.stdout)
|
||||
self.assertIn("--platform", proc.stdout)
|
||||
|
||||
def test_a_cores_folder_can_be_named(self):
|
||||
cores = self.home / "cores"
|
||||
cores.mkdir()
|
||||
(cores / "handy_libretro.so").write_bytes(b"")
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
||||
str(self.home / "s"), "--cores-dir", str(cores),
|
||||
"--dry-run", "--no-input")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("to the installed cores", proc.stdout)
|
||||
|
||||
def test_no_core_found_says_how_to_go_on(self):
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
||||
str(self.home / "s"), "--installed-cores", "--dry-run",
|
||||
"--no-input")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("--cores-dir <cores folder>", proc.stderr)
|
||||
self.assertIn("without --installed-cores", proc.stderr)
|
||||
|
||||
|
||||
class DestAloneFollowsWhatIsFoundHere(unittest.TestCase):
|
||||
"""--dest without --platform always meant RetroArch's list, even on a
|
||||
machine where Batocera was found, and nothing said so."""
|
||||
|
||||
def _pick(self, found, interactive=False, answer=""):
|
||||
out = io.StringIO()
|
||||
with unittest.mock.patch.object(install, "detect_platforms", return_value=found), \
|
||||
unittest.mock.patch("builtins.input", lambda _prompt="": answer), \
|
||||
contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
|
||||
try:
|
||||
return install._platform_for_dest("linux", Path("/media/card"), interactive), out.getvalue()
|
||||
except SystemExit as exc:
|
||||
return exc, out.getvalue()
|
||||
|
||||
def test_nothing_found_is_retroarch_said_aloud(self):
|
||||
plat, out = self._pick([])
|
||||
self.assertEqual(plat, "retroarch")
|
||||
self.assertIn("File list: RetroArch (nothing was detected here)", out)
|
||||
|
||||
def test_the_one_platform_found_is_used(self):
|
||||
plat, out = self._pick([("batocera", Path("/userdata/bios"))])
|
||||
self.assertEqual(plat, "batocera")
|
||||
self.assertIn("File list: Batocera", out)
|
||||
|
||||
def test_several_found_without_a_terminal_is_refused(self):
|
||||
result, out = self._pick([("emudeck", Path("/e")), ("retroarch", Path("/r"))])
|
||||
self.assertIsInstance(result, SystemExit)
|
||||
self.assertEqual(result.code, 1)
|
||||
self.assertIn("--platform", out)
|
||||
|
||||
def test_a_terminal_asks_and_enter_keeps_the_one_found(self):
|
||||
plat, out = self._pick([("batocera", Path("/userdata/bios"))], interactive=True)
|
||||
self.assertEqual(plat, "batocera")
|
||||
self.assertIn("Which platform's files go to /media/card?", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+30
-15
@@ -115,9 +115,10 @@ Checking existing files...
|
||||
|
||||
When the manifest leaves entries out (a file the collection cannot supply, or
|
||||
one that failed its hash check), a `Not installable:` line under the index count
|
||||
says how many and why. `--check` also names the required ones missing from the
|
||||
folder. They do not change its exit code, since no run of the installer can
|
||||
fetch them.
|
||||
says how many and why. `--check` and the closing report also name the required
|
||||
ones, grouped by system with the emulators that read them, and point at the
|
||||
emulator pages. They do not change the exit code of `--check`, since no run of
|
||||
the installer can fetch them.
|
||||
|
||||
## Choosing what to install
|
||||
|
||||
@@ -125,7 +126,7 @@ By default the installer fetches everything the platform runs. At a terminal,
|
||||
after detection, it asks:
|
||||
|
||||
```
|
||||
Retroarch: 5865 files, 5.7 GB.
|
||||
RetroArch: 5865 files, 5.7 GB.
|
||||
Enter) install everything
|
||||
i) only what the installed cores read: 102 files, 44.5 MB
|
||||
c) choose systems, cores and regions
|
||||
@@ -188,14 +189,20 @@ frontend only checks the path, the core's copy is installed while that core
|
||||
is, and otherwise the platform's answer, read from the first declaring
|
||||
system that stays.
|
||||
|
||||
With `--dest` and no `retroarch.cfg` beside the folder, nothing names the cores
|
||||
folder: `--cores-dir` names it, and the refusal prints the command with it.
|
||||
|
||||
Batocera, Recalbox and the other distributions ship their cores with the
|
||||
system, so the option is refused there, as it is when no core is found or
|
||||
when the file list predates the mapping: carrying on would install
|
||||
everything while the option asked for less.
|
||||
|
||||
A name the platform does not have is refused with the list of the ones it
|
||||
has, before anything is downloaded: carrying on would install something other
|
||||
than what was asked. A system name that itself contains commas, as Recalbox's
|
||||
A name the platform does not have is refused before anything is downloaded,
|
||||
since carrying on would install something other than what was asked. The
|
||||
refusal suggests the closest names (`playstation` gives `sony-playstation`) and
|
||||
the option that lists them all. Names are matched on letters and digits, so
|
||||
`--platform "MiSTer FPGA"` and `--system "Sony PlayStation"` are read as the
|
||||
site writes them. A system name that itself contains commas, as Recalbox's
|
||||
`msx1,msx2,msxturbor` does, is read as one name.
|
||||
|
||||
The region rule is the one `generate_pack.py --region` applies, and a test
|
||||
@@ -216,10 +223,11 @@ what the options name, everything by default.
|
||||
| Option | Effect |
|
||||
|--------|--------|
|
||||
| `--platform NAME` | Install for this platform instead of the detected one. Unknown names are refused with the available list |
|
||||
| `--dest PATH` | Destination directory, overriding detection. With `--dest` alone the file list is RetroArch's |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
| `--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices |
|
||||
| `--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) |
|
||||
| `--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores` |
|
||||
| `--system NAMES` | Install only these systems. Comma-separated or repeated. `--list-systems` prints the names, `other` included |
|
||||
| `--core NAMES` | Keep the platform's own list and only the extra files these cores load. `--list-cores` prints the names |
|
||||
| `--emulator NAMES` | Install what these emulators read on the detected platform, and nothing else: their own files and the platform's files of the systems they run. Each emulator page on the site gives this command. `--list-cores` prints the names |
|
||||
@@ -237,10 +245,12 @@ what the options name, everything by default.
|
||||
| `--jobs N`, `-j N` | Parallel downloads, 1 to 32, default 8 |
|
||||
| `--verbose`, `-v` | Print per-attempt failures and hash mismatches |
|
||||
| `--standalone-copies` | Also copy into detected standalone emulator directories |
|
||||
| `--help`, `-h` | Print the option list and exit |
|
||||
| `--help`, `-h` | Print the option list, a few examples in the form the run was started with, and the address of this page |
|
||||
|
||||
`--check` reads the same file list, hashes the destination and exits, with
|
||||
code 1 when a file is missing or does not match and 0 when every one does. Without
|
||||
code 1 when a file is missing or does not match and 0 when every one does. Files
|
||||
with a wrong hash are listed first and in full, then the first ten missing ones
|
||||
(`--verbose` lists them all). `--dry-run` counts the data trees in its total. Without
|
||||
it, a file already present with the expected hash is left untouched, and one
|
||||
whose contents do not match is re-downloaded and replaced by the verified copy.
|
||||
Files the list does not name are never read, moved or deleted.
|
||||
@@ -250,11 +260,12 @@ Files the list does not name are never read, moved or deleted.
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `RETROBIOS_REF` | Branch or tag the file list and the payloads are read from. Defaults to `main`, and a release tag pins a reproducible file set |
|
||||
| `RETROBIOS_BASE_URL` | Base URL for the manifest and the files it declares. HTTPS only, plain HTTP allowed to loopback for end-to-end tests. Whoever controls this base controls the expected hashes, hence the check |
|
||||
| `RETROBIOS_BASE_URL` | Base URL for the manifest and the files it declares. HTTPS only, plain HTTP allowed to loopback for end-to-end tests. Whoever controls this base controls the expected hashes, hence the check. A file list that cannot be fetched names the address, and this variable when it is set |
|
||||
| `RETROBIOS_OS` | Names the host outright (`linux`, `wsl`, `windows`, `darwin`, `android`) instead of detecting it |
|
||||
| `EXTERNAL_STORAGE` | Android only, the shared storage root RetroArch builds its default directories from. Defaults to `/storage/emulated/0` |
|
||||
| `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only |
|
||||
| `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters |
|
||||
| `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters. When either of these two is set and the fingerprint does not match, the error names the variable instead of suggesting a retry |
|
||||
| `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, so that its hints give back the one-line command rather than `python3 install.py` |
|
||||
| `NO_COLOR` | Plain text, no colour, even at a terminal. `TERM=dumb` also turns the live progress line off |
|
||||
| `HTTPS_PROXY`, `NO_PROXY` | Honoured for every download through the standard library's default proxy handling: one names the proxy, the other the hosts to reach directly |
|
||||
|
||||
@@ -362,7 +373,9 @@ relying on that.
|
||||
|
||||
With nothing detected, the installer lists the platforms and asks. With several
|
||||
detected, it asks which one. Both prompts need a terminal: piped into a script
|
||||
with no platform to install for, it prints the manual invocation and exits 1.
|
||||
with no platform to install for, it prints the manual invocation, in the form
|
||||
the run was started with (the one-line command for a one-liner user), and
|
||||
exits 1.
|
||||
|
||||
## Standalone copies
|
||||
|
||||
@@ -491,12 +504,14 @@ fields, the installer refuses to narrow rather than install everything.
|
||||
| `0` | everything asked for is installed or already up to date |
|
||||
| `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 while others installed, or `--check` found files to fetch |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one) |
|
||||
| `130` | stopped with Ctrl+C. Run the same command again to carry on |
|
||||
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
|
||||
| `141` | the output was piped to a reader that closed early (`--list \| head`) |
|
||||
|
||||
`install.ps1` passes the installer's code on unchanged: run as a file it exits
|
||||
with it, run through `irm \| iex` it sets `$LASTEXITCODE` (an `exit` there
|
||||
would close the PowerShell window).
|
||||
would close the PowerShell window). Its body runs in a script block of its own,
|
||||
so `irm \| iex` leaves neither its variables nor its `$ErrorActionPreference`
|
||||
in the session.
|
||||
|
||||
Failures that are per-file, such as a download exhausting its three attempts,
|
||||
are counted and reported at the end.
|
||||
|
||||
+15
-6
@@ -282,16 +282,25 @@ Windows refuses that too, which a file owned by another account can cause.
|
||||
|
||||
### Platform not detected
|
||||
|
||||
`install.py` auto-detects your platform by checking for known config files. If
|
||||
detection fails, specify the platform manually:
|
||||
The installer finds your platform from the config files it knows. If it finds
|
||||
none, name the platform and its BIOS folder.
|
||||
|
||||
Linux, macOS, Android (Termux):
|
||||
|
||||
```bash
|
||||
python install.py --platform retroarch --dest ~/RetroArch/system/
|
||||
python install.py --platform batocera --dest /userdata/bios/
|
||||
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --platform retroarch --dest ~/.config/retroarch/system
|
||||
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --platform batocera --dest /userdata/bios
|
||||
```
|
||||
|
||||
`python install.py --list-platforms` lists the platforms the installer knows
|
||||
and the ones it detects here, and `--help` lists the options.
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
&([scriptblock]::Create((irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1))) `
|
||||
--platform retroarch --dest C:\RetroArch-Win64\system
|
||||
```
|
||||
|
||||
`--list-platforms` lists the platforms the installer knows and the ones it
|
||||
finds here, and `--help` lists the options with examples.
|
||||
|
||||
### Manifest or file not found
|
||||
|
||||
|
||||
Reference in new issue
Block a user