mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
chore: merge main into the launcher fixes
This commit is contained in:
commit
68349f246c
10 files changed
+1130
-134
No files matched your search
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
|
||||
$defaultInstallSha256 = "563ed14fea5ea54f5d6cb98311932293e714e9581014ea60c195325c20a587eb"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+523
-115
File diff suppressed because it is too large.
Load diff
+1
-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="e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
|
||||
DEFAULT_INSTALL_SHA256="563ed14fea5ea54f5d6cb98311932293e714e9581014ea60c195325c20a587eb"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -17,6 +17,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
import unittest.mock
|
||||
@@ -2369,6 +2370,48 @@ class FileListsAreRetried(unittest.TestCase):
|
||||
self.assertIn(f"after {install.MAX_RETRIES} attempts", err.getvalue())
|
||||
|
||||
|
||||
class ASilentServerIsBounded(unittest.TestCase):
|
||||
"""A server that takes the connection and never answers held line mode
|
||||
93 s: three waits of 30 s for a first byte that never came."""
|
||||
|
||||
def test_two_short_waits_then_the_reason(self):
|
||||
import socket
|
||||
import threading
|
||||
|
||||
listener = socket.socket()
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(8)
|
||||
self.addCleanup(listener.close)
|
||||
held = []
|
||||
|
||||
def accept():
|
||||
while True:
|
||||
try:
|
||||
conn, _addr = listener.accept()
|
||||
except OSError:
|
||||
return
|
||||
held.append(conn)
|
||||
|
||||
threading.Thread(target=accept, daemon=True).start()
|
||||
self.addCleanup(lambda: [conn.close() for conn in held])
|
||||
port = listener.getsockname()[1]
|
||||
err = io.StringIO()
|
||||
start = time.monotonic()
|
||||
with unittest.mock.patch.object(install, "LIST_TIMEOUT", 0.5), \
|
||||
unittest.mock.patch.object(install, "_retry_delay", return_value=0), \
|
||||
unittest.mock.patch.object(install, "MANIFEST_URL",
|
||||
f"http://127.0.0.1:{port}/{{platform}}.json"), \
|
||||
contextlib.redirect_stderr(err):
|
||||
with self.assertRaises(SystemExit):
|
||||
install.fetch_manifest("retroarch")
|
||||
elapsed = time.monotonic() - start
|
||||
self.assertLess(elapsed, 5)
|
||||
self.assertEqual(len(held), install.STALLED_ATTEMPTS)
|
||||
self.assertIn(f"after {install.STALLED_ATTEMPTS} attempts", err.getvalue())
|
||||
# A download keeps its own, longer wait between chunks.
|
||||
self.assertGreater(install.LIST_TIMEOUT, 1)
|
||||
|
||||
|
||||
class TargetListFailureIsNotAnAnswer(unittest.TestCase):
|
||||
"""A timeout is not 'no targets': it sent users to the full pack."""
|
||||
|
||||
@@ -3248,6 +3291,41 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
||||
self.assertEqual(counts["changed"], 1)
|
||||
self.assertLess(Endless.served, 64 * 1024)
|
||||
|
||||
def test_a_damaged_member_is_a_reason_and_leaves_no_part(self):
|
||||
"""zlib.error and EOFError from a damaged member ended the run on a
|
||||
traceback, and a failed write left its .part beside the tree."""
|
||||
import zlib
|
||||
|
||||
work = Path(_tempdir(self))
|
||||
entry = self._entry()
|
||||
entry["files"] = {"Sys/GC/font.bin": {"sha1": "1" * 40, "size": 4}}
|
||||
for error in (zlib.error("invalid stored block lengths"), EOFError(),
|
||||
OSError(28, "No space left on device")):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
class Broken(io.RawIOBase):
|
||||
def readable(self):
|
||||
return True
|
||||
|
||||
def readinto(self, buffer, error=error):
|
||||
raise error
|
||||
|
||||
def members(_archive, _entry):
|
||||
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Broken())
|
||||
|
||||
with unittest.mock.patch.object(install, "_fetch_archive",
|
||||
lambda url, d: Path(d) / "a"), \
|
||||
unittest.mock.patch.object(install, "_archive_members", members):
|
||||
counts = install.install_data_directory(entry, work)
|
||||
self.assertTrue(counts["error"])
|
||||
self.assertEqual(counts["installed"], 0)
|
||||
self.assertEqual([p.name for p in work.rglob("*.part")], [])
|
||||
|
||||
def test_offline_keeps_its_reason(self):
|
||||
work = Path(_tempdir(self))
|
||||
with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
|
||||
counts = install.install_data_directory(self._entry(), work)
|
||||
self.assertIsInstance(counts["error"], install.OfflineReason)
|
||||
|
||||
def test_a_tarball_maps_through_source_path(self):
|
||||
import io as _io
|
||||
import tarfile
|
||||
|
||||
@@ -288,6 +288,56 @@ class APlatformFileSettledByCoresAndSystems(unittest.TestCase):
|
||||
self.assertEqual(self.served(["dsi"]), "c")
|
||||
self.assertEqual(self.served(["nds"]), "b")
|
||||
|
||||
def test_declared_only_keeps_the_declaration_whose_system_remains(self):
|
||||
"""--declared-only settled the slot on the first declaration before
|
||||
the cores narrowed it: on a target without melonds it checked the
|
||||
nintendo-dsi copy, the platform the nintendo-ds one."""
|
||||
manifest = self.manifest()
|
||||
for names, letter in ((["nds"], "b"), (["dsi"], "c"), (["melonds"], "c"),
|
||||
(["desmume"], "b")):
|
||||
with self.subTest(names=names):
|
||||
files, omitted = install.keep_declared(manifest["files"], [])
|
||||
files, _ = install.narrow_to_cores(manifest, names, files, omitted)
|
||||
self.assertEqual(files[0]["sha1"][0], letter)
|
||||
|
||||
def test_the_declared_event_judges_the_copy_the_platform_checks(self):
|
||||
"""melonds' copy is served at the slot, the platform checks its own:
|
||||
the event and a --declared-only check give the same answer."""
|
||||
import hashlib
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
payloads = {letter: letter.encode() * 3 for letter in "abc"}
|
||||
manifest = self.manifest()
|
||||
entry = manifest["files"][0]
|
||||
|
||||
def real(copy: dict, letter: str) -> None:
|
||||
copy.update(sha1=hashlib.sha1(payloads[letter]).hexdigest(),
|
||||
sha256=hashlib.sha256(payloads[letter]).hexdigest(), size=3)
|
||||
|
||||
real(entry, "a")
|
||||
real(entry["slot_choices"][1], "a")
|
||||
real(entry["declared_choices"][0], "c")
|
||||
real(entry["declared_choices"][1], "b")
|
||||
(tmp / "dsi_bios9.bin").write_bytes(payloads["c"])
|
||||
|
||||
def declared_event(files: list[dict]) -> dict:
|
||||
_todo, fine, wrong = install.check_local(files, tmp)
|
||||
with mock.patch.object(install.EVENTS, "emit") as emit:
|
||||
install._emit_check("retroarch", manifest, tmp, files, fine, wrong, 0, 0)
|
||||
return next(call.kwargs for call in emit.call_args_list
|
||||
if call.args[0] == "declared")
|
||||
|
||||
for names in (None, ["melonds"], ["desmume", "melonds"]):
|
||||
with self.subTest(names=names):
|
||||
served = manifest["files"]
|
||||
own, _ = install.keep_declared(manifest["files"], [])
|
||||
if names is not None:
|
||||
served, _ = install.narrow_to_cores(manifest, names)
|
||||
own, _ = install.narrow_to_cores(manifest, names, own, [])
|
||||
self.assertEqual(declared_event(served), declared_event(own))
|
||||
self.assertEqual(declared_event(own)["present"], 1)
|
||||
|
||||
def test_the_boundary_checks_every_copy(self):
|
||||
install._validate_manifest(self.manifest(), "retroarch")
|
||||
bad_choices = (
|
||||
|
||||
@@ -17,7 +17,9 @@ import errno
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
@@ -518,6 +520,18 @@ class ACopyThatFails(FromFolder):
|
||||
self.assertNotIn(install.NOT_WRITABLE, reason)
|
||||
self.assertIsInstance(reason, install.NetworkReason)
|
||||
|
||||
def test_offline_the_fallback_keeps_the_offline_reason(self):
|
||||
"""The copy failed, the download was refused by --offline: the
|
||||
joined reason lost its kind, and the advice named neither."""
|
||||
_unprivileged(self)
|
||||
self.source.chmod(0)
|
||||
self.addCleanup(self.source.chmod, 0o644)
|
||||
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
|
||||
_dest, ok, reason = install._download_one(self.entry, self.dest, copy=self.copy)
|
||||
self.assertFalse(ok)
|
||||
self.assertIsInstance(reason, install.OfflineReason)
|
||||
self.assertIn("could not be read: Permission denied, and not fetched", reason)
|
||||
|
||||
def test_a_read_error_is_the_source_and_not_the_folder(self):
|
||||
class Failing(io.BytesIO):
|
||||
def read(self, *_args):
|
||||
@@ -596,6 +610,28 @@ class UnreadableCopies(FromFolder):
|
||||
self.assertIn(line, proc.stdout)
|
||||
self.assertNotIn("notes.txt", proc.stdout)
|
||||
|
||||
def test_names_on_the_card_cannot_drive_the_terminal(self):
|
||||
"""The plain output printed the paths it found as they are: a name
|
||||
holding ESC cleared the screen or set the clipboard (OSC 52)."""
|
||||
hostile = "x\x1b]52;c;eA==\x07\x1b[2J"
|
||||
self.locked(f"{hostile}/scph5501.bin", US)
|
||||
self.keep(f"{hostile}/gba_bios.bin", GBA)
|
||||
(self.dest / f"pcsx{hostile}").mkdir(parents=True)
|
||||
(self.dest / f"pcsx{hostile}" / "extra.bin").write_bytes(EXTRA)
|
||||
for options in (("--check",), ("--dry-run",), ("--check", "--verbose"), ()):
|
||||
with self.subTest(options=options):
|
||||
proc = self.install(*options)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
for stream in (proc.stdout, proc.stderr):
|
||||
self.assertIsNone(re.search(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]", stream),
|
||||
stream)
|
||||
self.assertIn("x?]52;c;eA==??[2J", proc.stdout)
|
||||
proc = self.run_installer("--manifest", str(self.list), "--no-input",
|
||||
"--from", str(self.root / hostile))
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertNotIn("\x1b", proc.stderr)
|
||||
self.assertIn("x?]52;c;eA==??[2J", proc.stderr)
|
||||
|
||||
def test_an_unreadable_copy_is_an_event(self):
|
||||
path = self.locked("psx/scph5501.bin", US).resolve()
|
||||
proc = self.install("--check", "--events")
|
||||
@@ -843,6 +879,54 @@ class ManySameSizedFiles(FromFolder):
|
||||
self.assertEqual(len(copies), 3)
|
||||
self.assertLess(elapsed, 30, f"{elapsed:.1f} s")
|
||||
|
||||
def test_one_archive_of_thousands_of_members_is_opened_once(self):
|
||||
"""Each member read reopened its ZIP and parsed its directory again:
|
||||
4000 members of one size made 4000 parses of 4000 entries."""
|
||||
count = 4000
|
||||
_zip(self.old / "romset.zip", {f"m{n:04}.bin": b"m%015d" % n for n in range(count)})
|
||||
wanted = [_entry(f"z{n}.bin", b"m%015d" % n, ["x"]) for n in (count - 1, 3, 1234)]
|
||||
index = install.FolderIndex(self.old)
|
||||
opened = []
|
||||
real = zipfile.ZipFile
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
opened.append(args[0])
|
||||
return real(*args, **kwargs)
|
||||
|
||||
start = time.monotonic()
|
||||
with mock.patch.object(install.zipfile, "ZipFile", side_effect=counting):
|
||||
copies = install.plan_local_copies(wanted, self.dest, None, [index])
|
||||
self.assertEqual(len(copies), 3)
|
||||
# The copy step reads members of the archive the plan opened,
|
||||
# from threads.
|
||||
results = []
|
||||
threads = [threading.Thread(target=lambda e=e: results.append(
|
||||
install._copy_local(e, self.dest, self.dest / e["dest"], copies[id(e)], None)))
|
||||
for e in wanted]
|
||||
self.dest.mkdir(parents=True, exist_ok=True)
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
elapsed = time.monotonic() - start
|
||||
self.assertEqual(sorted(ok for _d, ok, _r in results), [True] * 3, results)
|
||||
self.assertEqual(len(opened), 1)
|
||||
self.assertLess(elapsed, 20, f"{elapsed:.1f} s")
|
||||
index.close()
|
||||
self.assertEqual(index.open_archives(), 0)
|
||||
|
||||
def test_a_card_of_many_archives_holds_few_open(self):
|
||||
count = install.OPEN_ARCHIVES * 3
|
||||
for n in range(count):
|
||||
_zip(self.old / f"sets/s{n:03}.zip", {f"m{n}.bin": b"n%015d" % n})
|
||||
wanted = [_entry(f"z{n}.bin", b"n%015d" % n, ["x"]) for n in range(count)]
|
||||
index = install.FolderIndex(self.old)
|
||||
copies = install.plan_local_copies(wanted, self.dest, None, [index])
|
||||
self.assertEqual(len(copies), count)
|
||||
self.assertLessEqual(index.open_archives(), install.OPEN_ARCHIVES)
|
||||
install.LOCAL.close_indexes()
|
||||
self.assertEqual(index.open_archives(), 0)
|
||||
|
||||
def test_a_file_of_the_same_name_comes_first(self):
|
||||
self.keep("a/other.bin", US)
|
||||
self.keep("b/scph5501.bin", US)
|
||||
|
||||
@@ -493,6 +493,19 @@ class TheBoundaryChecksTheNewFields(unittest.TestCase):
|
||||
with self.subTest(field=field, value=bad), self.assertRaises(ValueError):
|
||||
install._validate_manifest(manifest, "retroarch")
|
||||
|
||||
def test_the_system_titles(self):
|
||||
"""A title is read as text by --system and the interface: a list or
|
||||
a number there raised mid-run instead of at the boundary."""
|
||||
good = self._manifest()
|
||||
good["system_titles"] = {"sony-playstation": "Sony PlayStation"}
|
||||
install._validate_manifest(good, "retroarch")
|
||||
for bad in (["x"], {"s": ["Sony"]}, {"s": None}, {"s": 5}, {"s": ""},
|
||||
{"": "Sony"}, {"s": "x" * 257}, {"s" * 257: "Sony"}):
|
||||
manifest = self._manifest()
|
||||
manifest["system_titles"] = bad
|
||||
with self.subTest(value=bad), self.assertRaises(ValueError):
|
||||
install._validate_manifest(manifest, "retroarch")
|
||||
|
||||
|
||||
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, format, *args): # noqa: A002 - stdlib signature
|
||||
@@ -1488,5 +1501,38 @@ class TheTitleASetupPrints(unittest.TestCase):
|
||||
self.assertEqual(install.system_aliases(manifest)["Sony PlayStation"], "arcade")
|
||||
|
||||
|
||||
class SomethingElseAtADestination(unittest.TestCase):
|
||||
"""A folder, or a file this user cannot read, at a destination ended
|
||||
--check and the install on an IsADirectoryError traceback."""
|
||||
|
||||
def prepare(self, repo: FakeRepository) -> None:
|
||||
(repo.dest / "scph5500.bin").mkdir(parents=True)
|
||||
unreadable = repo.dest / "scph5501.bin"
|
||||
unreadable.write_bytes(b"x")
|
||||
unreadable.chmod(0)
|
||||
|
||||
@unittest.skipIf(os.name == "nt" or (hasattr(os, "geteuid") and os.geteuid() == 0),
|
||||
"a mode of 0 stops no read on Windows or as root")
|
||||
def test_each_says_why_in_the_check_and_the_install(self):
|
||||
with FakeRepository() as repo:
|
||||
self.prepare(repo)
|
||||
proc = repo.run("--check")
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
self.assertIn(" wrong hash: scph5500.bin\n a folder sits where this "
|
||||
"file goes: move it away, then run again", proc.stdout)
|
||||
self.assertIn(" wrong hash: scph5501.bin\n could not be read "
|
||||
"(Permission denied)", proc.stdout)
|
||||
proc = repo.run("--yes")
|
||||
self.assertEqual(proc.returncode, 1, proc.stderr)
|
||||
self.assertNotIn("Traceback", proc.stderr)
|
||||
self.assertIn("failed: scph5500.bin (a folder sits where this file goes",
|
||||
proc.stdout)
|
||||
self.assertIn("failed: scph5501.bin (file in place could not be kept aside",
|
||||
proc.stdout)
|
||||
self.assertTrue((repo.dest / "scph5500.bin").is_dir())
|
||||
self.assertIn("gba_bios.bin", repo.installed())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+313
-4
@@ -10,6 +10,8 @@ installs it).
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import errno
|
||||
import fcntl
|
||||
import functools
|
||||
import hashlib
|
||||
@@ -17,6 +19,7 @@ import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
@@ -1095,6 +1098,13 @@ class PadRoutes(unittest.TestCase):
|
||||
"Close the terminal"):
|
||||
self.assertIn(words, muxterm)
|
||||
self.assertNotIn("Start+Select", muxterm)
|
||||
# muxterm keeps PgUp and PgDn for its scrollback, and L1 and R1
|
||||
# send nothing: no paging is offered there, on a list or a pager.
|
||||
for screen in ("systems", "result", "plan"):
|
||||
with self.subTest(screen=screen):
|
||||
text = help_text("muxterm", screen)
|
||||
for words in ("L1", "R1", "PgUp", "PgDn", "Page"):
|
||||
self.assertNotIn(words, text)
|
||||
miyoo = help_text("miyoo", "systems")
|
||||
for words in ("Start, Enter", "A, Space, Right/Left", "R2, Esc, Backspace",
|
||||
"L2, Tab, o", "Sends no key here"):
|
||||
@@ -1444,13 +1454,14 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
|
||||
mock.patch.object(sys, "stderr", out):
|
||||
code = install.run_tui(_args())
|
||||
self.assertIs(sys.stdout, out)
|
||||
self.assertEqual(code, 1)
|
||||
# A window that answers EIO is a closed window: its signal's code,
|
||||
# not a crash.
|
||||
self.assertEqual(code, 129)
|
||||
# The farewell found no terminal: what still flushes at exit goes to
|
||||
# the null device, and the exit code stays the one returned.
|
||||
self.assertEqual(os.fstat(screen_fd).st_rdev, os.stat(os.devnull).st_rdev)
|
||||
# The crash keeps its log, marked kept, and nothing else of the run.
|
||||
self.assertEqual(sorted(path.name for path in work.iterdir()),
|
||||
["error.log", "kept", "owner"])
|
||||
# No crash log is kept, and nothing else of the run.
|
||||
self.assertFalse(work.exists())
|
||||
self.assertEqual(termios.tcgetattr(slave), before)
|
||||
for number, handler in handlers.items():
|
||||
self.assertEqual(signal.getsignal(number), handler)
|
||||
@@ -1800,6 +1811,31 @@ class WorkFolders(unittest.TestCase):
|
||||
work, out = self.run_to(130)
|
||||
self.assertFalse(work.exists())
|
||||
|
||||
def test_a_full_temp_disk_or_a_failing_pruner_still_opens(self):
|
||||
real_write = Path.write_text
|
||||
|
||||
def full(path, *args, **kwargs):
|
||||
if path.name == install.TUI_OWNER:
|
||||
raise OSError(errno.ENOSPC, "No space left on device")
|
||||
return real_write(path, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(Path, "write_text", full):
|
||||
work, out = self.run_to(0)
|
||||
self.assertFalse(work.exists())
|
||||
with mock.patch.object(install, "_prune_work_folders",
|
||||
side_effect=OSError(errno.EACCES, "denied")):
|
||||
work, out = self.run_to(0)
|
||||
self.assertFalse(work.exists())
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(install.tempfile, "mkdtemp",
|
||||
side_effect=OSError(errno.ENOSPC, "No space left")), \
|
||||
mock.patch.object(install, "tui_start", return_value=(fresh_state(), [])), \
|
||||
mock.patch.object(install, "_open_local_sources"), \
|
||||
mock.patch.object(install, "detect_os", return_value="linux"), \
|
||||
mock.patch.object(sys, "stderr", err):
|
||||
self.assertEqual(install.run_tui(_args()), 1)
|
||||
self.assertIn("no work folder could be made", err.getvalue())
|
||||
|
||||
def test_a_stop_while_it_starts_ends_on_the_farewell(self):
|
||||
"""Start+Select during the detection: the signal is the interface's,
|
||||
never the default action that ended Python without a word."""
|
||||
@@ -2095,6 +2131,33 @@ class AuditRoundThree(unittest.TestCase):
|
||||
self.assertEqual(install.card_dest("retroarch", "retroarch", root),
|
||||
("retroarch", root))
|
||||
|
||||
def test_p1_1_the_folder_named_is_compared_by_folders(self):
|
||||
"""NOTMUOS/bios ended with MUOS/bios as text and was taken for a
|
||||
muOS card's folder."""
|
||||
root = self.card({"roms/": ""})
|
||||
stray = root / "NOTMUOS" / "bios"
|
||||
with self.assertRaises(SystemExit), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as err:
|
||||
install.card_dest("muos", "retroarch", stray)
|
||||
self.assertIn("is not a muOS card", err.getvalue())
|
||||
self.assertEqual(install.card_dest("muos", "retroarch", root / "muos" / "BIOS"),
|
||||
("retroarch", root / "muos" / "BIOS"))
|
||||
|
||||
def test_p1_1_a_card_two_systems_claim_says_so(self):
|
||||
root = self.card({"MUOS/": "", "spruce/": ""})
|
||||
self.assertIsNone(install.card_system(root))
|
||||
with contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
self.assertEqual(install.card_dest("retroarch", "retroarch", root),
|
||||
("retroarch", root))
|
||||
lines = out.getvalue().splitlines()
|
||||
self.assertEqual(len(lines), 1, lines)
|
||||
self.assertIn("holds the files of muOS and spruceOS", lines[0])
|
||||
# The system named among them decides.
|
||||
with contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
self.assertEqual(install.card_dest("muos", "retroarch", root),
|
||||
("retroarch", root / "MUOS/bios"))
|
||||
self.assertIn("muOS card", out.getvalue())
|
||||
|
||||
# P1-2: Space is X on PortMaster and Y on muxterm.
|
||||
|
||||
def test_p1_2_space_on_install_opens_the_confirm_screen(self):
|
||||
@@ -2455,12 +2518,147 @@ class AuditRoundThree(unittest.TestCase):
|
||||
unknown_old = folder("unknown-old", None, False, 2 * week)
|
||||
other = root / "something-else"
|
||||
other.mkdir()
|
||||
# A stray or hostile owner file is an unknown owner: a number past
|
||||
# what a pid holds raised OverflowError, and 0 or -1 name a group.
|
||||
hostile = [folder(f"hostile{index}", None, False, 60)
|
||||
for index in range(3)]
|
||||
for path, text in zip(hostile, ("9" * 40, "0", "-1")):
|
||||
(path / install.TUI_OWNER).write_text(text)
|
||||
self.assertIsNone(install._owner_alive(path))
|
||||
removed = install._prune_work_folders(root, now)
|
||||
self.assertEqual(sorted(removed), sorted([killed, failed_old, unknown_old]))
|
||||
for path in hostile:
|
||||
shutil.rmtree(path)
|
||||
self.assertEqual(sorted(path.name for path in root.iterdir()),
|
||||
sorted(path.name for path in (running, failed, unknown, other)))
|
||||
|
||||
|
||||
class IdleExit(unittest.TestCase):
|
||||
def test_the_variable(self):
|
||||
for value, seconds in (("30", 30.0), (" 2.5 ", 2.5), ("", None), ("0", None),
|
||||
("-4", None), ("soon", None), ("inf", None), ("nan", None)):
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(install.tui_idle_exit({install.TUI_IDLE_ENV: value}),
|
||||
seconds)
|
||||
self.assertIsNone(install.tui_idle_exit({}))
|
||||
|
||||
def test_the_farewell(self):
|
||||
state = fresh_state()
|
||||
state.idle_closed, state.exit_code = True, 0
|
||||
self.assertEqual(install.tui_farewell(state),
|
||||
["RetroBIOS: No button pressed, closing."])
|
||||
|
||||
|
||||
class AuditRoundFour(unittest.TestCase):
|
||||
"""tui_audit_3.md, each finding held where it was found."""
|
||||
|
||||
UNPLACED = {"reason": "no_save_directory", "label": "no save directory", "files": 2,
|
||||
"required": 0}
|
||||
|
||||
def test_p1_1_files_left_out_of_the_save_directory_are_said(self):
|
||||
state = fresh_state()
|
||||
state.stack = ["setup"]
|
||||
with mock.patch.object(install.Path, "exists", return_value=True):
|
||||
install.tui_update(state, Key("enter"))
|
||||
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
|
||||
summary = {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 0,
|
||||
"pending": 5, "failed": [], "skipped": [self.UNPLACED], "left_out": [],
|
||||
"kept": [], "interrupted": False}
|
||||
for event in [*FILES, *SYSTEMS, CHECK, summary]:
|
||||
install.tui_receive(state, ("event", state.run_id, event))
|
||||
with mock.patch.object(install, "_tui_folder_facts", return_value={
|
||||
"free": 50 * 1024 ** 3, "writable": True, "cores": []}):
|
||||
install.tui_receive(state, ("ended", state.run_id, 1, []))
|
||||
line = ("2 files read from RetroArch's save directory left out: its place is "
|
||||
"not known. Name it with --save-dir.")
|
||||
plan = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
|
||||
self.assertIn(line, plan)
|
||||
state.stack.append("result")
|
||||
state.code = 0
|
||||
state.summary = {**summary, "mode": "install", "installed": 5}
|
||||
self.assertIn(line, install.tui_outcome(state)[1])
|
||||
|
||||
|
||||
class OfflineInTheInterface(unittest.TestCase):
|
||||
"""--offline in the full screen: a "No connection" error offering a Try
|
||||
again that can never work, and a plan counting downloads the run
|
||||
forbids."""
|
||||
|
||||
def setUp(self):
|
||||
patcher = mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"})
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_no_list_names_the_ways_that_work(self):
|
||||
inbox = queue.Queue()
|
||||
install._tui_fetch("retroarch", inbox, io.StringIO())
|
||||
kind, plat, title, lines, code = inbox.get_nowait()
|
||||
self.assertEqual((kind, title, code), ("manifest_failed",
|
||||
"No internet: offline mode", 1))
|
||||
text = " ".join(lines)
|
||||
for words in ("--manifest", "--from-pack", "without --offline"):
|
||||
self.assertIn(words, text)
|
||||
state = fresh_state()
|
||||
state.stack = ["setup"]
|
||||
with mock.patch.object(install.Path, "exists", return_value=True):
|
||||
install.tui_update(state, Key("enter"))
|
||||
install.tui_receive(state, (kind, plat, title, lines, code))
|
||||
self.assertEqual(state.screen, "error")
|
||||
self.assertNotIn("retry", [row.action for row in install.tui_rows(state)])
|
||||
view = "\n".join(install.tui_view(state, 80, 24))
|
||||
self.assertIn("No internet: offline mode", view)
|
||||
self.assertNotIn("Try again", view)
|
||||
|
||||
def test_the_plan_offers_no_download(self):
|
||||
state = checked_state()
|
||||
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
|
||||
self.assertNotIn("to download", text)
|
||||
self.assertIn("need the internet, which --offline does not use", text)
|
||||
self.assertNotIn("install", [row.action for row in install.tui_rows(state)])
|
||||
# A file found on this machine can still be copied.
|
||||
state.found = [{"event": "found", "platform": "retroarch", "dest": FILES[0]["dest"],
|
||||
"folder": "/media/old", "path": "/media/old/scph5500.bin",
|
||||
"member": "", "reason": ""}]
|
||||
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
|
||||
self.assertIn("1 found in old,", text)
|
||||
self.assertIn("need the internet (--offline)", text)
|
||||
self.assertEqual(install.tui_rows(state)[0].action, "install")
|
||||
self.assertIn("--offline", install.tui_command(state))
|
||||
|
||||
|
||||
class NarrowSystemNames(unittest.TestCase):
|
||||
"""At 40 columns the size column took half the line: Game Boy, Game Boy
|
||||
Advance and Game Boy Color all read "Nintendo Gam"."""
|
||||
|
||||
def test_the_name_comes_before_the_size(self):
|
||||
state = checked_state()
|
||||
names = {"nintendo-gb": "Nintendo Game Boy", "nintendo-gba": "Nintendo Game Boy Advance",
|
||||
"nintendo-gbc": "Nintendo Game Boy Color",
|
||||
"videoton-tvc": "Videoton TV Computer"}
|
||||
state.manifest = {**state.manifest, "system_titles": names}
|
||||
state.systems = [
|
||||
{"event": "system", "platform": "retroarch", "system": system, "name": system,
|
||||
"files": 30, "present": 0, "missing": 30, "wrong_hash": 0,
|
||||
"bytes_needed": 30 * 1024 * 1024}
|
||||
for system in names]
|
||||
state.files = [{"event": "file", "platform": "retroarch", "dest": f"s{n}.bin",
|
||||
"systems": list(names), "size": 1024, "state": "missing"}
|
||||
for n in range(27)]
|
||||
state.ticked = set(names)
|
||||
state.stack.append("systems")
|
||||
for cols, rows in ((40, 15), (40, 12), (52, 15)):
|
||||
with self.subTest(size=f"{cols}x{rows}"):
|
||||
text = "\n".join(install.tui_view(state, cols, rows))
|
||||
for name in names.values():
|
||||
self.assertIn(name, text)
|
||||
self.assertNotIn("...", text)
|
||||
for line in text.splitlines():
|
||||
self.assertLessEqual(install.cell_width(line), cols)
|
||||
# Wide, the full form stays.
|
||||
self.assertIn("3 files, 30.0 MB + 27 shared",
|
||||
"\n".join(install.tui_view(state, 120, 40)))
|
||||
|
||||
|
||||
class Crash(unittest.TestCase):
|
||||
"""An unexpected error ends on one sentence and a log, never a traceback."""
|
||||
|
||||
@@ -2620,6 +2818,29 @@ class GameDataRepository(FakeRepository):
|
||||
return self
|
||||
|
||||
|
||||
class SaveDirectoryRepository(FakeRepository):
|
||||
"""The fake repository with one file read from RetroArch's save
|
||||
directory, as Azahar's keys.txt and 105 others of the real list."""
|
||||
|
||||
KEYS = b"azahar keys"
|
||||
|
||||
def __enter__(self) -> SaveDirectoryRepository:
|
||||
super().__enter__()
|
||||
path = self.root / "install" / "retroarch.json"
|
||||
manifest = json.loads(path.read_text())
|
||||
(self.root / "bios" / "keys.txt").write_bytes(self.KEYS)
|
||||
manifest["files"].append({
|
||||
"dest": "Azahar/Azahar/sysdata/keys.txt", "root": "save",
|
||||
"core_folder": "Azahar", "sha1": hashlib.sha1(self.KEYS).hexdigest(),
|
||||
"sha256": hashlib.sha256(self.KEYS).hexdigest(), "size": len(self.KEYS),
|
||||
"repo_path": "bios/keys.txt", "cores": ["azahar"],
|
||||
"systems": ["nintendo-3ds"]})
|
||||
manifest["save_directory"] = {"folder": "saves", "by_core": True}
|
||||
manifest["base_destination"] = "system"
|
||||
path.write_text(json.dumps(manifest))
|
||||
return self
|
||||
|
||||
|
||||
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
|
||||
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
|
||||
class EndToEnd(unittest.TestCase):
|
||||
@@ -2671,6 +2892,49 @@ class EndToEnd(unittest.TestCase):
|
||||
# The cursor and the main screen come back before the result is printed.
|
||||
self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw)
|
||||
|
||||
def test_the_save_directory_is_the_one_line_mode_finds(self):
|
||||
"""The interface named the BIOS folder with --dest, so its runs never
|
||||
read retroarch.cfg: keys.txt went to a guessed RetroArch/saves, or
|
||||
was dropped without a word, where line mode writes it to the
|
||||
savefile_directory RetroArch reads."""
|
||||
import subprocess
|
||||
|
||||
def written(repo, saves: Path) -> list[str]:
|
||||
return sorted([*repo.installed(), *(
|
||||
"saves/" + p.relative_to(saves).as_posix()
|
||||
for p in saves.rglob("*") if p.is_file())])
|
||||
|
||||
results = []
|
||||
for mode in ("line", "interface"):
|
||||
with self.subTest(mode=mode):
|
||||
repo = self.repo(SaveDirectoryRepository)
|
||||
home = Path(self.env["HOME"])
|
||||
saves = home / "MySaves"
|
||||
saves.mkdir()
|
||||
cfg = home / ".config" / "retroarch" / "retroarch.cfg"
|
||||
cfg.write_text(f'system_directory = "{repo.dest}"\n'
|
||||
f'savefile_directory = "{saves}"\n')
|
||||
if mode == "line":
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform",
|
||||
"retroarch", "--yes"], env=self.env, capture_output=True,
|
||||
text=True, timeout=60, check=False, stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("6 files installed", proc.stdout)
|
||||
else:
|
||||
term = self.start()
|
||||
term.wait("Where do you play?")
|
||||
term.send("\r")
|
||||
screen = term.wait("files to download")
|
||||
self.assertIn("6 files to download", screen)
|
||||
term.send("\r")
|
||||
term.wait("6 files installed.")
|
||||
term.send("\r")
|
||||
self.assertEqual(term.close(), 0)
|
||||
results.append(written(repo, saves))
|
||||
self.assertIn("saves/Azahar/Azahar/sysdata/keys.txt", results[-1])
|
||||
self.assertEqual(results[0], results[1])
|
||||
|
||||
def test_one_system_from_the_checklist(self):
|
||||
repo = self.repo()
|
||||
term = self.start("--platform", "retroarch", "--dest", str(repo.dest), cols=40, rows=15)
|
||||
@@ -2859,6 +3123,51 @@ class EndToEnd(unittest.TestCase):
|
||||
self.assertTrue(os.WIFEXITED(status))
|
||||
self.assertEqual(term.status, 129)
|
||||
|
||||
def test_closing_the_window_while_it_installs(self):
|
||||
"""The changing screen fails to draw on the closed window: that is
|
||||
the window's signal, not a crash with a log kept."""
|
||||
repo = self.repo(GameDataRepository, slow=True)
|
||||
scratch = repo.root / "scratch"
|
||||
scratch.mkdir()
|
||||
self.env["TMPDIR"] = str(scratch)
|
||||
term = self.start("--platform", "retroarch", "--dest", str(repo.dest), "--jobs", "1")
|
||||
term.wait("> Install")
|
||||
term.send("\r")
|
||||
term.wait("1 of 6 files")
|
||||
os.close(term.fd)
|
||||
end = time.monotonic() + 20
|
||||
while time.monotonic() < end:
|
||||
pid, status = os.waitpid(term.pid, os.WNOHANG)
|
||||
if pid:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
os.kill(term.pid, 9)
|
||||
self.fail("still running once its window closed")
|
||||
term.status = os.WEXITSTATUS(status)
|
||||
self.assertEqual(term.status, 129)
|
||||
self.assertEqual(list(scratch.glob("retrobios-tui-*/error.log")), [])
|
||||
|
||||
def test_no_key_on_the_first_screen_closes_it(self):
|
||||
"""A mapper that never opens the pad left the first screen up for
|
||||
good: a handheld then has no way out but the power button."""
|
||||
self.repo()
|
||||
self.env[install.TUI_IDLE_ENV] = "1"
|
||||
term = self.start()
|
||||
term.wait("Where do you play?")
|
||||
self.assertEqual(term.close(), 0)
|
||||
self.assertIn("No button pressed, closing.", term.after())
|
||||
# A key pressed, the screen stays: the person is there.
|
||||
term = self.start()
|
||||
term.wait("Where do you play?")
|
||||
term.send(DOWN)
|
||||
end = time.monotonic() + 2.5
|
||||
while time.monotonic() < end:
|
||||
term._pump(0.05)
|
||||
self.assertEqual(os.waitpid(term.pid, os.WNOHANG), (0, 0))
|
||||
term.send("q")
|
||||
self.assertEqual(term.close(), 130)
|
||||
|
||||
def test_a_desktop_names_its_keys_and_a_pad_its_buttons(self):
|
||||
self.repo()
|
||||
for env, bar in (({"DISPLAY": ":0"}, "Enter Continue Esc Quit ? Help"),
|
||||
|
||||
@@ -83,7 +83,7 @@ It opens a menu:
|
||||
- **Wrong folder?** Choose **Another setup or folder**, then browse to yours.
|
||||
- **A handheld's SD card in a reader?** It is listed, as **muOS SD card**.
|
||||
|
||||
Arrows move, Enter chooses, Esc goes back. A controller's A and B do the same.
|
||||
Arrows move, Enter chooses, Esc goes back. A handheld's A and B do the same.
|
||||
|
||||
## 3. Install
|
||||
|
||||
@@ -142,8 +142,12 @@ The message shows the command that installs Python. Run it, then run the
|
||||
installer again.
|
||||
|
||||
A RetroPie 4.8 image has Python 3.7.3, and its update command finds nothing
|
||||
newer. Run the command on a computer, with
|
||||
`--platform retropie --dest \\RETROPIE\bios\`.
|
||||
newer. Run it on a computer, the `bios` share open:
|
||||
|
||||
<!-- os-tabs windows: --platform retropie --dest \\RETROPIE\bios\ -->
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --platform retropie --dest <the bios share>
|
||||
```
|
||||
|
||||
### Error: the downloaded installer does not match its expected fingerprint.
|
||||
|
||||
|
||||
+27
-10
@@ -26,9 +26,9 @@ How the command protects your machine is under
|
||||
Each links to its row in the [Options](#options) table.
|
||||
|
||||
- **Where and for what**: [`--platform`](#option-platform), [`--dest`](#option-dest), [`--target`](#option-target), [`--every-board`](#option-every-board), [`--save-dir`](#option-save-dir), [`--romm-firmware`](#option-romm-firmware), [`--standalone-copies`](#option-standalone-copies)
|
||||
- **Fewer files**: [`--system`](#option-system), [`--core`](#option-core), [`--emulator`](#option-emulator), [`--region`](#option-region), [`--no-game-data`](#option-no-game-data), [`--installed-cores`](#option-installed-cores), [`--cores-dir`](#option-cores-dir)
|
||||
- **Fewer files**: [`--system`](#option-system), [`--core`](#option-core), [`--emulator`](#option-emulator), [`--region`](#option-region), [`--no-game-data`](#option-no-game-data), [`--installed-cores`](#option-installed-cores), [`--cores-dir`](#option-cores-dir), [`--declared-only`](#option-declared-only)
|
||||
- **Look first**: [`--check`](#option-check), [`--dry-run`](#option-dry-run), [`--list-platforms`](#option-list-platforms), [`--list-targets`](#option-list-targets), [`--list-systems`](#option-list-systems), [`--list-cores`](#option-list-cores), [`--list-emulators`](#option-list-emulators)
|
||||
- **From files you have**: [`--from`](#option-from), [`--from-pack`](#option-from-pack), [`--manifest`](#option-manifest), [`--restore`](#option-restore)
|
||||
- **From files you have**: [`--from`](#option-from), [`--from-pack`](#option-from-pack), [`--manifest`](#option-manifest), [`--offline`](#option-offline), [`--restore`](#option-restore)
|
||||
- **How the run talks**: [`--tui`](#option-tui), [`--plain`](#option-plain), [`--no-input`](#option-no-input), [`--yes`](#option-yes), [`--quiet`](#option-quiet), [`--verbose`](#option-verbose), [`--events`](#option-events), [`--jobs`](#option-jobs), [`--version`](#option-version), [`--help`](#option-help)
|
||||
|
||||
## One line
|
||||
@@ -232,7 +232,8 @@ below the BIOS folder: Azahar's keys, shared font and NAND files, Citra's
|
||||
`sysdata`, Dolphin's `User` tree. The file list marks them `root: save`, and
|
||||
the run writes them below the save directory, which it names before checking
|
||||
files (`5 files read from RetroArch's save directory go to ...`). For a
|
||||
RetroArch found by its `retroarch.cfg`, that is `savefile_directory` as
|
||||
RetroArch found by its `retroarch.cfg` (with `--dest` too, when the folder is
|
||||
the config's `system_directory`, as in the full screen), that is `savefile_directory` as
|
||||
RetroArch expands it (`:` for its own folder, `~` for the home), `saves/`
|
||||
beside the config when the value is `default`, empty, or names no folder, and
|
||||
the core's own folder below it when `sort_savefiles_enable` is on, as it is by
|
||||
@@ -242,7 +243,7 @@ the save directory beside it (`saves`, `/storage/savefiles` on Lakka).
|
||||
applies, or when `savefiles_in_content_dir` or
|
||||
`sort_savefiles_by_content_enable` keeps saves beside each game, those files
|
||||
are left out and the run says why, like the other entries the list cannot
|
||||
install.
|
||||
install. The full screen says so on its plan and its result.
|
||||
|
||||
Batocera, KNULLI, Recalbox, RetroBat and RetroDECK write the save directory at
|
||||
each launch, one folder per system (`by_system` in the file list). Their lists
|
||||
@@ -1066,7 +1067,9 @@ muxterm, as the muOS app does.
|
||||
Start pressed first also chooses the line in focus: hold Select first. A
|
||||
running install is then stopped the way B stops it, and the last screen says
|
||||
what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y
|
||||
ticks a system, Select shows its keyboard, and Menu quits.
|
||||
ticks a system, Select shows its keyboard, and Menu quits. L1 and R1 do not
|
||||
page there: muxterm keeps PgUp and PgDn for its own scrollback, so a list
|
||||
moves with the D-pad.
|
||||
|
||||
Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed
|
||||
on its first screen for 5 minutes, the interface closes by itself. A pad the
|
||||
@@ -1083,7 +1086,12 @@ Each launcher tells the interface which buttons reach it, in
|
||||
| `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port |
|
||||
| `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet |
|
||||
|
||||
A launcher with no mapper at all (a keyboard) leaves it unset. On a Miyoo, B,
|
||||
A launcher with no mapper at all (a keyboard) leaves it unset.
|
||||
A launcher can also set `RETROBIOS_IDLE_EXIT` to a number of seconds: when
|
||||
no button reaches the first screen for that long, before any choice, the
|
||||
interface closes with `No button pressed, closing.` and exit status 0. A
|
||||
mapper that never opened the pad would otherwise hold the device on that
|
||||
screen. On a Miyoo, B,
|
||||
X, Y and Select are Left Ctrl, Shift, Alt and Right Ctrl: pressed alone, a
|
||||
modifier sends nothing to a terminal, so the interface goes back with R2
|
||||
instead, and the bottom line says so.
|
||||
@@ -1240,13 +1248,15 @@ 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, `--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, or `--restore` could not put a file back |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), 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`. The message then starts with `install.py: error:`. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), 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. The message then starts with `install.py: error:`. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
|
||||
| `130` | stopped with Ctrl+C, or with `q`, 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-platforms \| head`) |
|
||||
|
||||
The full screen ended by SIGTERM (Start+Select on a handheld) before any
|
||||
install exits `143`. During an install it stops the install first, and exits
|
||||
`130`.
|
||||
`130`. Its window or terminal closed (SIGHUP) exits `129`, during an install
|
||||
too: the install is stopped as Ctrl+C stops it, and the last lines go
|
||||
nowhere, no one being left to read them.
|
||||
|
||||
`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
|
||||
@@ -1298,7 +1308,9 @@ other schemas under `api/v1/schemas/`.
|
||||
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`), never a key of an
|
||||
existing kind: a reader skips a kind it does not know. An option refused
|
||||
before the run starts (`install.py: error:`, code 2) writes
|
||||
no event at all. A program reads a missing `end` as that case.
|
||||
no event at all. A program reads a missing `end` as that case, or as a
|
||||
crash: an error the installer did not expect ends the process on a Python
|
||||
traceback, on standard error, without an `end` line.
|
||||
|
||||
Five lines of a run of `--check --events --system sony-playstation` on a
|
||||
RetroArch folder that holds only `scph5501.bin`, in the order written:
|
||||
@@ -1327,6 +1339,8 @@ RetroArch folder that holds only `scph5501.bin`, in the order written:
|
||||
| `FORCE_COLOR`, `CLICOLOR_FORCE` | Colour even when the output is a pipe or a file, set to any value but `0` or an empty one. `NO_COLOR` wins over both, and the progress line stays one line per file |
|
||||
| `SSL_CERT_FILE`, `SSL_CERT_DIR` | The trusted certificates to check servers with, for a Python that finds none of its own |
|
||||
| `RETROBIOS_PLAIN`, `ACCESSIBLE` | Set to anything but `0`: the questions come line by line, as with `--plain`, instead of the full-screen interface |
|
||||
| `RETROBIOS_PAD` | The buttons a handheld launcher's mapper sends to the full screen (`gptokeyb`, `muxterm`, `evmapy`, `miyoo`), so its hints name them. See [On a handheld](#on-a-handheld-the-retrobios-port) |
|
||||
| `RETROBIOS_IDLE_EXIT` | Seconds the full screen's first screen waits for a first button. With none pressed, it closes with exit status 0 |
|
||||
| `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 |
|
||||
|
||||
## When something goes wrong
|
||||
@@ -1341,7 +1355,10 @@ The installer fetches its manifest, then downloads only the repository or
|
||||
`https://github.com` opens. Behind a proxy, set `HTTPS_PROXY` in the
|
||||
environment. A connection that does not open within 4 seconds counts as
|
||||
none, so a dropped network gives its error in about 15 seconds. Once a
|
||||
connection is open, a slow transfer keeps the full timeout. A file that fails three attempts is counted and reported at the
|
||||
connection is open, a slow transfer keeps the full timeout. The file list
|
||||
is the exception: a server that takes the connection and then sends nothing
|
||||
(a Wi-Fi sign-in page, a stuck proxy) gets 15 seconds per wait and two
|
||||
attempts, about 30 seconds in all. A file that fails three attempts is counted and reported at the
|
||||
end, and the next run fetches only what is still missing.
|
||||
|
||||
The reason is said in words: no internet connection or a name that could not
|
||||
|
||||
Reference in new issue
Block a user