diff --git a/install.ps1 b/install.ps1 index 16e613e2..c0ed4e27 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" + $defaultInstallSha256 = "9fbee0f382ca68880697ed573b79ecc1ffca793a406a3f258dad16e40d2845d6" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 9cd3e52e..68bcb081 100644 --- a/install.py +++ b/install.py @@ -41,7 +41,7 @@ import xml.etree.ElementTree as ET import zipfile from collections.abc import Callable, Iterator from pathlib import Path, PurePosixPath -from typing import BinaryIO, TextIO +from typing import BinaryIO, NamedTuple, TextIO # Manifests are read from the same ref the bootstrap verified this installer # against, so a file list and the code reading it always come from one commit. @@ -481,7 +481,7 @@ def detect_os() -> str: def _parse_os_release() -> dict[str, str]: """Parse /etc/os-release KEY=value format.""" result: dict[str, str] = {} - path = Path("/etc/os-release") + path = _on_device("/etc/os-release") if not path.exists(): return result try: @@ -535,7 +535,7 @@ def _enter_wsl() -> None: os.environ[variable] = str(_host_path(value)) -def _expand_retroarch_path(value: str, app_dir: Path) -> Path: +def _expand_retroarch_path(value: str, app_dir: Path, home: Path | None = None) -> Path: """Expand the notations RetroArch writes into its config values. fill_pathname_expand_special (libretro-common/file/file_path.c) maps a @@ -543,14 +543,14 @@ def _expand_retroarch_path(value: str, app_dir: Path) -> Path: directory, dropping the two leading characters in both cases. """ if value[:1] == "~": - return Path(str(Path.home())) / value[2:] + return (home or Path(str(Path.home()))) / value[2:] if value[:1] == ":": return app_dir / value[2:] return _host_path(os.path.expandvars(os.path.expanduser(value))) def _retroarch_cfg_dir( - cfg_path: Path, key: str, app_dir: Path, default: str + cfg_path: Path, key: str, app_dir: Path, default: str, home: Path | None = None ) -> Path | None: """A directory setting of retroarch.cfg, as RetroArch expands it. @@ -569,7 +569,7 @@ def _retroarch_cfg_dir( value = value.strip().strip('"').strip("'") if not value or value == "default": return app_dir / default - return _expand_retroarch_path(value, app_dir) + return _expand_retroarch_path(value, app_dir, home) return None @@ -661,18 +661,19 @@ _RETROARCH_CONFIGS: dict[Path, tuple[Path, Path]] = {} def _parse_retroarch_system_dir( - cfg_path: Path, app_dir: Path | None = None + cfg_path: Path, app_dir: Path | None = None, home: Path | None = None ) -> Path | None: """Parse system_directory from retroarch.cfg. app_dir is RetroArch's application directory, used to expand ':' values. It defaults to the directory holding the config, which is where a portable - install keeps both. + install keeps both. home is what '~' means to that RetroArch, the user's + home when not given. """ if not cfg_path.exists(): return None app_dir = app_dir or cfg_path.parent - system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system") + system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system", home) if system_dir is not None: _RETROARCH_CONFIGS.setdefault(system_dir, (cfg_path, app_dir)) return system_dir @@ -817,70 +818,345 @@ def _parse_ps1_var(path: Path, key: str) -> str | None: return None -# Embedded systems, first match wins. The registry publishes the same rules -# as each manifest's `detect`, and a test holds the two equal. MiSTer is a -# path rule below. -_EMBEDDED_RULES: tuple[tuple[str, str, tuple[str, ...], str], ...] = ( - # ROCKNIX writes OS_NAME and no ID since 2025 (scripts/image). Older - # images wrote ID=rocknix. - ("rocknix", "os_release", ("OS_NAME=rocknix", "ID=rocknix"), "/storage/roms/bios"), - # Knulli (knulli-linux 57afa662): OS_NAME in os-release - # (board/scripts/post-build-script.sh:233-236) and knulli.version - # (package/system/knulli-system/knulli-system.mk:98-102). - ("batocera", "os_release", ("OS_NAME=knulli",), "/userdata/bios"), - ("batocera", "file_exists", ("/usr/share/knulli/knulli.version",), "/userdata/bios"), - ("lakka", "os_release", ("ID=lakka",), "/storage/system"), - # batocera.linux 38845725, batocera-system.mk:21-22 (the same lines in - # batocera-30): batocera.arch and batocera.version. - ("batocera", "file_exists", ("/usr/share/batocera/batocera.version",), "/userdata/bios"), - ("recalbox", "file_exists", ("/recalbox/recalbox.version",), "/recalbox/share/bios"), - ("recalbox", "file_exists", ("/usr/bin/recalbox-settings",), "/recalbox/share/bios"), - ("retroarch", "file_exists", ("/opt/muos",), "/run/muos/storage/bios"), - ("retroarch", "file_exists", ("/mnt/mmc/MUOS",), "/mnt/mmc/MUOS/bios"), - ("retroarch", "file_exists", ("/home/ark", "/opt/system"), "/roms/bios"), - ("retroarch", "file_exists", ("/mnt/vendor/bin/dmenu.bin",), "/mnt/mmc/bios"), -) +# The root the embedded rules read under: "/" on a device, a folder holding a +# copy of one in the tests. +_SYSTEM_ROOT = Path("/") -def _detect_embedded() -> list[tuple[str, Path]]: - """Check for embedded Linux retrogaming OSes.""" - release = {k: v.lower() for k, v in _parse_os_release().items()} - for platform_name, method, values, bios in _EMBEDDED_RULES: - if method == "os_release": - # Any of the KEY=value pairs, the value compared without case. - matched = any( - release.get(key) == value.lower() - for key, _, value in (pair.partition("=") for pair in values) - ) - else: - matched = all(Path(value).exists() for value in values) - if matched: - return [(platform_name, Path(bios))] - return [] +def _on_device(path: str) -> Path: + """A path of the running system, read under _SYSTEM_ROOT.""" + if _SYSTEM_ROOT == Path("/"): + return Path(path) + return _SYSTEM_ROOT / path.lstrip("/") +class _OsRule(NamedTuple): + """One piece of evidence that names an embedded system. + + manifest is the platform whose files it gets, "" for a system the + installer does not lay out. name is the system's own name. values are the + KEY=value pairs of os_release (any one), the files of file_exists (all), + or the file and the pattern of file_matches. bios is the folder used when + none of configs (retroarch.cfg files, read in order, `*` allowed) names a + system_directory. home is what `~` means to that system's RetroArch. + base is the system this one is built on: both match, this one is meant. + """ + + manifest: str + name: str + method: str + values: tuple[str, ...] + bios: str | None + configs: tuple[str, ...] = () + home: str = "" + base: str = "" + + +# Systems whose own platform is not published yet. Their files come from the +# platform whose core set they ship, and this is the one place that changes +# when the manifest of their own is published. +_STANDIN_MANIFESTS = { + "AmberELEC": "retroarch", + "EmuELEC": "retroarch", + "KNULLI": "batocera", +} + +# Where the ELEC family keeps RetroArch: the config runemu writes and copies +# back on every launch, and HOME=/storage for the services that start it. +_ELEC_CONFIGS = ("/storage/.config/retroarch/retroarch.cfg",) +# ArkOS and dArkOS: the 64-bit and the 32-bit RetroArch. "Switch to SD2 for +# Roms" rewrites /roms/ to /roms2/ in both (darkos daecf488, +# scripts/Switch to SD2 for Roms.sh:91-92), so a second card is read here. +_ARK_CONFIGS = ("/home/ark/.config/retroarch/retroarch.cfg", + "/home/ark/.config/retroarch32/retroarch.cfg") +_PLYMOUTH_TITLE = "/usr/share/plymouth/themes/text.plymouth" + # muOS runs RetroArch on the config it copies from its default on first launch # (internal f41f4a89, script/control/retroarch.sh:8-20). The default names # /run/muos/storage/bios, a bind mount of MUOS/bios on the second card when # that card holds one, else on the first (script/device/bind.sh:4,116-150), # and loads cores from /opt/muos/share/core (retroarch.default.cfg:2806,3151). -MUOS_ROOT = Path("/opt/muos") MUOS_RETROARCH_CONFIGS = ( - MUOS_ROOT / "share" / "info" / "config" / "retroarch.cfg", - MUOS_ROOT / "share" / "emulator" / "retroarch" / "retroarch.default.cfg", + "/opt/muos/share/info/config/retroarch.cfg", + "/opt/muos/share/emulator/retroarch/retroarch.default.cfg", ) +# Embedded systems, by what their own builds write. The registry publishes the +# same rules as each manifest's `detect`, and a test holds the two equal. +# MiSTer is a path rule below. +_EMBEDDED_RULES: tuple[_OsRule, ...] = ( + # ROCKNIX writes OS_NAME and no ID since 2025 (scripts/image). Older + # images wrote ID=rocknix. + _OsRule("rocknix", "ROCKNIX", "os_release", ("OS_NAME=rocknix", "ID=rocknix"), + "/storage/roms/bios"), + # Knulli (knulli-linux 57afa662): OS_NAME in os-release + # (board/scripts/post-build-script.sh:233-236) and knulli.version + # (package/system/knulli-system/knulli-system.mk:98-102). + _OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "os_release", ("OS_NAME=knulli",), + "/userdata/bios", base="Batocera"), + _OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_exists", + ("/usr/share/knulli/knulli.version",), "/userdata/bios", base="Batocera"), + _OsRule("lakka", "Lakka", "os_release", ("ID=lakka",), "/storage/system"), + # batocera.linux 38845725, batocera-system.mk:21-22 (the same lines in + # batocera-30): batocera.arch and batocera.version. + _OsRule("batocera", "Batocera", "file_exists", ("/usr/share/batocera/batocera.version",), + "/userdata/bios"), + _OsRule("recalbox", "Recalbox", "file_exists", ("/recalbox/recalbox.version",), + "/recalbox/share/bios"), + _OsRule("recalbox", "Recalbox", "file_exists", ("/usr/bin/recalbox-settings",), + "/recalbox/share/bios"), + _OsRule("retroarch", "muOS", "file_exists", ("/opt/muos",), "/run/muos/storage/bios", + configs=MUOS_RETROARCH_CONFIGS), + _OsRule("retroarch", "muOS", "file_exists", ("/mnt/mmc/MUOS",), "/mnt/mmc/MUOS/bios"), + # AmberELEC 5a3882cb: scripts/image:150-152 writes NAME="${DISTRONAME}", + # AmberELEC in distributions/AmberELEC/options:17. CHAos (tech4bot/AmberELEC + # c0a4b0c6) is built from the same tree and writes the same name + # (distributions/AmberELEC/options:14). runemu.py:22 launches with + # /storage/.config/retroarch/retroarch.cfg, whose default is "~/roms/bios" + # (packages/games/tools/retroarch/sources/retroarch.cfg:611), and + # emustation.service:6 sets HOME=/storage. + _OsRule(_STANDIN_MANIFESTS["AmberELEC"], "AmberELEC", "os_release", ("NAME=AmberELEC",), + "/storage/roms/bios", _ELEC_CONFIGS, "/storage"), + # PAN4ELEC (Kegg1701/P4ELEC 9c19bfb2), an overlay on an AmberELEC image: + # AmberELEC/usr/bin/batocera-info:101 prints its name. + _OsRule(_STANDIN_MANIFESTS["AmberELEC"], "PAN4ELEC", "file_matches", + ("/usr/bin/batocera-info", r"OPERATING SYSTEM: PAN4ELEC"), + "/storage/roms/bios", _ELEC_CONFIGS, "/storage", base="AmberELEC"), + # EmuELEC d4c7b2f1: scripts/image:172-176 NAME and ID from DISTRONAME, + # EmuELEC in distributions/EmuELEC/options:4. setsettings.sh:22 names the + # config, and libretro_base/retroarch/package.mk:117 sets its + # system_directory to /storage/roms/bios. + _OsRule(_STANDIN_MANIFESTS["EmuELEC"], "EmuELEC", "os_release", ("NAME=EmuELEC",), + "/storage/roms/bios", _ELEC_CONFIGS, "/storage"), + # JELOS 7b2e116c: scripts/image:118-120 OS_NAME="${DISTRONAME}", JELOS in + # distributions/JELOS/options:17. runemu.sh:37 names the config, whose + # default is "~/roms/bios" (retroarch/sources/RK3566/retroarch.cfg:716), + # and emustation.service:8 sets HOME=/storage. + _OsRule("retroarch", "JELOS", "os_release", ("OS_NAME=JELOS",), + "/storage/roms/bios", _ELEC_CONFIGS, "/storage"), + # KOLOS (AnonLinuxOS/KOLOS 7daf5950) keeps OS_NAME=JELOS + # (distributions/JELOS/options:17) and adds jelos-ready.service + # ("KOLOS system ready script"), installed by scripts/install:100-102. + _OsRule("retroarch", "KOLOS", "file_exists", + ("/usr/lib/systemd/system/jelos-ready.service",), + "/storage/roms/bios", _ELEC_CONFIGS, "/storage", base="JELOS"), + # UnofficialOS 22635796: scripts/image:119-120 OS_NAME, UnofficialOS in + # distributions/UnofficialOS/options:17, config at + # packages/unofficialos/sources/scripts/runemu.sh:37. + _OsRule("retroarch", "UnofficialOS", "os_release", ("OS_NAME=UnofficialOS",), + "/storage/roms/bios", _ELEC_CONFIGS, "/storage"), + # Arch R (archr-linux/Arch-R dbd45d45): scripts/image:164-177 NAME and + # OS_NAME, ArchR in distributions/ArchR/options:17. factoryreset:9-10 + # names the config, whose default is "~/roms/bios" + # (retroarch/sources/RK3326/retroarch.cfg:718). + _OsRule("retroarch", "ArchR", "os_release", ("OS_NAME=ArchR",), + "/storage/roms/bios", _ELEC_CONFIGS, "/storage"), + # ArkOS: the user and the menu folder, and the boot splash title its + # updates write (christianhaitian/arkos dedf4278, Update-RGB10.sh:76). + _OsRule("retroarch", "ArkOS", "file_exists", ("/home/ark", "/opt/system"), "/roms/bios", + _ARK_CONFIGS), + _OsRule("retroarch", "ArkOS", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*ArkOS\b"), + "/roms/bios", _ARK_CONFIGS), + # dArkOS daecf488: finishing_touches.sh:359-361 writes "title=dArkOS + # ()", kept by the dArkOSRE, dArkOSen and ArkOS4Clone overlays. + # Default system_directory "/roms/bios" (retroarch.cfg.rgb10:3043). + _OsRule("retroarch", "dArkOS", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*dArkOS"), + "/roms/bios", _ARK_CONFIGS, base="ArkOS"), + # RetroOZ 0.60 image (no build source is published): title=RetroOZ in + # the splash theme, /opt/.retrooz/version, and /etc/retrooz_version, the + # file PortMaster reads (device_info.txt:95). RetroArch is started with + # --config /home/odroid/.config/retroarch/retroarch.cfg (es_systems.cfg), + # system_directory "/roms/bios" (retroarch32/retroarch.cfg:3046). + _OsRule("retroarch", "RetroOZ", "file_matches", (_PLYMOUTH_TITLE, r"^title=\s*RetroOZ"), + "/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg", + "/home/odroid/.config/retroarch32/retroarch.cfg")), + _OsRule("retroarch", "RetroOZ", "file_exists", ("/opt/.retrooz",), + "/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg", + "/home/odroid/.config/retroarch32/retroarch.cfg")), + _OsRule("retroarch", "RetroOZ", "file_exists", ("/etc/retrooz_version",), + "/roms/bios", ("/home/odroid/.config/retroarch/retroarch.cfg", + "/home/odroid/.config/retroarch32/retroarch.cfg")), + # TheRA is left undetected: no image or source of its Odroid Go builds is + # published, so neither its markers nor its folder could be read. + # spruceOS 824b1aae: the spruce folder at the card root. RetroArch reads + # Saves/ra-configs/retroarch-.cfg (spruce/scripts/ + # helperFunctions.sh:157-165), seeded with system_directory + # "/mnt/SDCARD/BIOS" (RetroArch/platform/retroarch-Brick.cfg.bak:3279). + _OsRule("retroarch", "spruceOS", "file_exists", ("/mnt/SDCARD/spruce",), + "/mnt/SDCARD/BIOS", ("/mnt/SDCARD/Saves/ra-configs/retroarch-*.cfg",), + base="TrimUI"), + # CrossMix-OS 206d612f: crossmix-version.txt (System/usr/trimui/scripts/ + # update_common.sh:20), RetroArch/retroarch.cfg:3202 (patch_ra_cfg.sh:5). + _OsRule("retroarch", "CrossMix-OS", "file_exists", + ("/mnt/SDCARD/System/usr/trimui/crossmix-version.txt",), + "/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/retroarch.cfg",), base="TrimUI"), + # Onion 07505ea5: Makefile:75-76 writes onionVersion/version.txt, and + # static/build/.tmp_update/script/patch_ra_cfg.sh:6 names the config + # (system_directory at static/configs/RetroArch/.retroarch/retroarch.cfg:3009). + _OsRule("retroarch", "Onion", "file_exists", + ("/mnt/SDCARD/.tmp_update/onionVersion/version.txt",), + "/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)), + # Allium ee0dda97: its base folder /mnt/SDCARD/.allium + # (crates/common/src/constants.rs:19-34). RetroArch runs with + # HOME=/mnt/SDCARD/RetroArch on .retroarch/retroarch.cfg + # (static/.allium/cores/retroarch/launch_without_savestate_auto_load.sh:2-7), + # and Allium's RetroArch patch makes $ROOT/BIOS the default system folder. + _OsRule("retroarch", "Allium", "file_exists", ("/mnt/SDCARD/.allium",), + "/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)), + # Koriki 8513071c: src/systemInfo/main.c:106 reads Koriki/version.txt, + # .tmp_update/runtime.sh:271 installs RetroArch/.retroarch/retroarch.cfg + # (system_directory at base/RetroArch/.retroarch/retroarch.cfg:3191). + _OsRule("retroarch", "Koriki", "file_exists", ("/mnt/SDCARD/Koriki/version.txt",), + "/mnt/SDCARD/BIOS", ("/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg",)), + # MiyooCFW (buildroot d5912eeb): board/miyoo/rootfs/etc/main sets HOME=/mnt + # (line 4), package/miyoo/miyoo-ctl/miyoo-ctl.mk:12 installs miyooctl. + # RetroArch is a DINGUX build: $HOME/.retroarch/retroarch.cfg, and + # $HOME/.retroarch/system when no config names a folder. + _OsRule("retroarch", "MiyooCFW", "file_exists", ("/etc/main", "/usr/bin/miyooctl"), + "/mnt/.retroarch/system", ("/mnt/.retroarch/retroarch.cfg",), "/mnt"), + # plumOS: os-release ID=plumos (game-de-it/plumOS-pixel2_v2 dec872c0, + # rootfs/pixel2/etc/os-release:2, PortMaster device_info.txt:101). Each + # device build appends its own system_directory at launch: the Pixel 2 + # build /mnt/plumos-user/bios (package/app-layer-pixel2/bin/ + # plumos-retroarch-launch:342, mount point made by rootfs/pixel2/sbin/ + # init:29), the MF build /BIOS (plumOS-MF 0095017c, + # plumos-retroarch-launch:15). Only the first is told apart by a file. + _OsRule("retroarch", "plumOS", "file_exists", ("/mnt/plumos-user",), + "/mnt/plumos-user/bios"), + _OsRule("retroarch", "plumOS", "os_release", ("ID=plumos",), None), + # TrimUI's stock firmware publishes no source naming its RetroArch BIOS + # folder. PortMaster finds its libraries in /usr/trimui + # (trimui/launch_hooks.txt:37). Named so that what runs on top of it is + # told apart, never installed to by a guess. + _OsRule("", "TrimUI", "file_exists", ("/usr/trimui",), None), + # MinUI, MyMinUI and NextUI keep their system in .system at the card root + # (MinUI dbf89435, workspace/all/common/defines.h:14-16) and give each core + # Bios// (workspace/all/minarch/minarch.c:2942), a layout the + # installer does not write. + _OsRule("", "MinUI", "file_exists", ("/mnt/SDCARD/.system/res",), None, base="TrimUI"), + _OsRule("", "MinUI", "file_exists", ("/mnt/sdcard/.system/res",), None, base="TrimUI"), + _OsRule("retroarch", "Anbernic stock firmware", "file_exists", + ("/mnt/vendor/bin/dmenu.bin",), "/mnt/mmc/bios"), +) -def _muos_system_dir() -> Path | None: - """The system directory of muOS's RetroArch, read from its config, which - also records the config for finding the installed cores.""" - if not MUOS_ROOT.is_dir(): - return None - for cfg in MUOS_RETROARCH_CONFIGS: - system_dir = _parse_retroarch_system_dir(cfg) - if system_dir is not None: - return system_dir - return None +# Why a system found here gets no folder: said once per run. +_NO_FOLDER = { + "TrimUI": "its firmware publishes no source naming the folder its RetroArch reads", + "MinUI": "MinUI, MyMinUI and NextUI give each core its own Bios// folder, " + "a layout the installer does not write yet", +} + +# (platform, BIOS folder) -> the name the system found there gives itself. +_DETECTED_OS: dict[tuple[str, Path], str] = {} + + +def _rule_matches(rule: _OsRule, release: dict[str, str]) -> bool: + if rule.method == "os_release": + # Any of the KEY=value pairs, the value compared without case. + return any( + release.get(key) == value.lower() + for key, _, value in (pair.partition("=") for pair in rule.values) + ) + if rule.method == "file_matches": + path, pattern = rule.values + try: + text = _on_device(path).read_text(encoding="utf-8", errors="replace") + except OSError: + return False + return re.search(pattern, text, re.IGNORECASE | re.MULTILINE) is not None + return all(_on_device(value).exists() for value in rule.values) + + +def _config_files(pattern: str) -> list[tuple[Path, Path]]: + """(file to read, its folder as the system sees it) for a config rule.""" + device = pattern.replace("$HOME", str(Path.home()), 1) + folder, _, name = device.rpartition("/") + if "*" not in name: + return [(_on_device(device), Path(folder))] + try: + return [(path, Path(folder)) for path in sorted(_on_device(folder).glob(name)) + if path.is_file()] + except OSError: + return [] + + +def _os_system_dir(rules: list[_OsRule]) -> Path | None: + """The folder a system's RetroArch reads: the system_directory of its + first config that sets one, else the folder its build defaults to. + + The config also records where the cores of that installation are. + """ + for rule in rules: + home = Path(rule.home) if rule.home else None + for pattern in rule.configs: + for path, folder in _config_files(pattern): + system_dir = _parse_retroarch_system_dir(path, folder, home) + if system_dir is not None: + seen = _on_device(str(system_dir)) + _RETROARCH_CONFIGS.setdefault(seen, _RETROARCH_CONFIGS[system_dir]) + return seen + bios = next((rule.bios for rule in rules if rule.bios), None) + return _on_device(bios) if bios else None + + +def _detect_embedded() -> list[tuple[str, Path]]: + """The embedded system this is, by what its own build writes. + + A system built on another matches both: the derivative is meant. Two + unrelated systems matching are both returned, and os_conflict() names + them, so that no folder is chosen between them in silence. + """ + release = {k: v.lower() for k, v in _parse_os_release().items()} + matched = [rule for rule in _EMBEDDED_RULES if _rule_matches(rule, release)] + bases = {rule.base for rule in matched if rule.base} + found: list[tuple[str, Path]] = [] + for name in dict.fromkeys(rule.name for rule in matched): + if name in bases: + continue + rules = [rule for rule in matched if rule.name == name] + folder = _os_system_dir(rules) if rules[0].manifest else None + if folder is None: + reason = _NO_FOLDER.get( + name, "nothing on it names the folder its RetroArch reads") + print(f"{name} found here, but {reason}. Name the folder with --dest.", + file=sys.stderr) + continue + _DETECTED_OS[(rules[0].manifest, folder)] = name + found.append((rules[0].manifest, folder)) + return found + + +def detected_os(plat: str, path: Path) -> str: + """The name of the system detection found this folder on, or "".""" + return _DETECTED_OS.get((plat, path), "") + + +def os_conflict(platforms: list[tuple[str, Path]]) -> list[str]: + """The systems among these when there are two or more: a machine runs one.""" + names = list(dict.fromkeys(filter(None, (detected_os(n, p) for n, p in platforms)))) + return names if len(names) > 1 else [] + + +def _system_lines() -> list[str]: + """Each embedded system detection knows, with the files it gets.""" + lines = [] + for name in dict.fromkeys(rule.name for rule in _EMBEDDED_RULES): + plat = next(rule.manifest for rule in _EMBEDDED_RULES if rule.name == name) + if plat: + lines.append(f"{name}: {plat}") + else: + lines.append(f"{name}: not installed to, {_NO_FOLDER[name]} (use --dest)") + return lines + + +def found_label(plat: str, path: Path) -> str: + """How a detected folder is announced: the system's own name, and the + platform whose files it gets when that is another name.""" + name = detected_os(plat, path) + shown = display_name(plat) + if not name or name == shown: + return f"{shown} at {path}" + return f"{name} at {path} ({shown} files)" def _android_storage_root() -> Path: @@ -1259,9 +1535,7 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]: found.extend(_detect_android()) if os_type in ("linux", "wsl"): - embedded = _detect_embedded() - muos = _muos_system_dir() if embedded and embedded[0][0] == "retroarch" else None - found.extend([("retroarch", muos)] if muos else embedded) + found.extend(_detect_embedded()) if os_type in ("linux", "wsl", "darwin"): # EmuDeck on Linux and macOS: functions/all.sh:3-4 and 24 (EmuDeck @@ -1391,7 +1665,9 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]: _detect_by_executable(os_type, found) _detect_by_registry(os_type, found) _detect_by_path(os_type, found) - return found + # An embedded system's config read again as the home folder's one (HOME is + # /storage on the ELEC family) is the same folder. + return list(dict.fromkeys(found)) def _emudeck_windows_bios() -> Path | None: @@ -4175,7 +4451,10 @@ 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}) {display_name(name)} ({path})") + system = detected_os(name, path) + label = f"{system}, {display_name(name)} files" if system and system != display_name(name) \ + else display_name(name) + print(f" {i}) {label} ({path})") last = len(platforms) + (1 if len(platforms) > 1 else 0) if len(platforms) > 1: print(f" {last}) All") @@ -4883,11 +5162,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print("Available platforms:") for p in AVAILABLE_PLATFORMS: print(f" {p}") + print("\nSystems recognized by their own files:") + for line in _system_lines(): + print(f" {line}") detected = detect_platforms(os_type) if detected: print("\nDetected on this system:") for name, path in detected: - print(f" {name}: {path}") + system = detected_os(name, path) + print(f" {name}: {path}" + (f" ({system})" if system else "")) return # Platform detection or override. A destination taken from the default @@ -4968,8 +5251,17 @@ 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 {display_name(name)} at {path}") + print(f" Found {found_label(name, path)}") + conflict = os_conflict(platforms) + if conflict and not interactive and not listing: + # A machine runs one system: installing to each folder would write + # where nothing reads, and picking one would be a guess. + print(f"Error: {' and '.join(conflict)} are both detected here, and a " + "machine runs one. Name the folder with --dest (and --platform for " + "its file list).", + file=sys.stderr) + sys.exit(1) if len(platforms) > 1 and not listing and interactive: platforms = _prompt_platform_choice(platforms) diff --git a/install.sh b/install.sh index 6f563d44..548c3a02 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" +DEFAULT_INSTALL_SHA256="9fbee0f382ca68880697ed573b79ecc1ffca793a406a3f258dad16e40d2845d6" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/platforms/_registry.yml b/platforms/_registry.yml index 790d0f31..84d0d7ce 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -267,34 +267,231 @@ platforms: method: config_file config: '%ProgramFiles%\Steam\steamapps\common\RetroArch\retroarch.cfg' parse_key: system_directory - # Handheld firmwares that run RetroArch as their frontend: muOS, - # ArkOS, and the stock Linux of Anbernic devices (dmenu). - # muOS copies its default config on first launch and RetroArch reads - # /run/muos/storage/bios, bound to MUOS/bios on the second card when - # it holds one, else the first (MustardOS/internal f41f4a89, - # script/control/retroarch.sh:8-20, script/device/bind.sh:4,116-150, - # share/emulator/retroarch/retroarch.default.cfg:2806,3151). - - os: linux - method: config_file - config: /opt/muos/share/info/config/retroarch.cfg - parse_key: system_directory - - os: linux - method: config_file - config: /opt/muos/share/emulator/retroarch/retroarch.default.cfg - parse_key: system_directory + # Handheld systems that run RetroArch as their frontend, each found by + # what its own build writes. A system built on another (derives_from) + # matches both and is the one meant. + # muOS: MustardOS/internal f41f4a89: retroarch.sh:8-20, bind.sh:4,116-150, + # retroarch.default.cfg:2806,3151. /mnt/mmc/MUOS is the older layout. - os: linux + name: muOS method: file_exists file: /opt/muos bios_path: /run/muos/storage/bios + config: [/opt/muos/share/info/config/retroarch.cfg, /opt/muos/share/emulator/retroarch/retroarch.default.cfg] - os: linux + name: muOS method: file_exists file: /mnt/mmc/MUOS bios_path: /mnt/mmc/MUOS/bios + # AmberELEC: AmberELEC 5a3882cb scripts/image:150-152, options:17, runemu.py:22, + # retroarch.cfg:611 ("~/roms/bios"), emustation.service:6 (HOME). CHAos + # (tech4bot/AmberELEC c0a4b0c6) writes the same name. Served by RetroArch's + # file list until its own platform is published. - os: linux + name: AmberELEC + method: os_release + match: [NAME=AmberELEC] + bios_path: /storage/roms/bios + config: [/storage/.config/retroarch/retroarch.cfg] + home: /storage + # PAN4ELEC: Kegg1701/P4ELEC 9c19bfb2, AmberELEC/usr/bin/batocera-info:101. + - os: linux + name: PAN4ELEC + method: file_matches + file: /usr/bin/batocera-info + pattern: 'OPERATING SYSTEM: PAN4ELEC' + bios_path: /storage/roms/bios + config: [/storage/.config/retroarch/retroarch.cfg] + home: /storage + derives_from: AmberELEC + # EmuELEC: EmuELEC d4c7b2f1 scripts/image:172-176, options:4, setsettings.sh:22, + # libretro_base/retroarch/package.mk:117. Served by RetroArch's file list + # until its own platform is published. + - os: linux + name: EmuELEC + method: os_release + match: [NAME=EmuELEC] + bios_path: /storage/roms/bios + config: [/storage/.config/retroarch/retroarch.cfg] + home: /storage + # JELOS: JustEnoughLinuxOS/distribution 7b2e116c scripts/image:118-120, + # options:17, runemu.sh:37, RK3566/retroarch.cfg:716. + - os: linux + name: JELOS + method: os_release + match: [OS_NAME=JELOS] + bios_path: /storage/roms/bios + config: [/storage/.config/retroarch/retroarch.cfg] + home: /storage + # KOLOS: AnonLinuxOS/KOLOS 7daf5950: OS_NAME=JELOS kept, jelos-ready.service + # ("KOLOS system ready script") installed by scripts/install:100-102. + - os: linux + name: KOLOS + method: file_exists + file: /usr/lib/systemd/system/jelos-ready.service + bios_path: /storage/roms/bios + config: [/storage/.config/retroarch/retroarch.cfg] + home: /storage + derives_from: JELOS + # UnofficialOS: RetroGFX/UnofficialOS 22635796 scripts/image:119-120, options:17, + # packages/unofficialos/sources/scripts/runemu.sh:37. + - os: linux + name: UnofficialOS + method: os_release + match: [OS_NAME=UnofficialOS] + bios_path: /storage/roms/bios + config: [/storage/.config/retroarch/retroarch.cfg] + home: /storage + # ArchR: archr-linux/Arch-R dbd45d45 scripts/image:164-177, options:17, + # factoryreset:9-10, RK3326/retroarch.cfg:718. + - os: linux + name: ArchR + method: os_release + match: [OS_NAME=ArchR] + bios_path: /storage/roms/bios + config: [/storage/.config/retroarch/retroarch.cfg] + home: /storage + # ArkOS: christianhaitian/arkos dedf4278 Update-RGB10.sh:76 (splash title). + # Both configs move to /roms2 with "Switch to SD2 for Roms" (darkos + # daecf488, scripts/Switch to SD2 for Roms.sh:91-92). + - os: linux + name: ArkOS method: file_exists file: [/home/ark, /opt/system] bios_path: /roms/bios + config: [/home/ark/.config/retroarch/retroarch.cfg, /home/ark/.config/retroarch32/retroarch.cfg] - os: linux + name: ArkOS + method: file_matches + file: /usr/share/plymouth/themes/text.plymouth + pattern: '^title=\s*ArkOS\b' + bios_path: /roms/bios + config: [/home/ark/.config/retroarch/retroarch.cfg, /home/ark/.config/retroarch32/retroarch.cfg] + # dArkOS: christianhaitian/darkos daecf488 finishing_touches.sh:359-361, + # retroarch.cfg.rgb10:3043. The dArkOSRE, dArkOSen and ArkOS4Clone overlays + # keep the title. + - os: linux + name: dArkOS + method: file_matches + file: /usr/share/plymouth/themes/text.plymouth + pattern: '^title=\s*dArkOS' + bios_path: /roms/bios + config: [/home/ark/.config/retroarch/retroarch.cfg, /home/ark/.config/retroarch32/retroarch.cfg] + derives_from: ArkOS + # RetroOZ: RetroOZ 0.60 image: splash title, /opt/.retrooz/version, + # retroarch32/retroarch.cfg:3046. /etc/retrooz_version is the file PortMaster + # reads (PortMaster-GUI ccea6b74, PortMaster/device_info.txt:95). + - os: linux + name: RetroOZ + method: file_matches + file: /usr/share/plymouth/themes/text.plymouth + pattern: '^title=\s*RetroOZ' + bios_path: /roms/bios + config: [/home/odroid/.config/retroarch/retroarch.cfg, /home/odroid/.config/retroarch32/retroarch.cfg] + - os: linux + name: RetroOZ + method: file_exists + file: /opt/.retrooz + bios_path: /roms/bios + config: [/home/odroid/.config/retroarch/retroarch.cfg, /home/odroid/.config/retroarch32/retroarch.cfg] + - os: linux + name: RetroOZ + method: file_exists + file: /etc/retrooz_version + bios_path: /roms/bios + config: [/home/odroid/.config/retroarch/retroarch.cfg, /home/odroid/.config/retroarch32/retroarch.cfg] + # spruceOS: spruceOS 824b1aae spruce/scripts/helperFunctions.sh:157-165, + # RetroArch/platform/retroarch-Brick.cfg.bak:3279. + - os: linux + name: spruceOS + method: file_exists + file: /mnt/SDCARD/spruce + bios_path: /mnt/SDCARD/BIOS + config: ['/mnt/SDCARD/Saves/ra-configs/retroarch-*.cfg'] + derives_from: TrimUI + # CrossMix-OS: cizia64/CrossMix-OS 206d612f System/usr/trimui/scripts/ + # update_common.sh:20, patch_ra_cfg.sh:5, RetroArch/retroarch.cfg:3202. + - os: linux + name: CrossMix-OS + method: file_exists + file: /mnt/SDCARD/System/usr/trimui/crossmix-version.txt + bios_path: /mnt/SDCARD/BIOS + config: [/mnt/SDCARD/RetroArch/retroarch.cfg] + derives_from: TrimUI + # Onion: OnionUI/Onion 07505ea5 Makefile:75-76, + # .tmp_update/script/patch_ra_cfg.sh:6, retroarch.cfg:3009. + - os: linux + name: Onion + method: file_exists + file: /mnt/SDCARD/.tmp_update/onionVersion/version.txt + bios_path: /mnt/SDCARD/BIOS + config: [/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg] + # Allium: goweiwen/Allium ee0dda97 crates/common/src/constants.rs:19-34, + # launch_without_savestate_auto_load.sh:2-7. Its RetroArch patch makes + # $ROOT/BIOS the default system folder. + - os: linux + name: Allium + method: file_exists + file: /mnt/SDCARD/.allium + bios_path: /mnt/SDCARD/BIOS + config: [/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg] + # Koriki: Rparadise-Team/Koriki 8513071c src/systemInfo/main.c:106, + # .tmp_update/runtime.sh:271, RetroArch/.retroarch/retroarch.cfg:3191. + - os: linux + name: Koriki + method: file_exists + file: /mnt/SDCARD/Koriki/version.txt + bios_path: /mnt/SDCARD/BIOS + config: [/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg] + # MiyooCFW: MiyooCFW buildroot d5912eeb board/miyoo/rootfs/etc/main:4 (HOME), + # package/miyoo/miyoo-ctl/miyoo-ctl.mk:12. DINGUX RetroArch defaults to + # $HOME/.retroarch/system. + - os: linux + name: MiyooCFW + method: file_exists + file: [/etc/main, /usr/bin/miyooctl] + bios_path: /mnt/.retroarch/system + config: [/mnt/.retroarch/retroarch.cfg] + home: /mnt + # plumOS: game-de-it/plumOS-pixel2_v2 dec872c0 rootfs/pixel2/etc/os-release:2, + # sbin/init:29, plumos-retroarch-launch:342. Other plumOS builds append a + # folder of their own at launch (plumOS-MF 0095017c, + # plumos-retroarch-launch:15): found, not installed to. + - os: linux + name: plumOS + method: file_exists + file: /mnt/plumos-user + bios_path: /mnt/plumos-user/bios + - os: linux + name: plumOS + method: os_release + match: [ID=plumos] + # TrimUI: the stock firmware publishes no source naming its BIOS folder. + # /usr/trimui is where PortMaster finds its libraries + # (trimui/launch_hooks.txt:37). Found so that what runs on it is told + # apart, never installed to. + - os: linux + name: TrimUI + method: file_exists + file: /usr/trimui + installs: false + # MinUI: MinUI dbf89435 workspace/all/common/defines.h:14-16, minarch.c:2942. + # One Bios// folder per core, not written by the installer. + - os: linux + name: MinUI + method: file_exists + file: /mnt/SDCARD/.system/res + installs: false + derives_from: TrimUI + - os: linux + name: MinUI + method: file_exists + file: /mnt/sdcard/.system/res + installs: false + derives_from: TrimUI + # Anbernic stock firmware: the stock Linux of Anbernic devices. + - os: linux + name: 'Anbernic stock firmware' method: file_exists file: /mnt/vendor/bin/dmenu.bin bios_path: /mnt/mmc/bios @@ -316,17 +513,23 @@ platforms: # Knulli, a Batocera derivative (knulli-cfw/knulli-linux 57afa662): # OS_NAME in os-release (board/scripts/post-build-script.sh:233-236), # and knulli.version (package/system/knulli-system/knulli-system.mk:98-102). + # Served by Batocera's file list until its own platform is published. - os: linux + name: KNULLI method: os_release match: [OS_NAME=knulli] bios_path: /userdata/bios + derives_from: Batocera - os: linux + name: KNULLI method: file_exists file: /usr/share/knulli/knulli.version bios_path: /userdata/bios + derives_from: Batocera # batocera.linux 38845725, package/batocera/core/batocera-system/ # batocera-system.mk:21-22 writes batocera.arch and batocera.version. - os: linux + name: Batocera method: file_exists file: /usr/share/batocera/batocera.version bios_path: /userdata/bios @@ -348,10 +551,12 @@ platforms: install: detect: - os: linux + name: Recalbox method: file_exists file: /recalbox/recalbox.version bios_path: /recalbox/share/bios - os: linux + name: Recalbox method: file_exists file: /usr/bin/recalbox-settings bios_path: /recalbox/share/bios @@ -456,6 +661,7 @@ platforms: install: detect: - os: linux + name: Lakka method: os_release match: [ID=lakka] bios_path: /storage/system @@ -568,6 +774,7 @@ platforms: install: detect: - os: linux + name: ROCKNIX method: os_release match: [OS_NAME=rocknix, ID=rocknix] bios_path: /storage/roms/bios diff --git a/tests/test_install.py b/tests/test_install.py index c16ca60e..8a68209f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1007,15 +1007,25 @@ class DetectionFollowsTheRegistry(unittest.TestCase): registry = yaml.safe_load( (Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text() ) - rules = set() + rules = [] for name, entry in registry["platforms"].items(): for rule in (entry.get("install") or {}).get("detect", []): if rule["method"] == "os_release": - rules.add((name, "os_release", tuple(rule["match"]), rule["bios_path"])) + values = tuple(rule["match"]) elif rule["method"] == "file_exists": - files = rule["file"] if isinstance(rule["file"], list) else [rule["file"]] - rules.add((name, "file_exists", tuple(files), rule["bios_path"])) - self.assertEqual(set(install._EMBEDDED_RULES), rules) + files = rule["file"] + values = tuple(files if isinstance(files, list) else [files]) + elif rule["method"] == "file_matches": + values = (rule["file"], rule["pattern"]) + else: + continue + rules.append(install._OsRule( + "" if rule.get("installs") is False else name, rule["name"], + rule["method"], values, rule.get("bios_path"), + tuple(rule.get("config", ())), rule.get("home", ""), + rule.get("derives_from", ""), + )) + self.assertEqual(sorted(install._EMBEDDED_RULES, key=repr), sorted(rules, key=repr)) def test_every_config_file_read_is_published(self): """The registry published five RetroArch config files and the fixed @@ -2758,8 +2768,9 @@ class TheInstallerPageNamesEveryEvidence(unittest.TestCase): def test_every_rule_is_on_the_page(self): page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") page = page.replace("\\\\", "\\") - expected = [path for _p, method, values, _b in install._EMBEDDED_RULES - if method == "file_exists" for path in values] + expected = [path for rule in install._EMBEDDED_RULES + if rule.method == "file_exists" for path in rule.values] + expected += [rule.name for rule in install._EMBEDDED_RULES] expected += [path.replace("$HOME", "~").rstrip("/") for _os, _p, path, _b in install._PATH_RULES] missing = [path for path in expected if path not in page] @@ -4196,54 +4207,264 @@ class EmuDeckWindowsIsFoundAsItsScriptsFindIt(unittest.TestCase): install._host_path("C:/Emulation/bios")) +def _device(test: unittest.TestCase, files: dict[str, str], + release: str = "", home: str = "/root") -> Path: + """A fake root holding these files, read by the embedded rules.""" + root = Path(_tempdir(test)) + if release: + files = {**files, "/etc/os-release": release} + for name, text in files.items(): + path = root / name.lstrip("/") + if name.endswith("/"): + path.mkdir(parents=True, exist_ok=True) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + for patcher in ( + unittest.mock.patch.object(install, "_SYSTEM_ROOT", root), + unittest.mock.patch.dict(os.environ, {"HOME": home}), + unittest.mock.patch.dict(install._RETROARCH_CONFIGS, clear=True), + unittest.mock.patch.dict(install._DETECTED_OS, clear=True), + ): + patcher.start() + test.addCleanup(patcher.stop) + return root + + class MuOsIsReadFromItsRetroArchConfig(unittest.TestCase): """muOS wrote to /mnt/mmc/MUOS/bios while RetroArch reads /run/muos/storage/bios, bound to the second card when it holds MUOS/bios, and --installed-cores found no core in /opt/muos/share/core.""" - def _muos(self, *, user_config: bool) -> tuple[Path, Path]: - root = Path(_tempdir(self)) - muos = root / "opt" / "muos" - system_dir, cores = root / "run" / "bios", root / "core" - cores.mkdir(parents=True) - configs = (muos / "share/info/config/retroarch.cfg", - muos / "share/emulator/retroarch/retroarch.default.cfg") - default = configs[1] - default.parent.mkdir(parents=True) - default.write_text(f'libretro_directory = "{cores}"\n' - f'system_directory = "{root / "default-bios"}"\n') - if user_config: - configs[0].parent.mkdir(parents=True) - configs[0].write_text(f'libretro_directory = "{cores}"\n' - f'system_directory = "{system_dir}"\n') - for patcher in ( - unittest.mock.patch.object(install, "MUOS_ROOT", muos), - unittest.mock.patch.object(install, "MUOS_RETROARCH_CONFIGS", configs), - unittest.mock.patch.object( - install, "_detect_embedded", - lambda: [("retroarch", Path("/run/muos/storage/bios"))]), - unittest.mock.patch.dict(os.environ, {"HOME": str(root / "home")}), - unittest.mock.patch.dict(install._RETROARCH_CONFIGS, clear=True), - ): - patcher.start() - self.addCleanup(patcher.stop) - return (system_dir if user_config else root / "default-bios"), cores + USER = "/opt/muos/share/info/config/retroarch.cfg" + DEFAULT = "/opt/muos/share/emulator/retroarch/retroarch.default.cfg" + + def _muos(self, files: dict[str, str]) -> Path: + root = _device(self, {"/opt/muos/": "", **files}) + found = install._detect_embedded() + self.assertEqual(len(found), 1) + self.assertEqual(install.detected_os(*found[0]), "muOS") + return root def test_the_users_config_decides(self): - system_dir, cores = self._muos(user_config=True) - found = install.detect_platforms("linux") - self.assertIn(("retroarch", system_dir), found) - self.assertNotIn(("retroarch", Path("/run/muos/storage/bios")), found) - self.assertIn(cores, install.core_dirs("retroarch", system_dir)) + root = self._muos({ + self.USER: 'libretro_directory = "/opt/muos/share/core"\n' + 'system_directory = "/mnt/sdcard/MUOS/bios"\n', + self.DEFAULT: 'system_directory = "/run/muos/storage/bios"\n', + }) + self.assertEqual(install._detect_embedded(), + [("retroarch", root / "mnt/sdcard/MUOS/bios")]) + self.assertIn(Path("/opt/muos/share/core"), + install.core_dirs("retroarch", root / "mnt/sdcard/MUOS/bios")) def test_before_the_first_launch_the_default_does(self): - system_dir, cores = self._muos(user_config=False) - self.assertIn(("retroarch", system_dir), install.detect_platforms("linux")) - self.assertIn(cores, install.core_dirs("retroarch", system_dir)) + root = self._muos({self.DEFAULT: 'system_directory = "/run/muos/storage/bios"\n'}) + self.assertEqual(install._detect_embedded(), + [("retroarch", root / "run/muos/storage/bios")]) def test_without_a_config_the_bound_folder_is_used(self): - rule = next(r for r in install._EMBEDDED_RULES if r[2] == ("/opt/muos",)) - self.assertEqual(rule[3], "/run/muos/storage/bios") + root = self._muos({}) + self.assertEqual(install._detect_embedded(), + [("retroarch", root / "run/muos/storage/bios")]) + + +class HandheldSystemsByTheirOwnFiles(unittest.TestCase): + """AmberELEC, EmuELEC, JELOS, ArkOS on a second card and a dozen other + handheld systems were not detected, or got /roms/bios whatever their + RetroArch read: each is found by what its own build writes, and its + folder is the system_directory of its RetroArch config.""" + + ELEC_CFG = "/storage/.config/retroarch/retroarch.cfg" + ARK_CFG = "/home/ark/.config/retroarch/retroarch.cfg" + TITLE = "/usr/share/plymouth/themes/text.plymouth" + + def _found(self, files: dict, release: str = "", home: str = "/root"): + root = _device(self, files, release, home) + with contextlib.redirect_stderr(io.StringIO()) as err: + found = install._detect_embedded() + names = [install.detected_os(plat, path) for plat, path in found] + return root, found, names, err.getvalue() + + def _one(self, files: dict, release: str = "", home: str = "/root"): + root, found, names, _err = self._found(files, release, home) + self.assertEqual(len(found), 1, found) + return root, found[0], names[0] + + def _elec(self, release: str, name: str, extra: dict | None = None, + plat: str = "retroarch"): + cfg = {self.ELEC_CFG: 'system_directory = "~/roms/bios"\n'} + root, (found_plat, path), found_name = self._one({**cfg, **(extra or {})}, release) + self.assertEqual((found_plat, path, found_name), + (plat, root / "storage/roms/bios", name)) + + def test_amberelec(self): + self._elec('NAME="AmberELEC"\nID="coreelec"\n', "AmberELEC", + plat=install._STANDIN_MANIFESTS["AmberELEC"]) + + def test_chaos_names_itself_amberelec(self): + # tech4bot/AmberELEC writes the same os-release as AmberELEC. + self._elec('NAME="AmberELEC"\nID="coreelec"\nCOREELEC_DEVICE="RG552"\n', "AmberELEC") + + def test_pan4elec_over_amberelec(self): + self._elec('NAME="AmberELEC"\n', "PAN4ELEC", + {"/usr/bin/batocera-info": 'echo "OPERATING SYSTEM: PAN4ELEC"\n'}) + + def test_emuelec(self): + self._elec('NAME="EmuELEC"\nID="emuelec"\n', "EmuELEC", + plat=install._STANDIN_MANIFESTS["EmuELEC"]) + + def test_jelos(self): + self._elec('OS_NAME="JELOS"\nHW_DEVICE="RK3566"\n', "JELOS") + + def test_kolos_over_jelos(self): + self._elec('OS_NAME="JELOS"\n', "KOLOS", + {"/usr/lib/systemd/system/jelos-ready.service": + "Description=KOLOS system ready script\n"}) + + def test_unofficialos(self): + self._elec('OS_NAME="UnofficialOS"\n', "UnofficialOS") + + def test_archr(self): + self._elec('NAME="ArchR"\nID="archr"\nOS_NAME="ArchR"\n', "ArchR") + + def test_an_elec_config_moved_elsewhere_is_followed(self): + root, (_plat, path), _name = self._one( + {self.ELEC_CFG: 'system_directory = "/storage/roms2/bios"\n'}, 'OS_NAME="JELOS"\n') + self.assertEqual(path, root / "storage/roms2/bios") + + def test_arkos_first_and_second_card(self): + base = {"/home/ark/": "", "/opt/system/": "", + self.TITLE: "[ubuntu-text]\ntitle=ArkOS 2.0 (02012025)-3\n"} + for folder in ("/roms/bios", "/roms2/bios"): + with self.subTest(folder=folder): + root, found, name = self._one( + {**base, self.ARK_CFG: f'system_directory = "{folder}"\n'}) + self.assertEqual((found, name), + (("retroarch", root / folder.lstrip("/")), "ArkOS")) + + def test_arkos_32_bit_config_when_the_64_bit_one_is_missing(self): + root, found, _name = self._one({ + "/home/ark/": "", "/opt/system/": "", + "/home/ark/.config/retroarch32/retroarch.cfg": 'system_directory = "/roms2/bios"\n', + }) + self.assertEqual(found, ("retroarch", root / "roms2/bios")) + + def test_darkos_first_and_second_card(self): + base = {"/home/ark/": "", "/opt/system/": "", + self.TITLE: "title=dArkOS (05102026)\n"} + for folder in ("/roms/bios", "/roms2/bios"): + with self.subTest(folder=folder): + root, found, name = self._one( + {**base, self.ARK_CFG: f'system_directory = "{folder}"\n'}) + self.assertEqual((found, name), + (("retroarch", root / folder.lstrip("/")), "dArkOS")) + + def test_darkos_without_a_config(self): + root, found, name = self._one({"/home/ark/": "", "/opt/system/": "", + self.TITLE: "title=dArkOS (05102026)\n"}) + self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "dArkOS")) + + def test_retrooz(self): + cfg = "/home/odroid/.config/retroarch32/retroarch.cfg" + root, found, name = self._one({ + "/opt/.retrooz/version": "0.60\n", self.TITLE: "[ubuntu-text]\ntitle=RetroOZ\n", + cfg: 'system_directory = "/roms/bios"\n', + }) + self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "RetroOZ")) + root, found, name = self._one({"/etc/retrooz_version": "1\n"}) + self.assertEqual((found, name), (("retroarch", root / "roms/bios"), "RetroOZ")) + + def test_thera_is_not_detected(self): + _root, found, _names, _err = self._found({self.TITLE: "title= *TheRA RC 1*\n"}) + self.assertEqual(found, []) + + def test_spruceos_on_a_trimui(self): + root, found, name = self._one({ + "/usr/trimui/": "", "/mnt/SDCARD/spruce/": "", + "/mnt/SDCARD/Saves/ra-configs/retroarch-Brick.cfg": + 'system_directory = "/mnt/SDCARD/BIOS"\n', + }) + self.assertEqual((found, name), (("retroarch", root / "mnt/SDCARD/BIOS"), "spruceOS")) + + def test_crossmix_os(self): + root, found, name = self._one({ + "/usr/trimui/": "", "/mnt/SDCARD/System/usr/trimui/crossmix-version.txt": "1.3\n", + "/mnt/SDCARD/RetroArch/retroarch.cfg": 'system_directory = "/mnt/SDCARD/BIOS"\n', + }) + self.assertEqual((found, name), (("retroarch", root / "mnt/SDCARD/BIOS"), "CrossMix-OS")) + + def _miyoo_mini(self, marker: str, name: str): + root, found, found_name = self._one({ + marker: "x\n", + "/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg": + 'system_directory = "/mnt/SDCARD/BIOS"\n', + }) + self.assertEqual((found, found_name), (("retroarch", root / "mnt/SDCARD/BIOS"), name)) + + def test_onion(self): + self._miyoo_mini("/mnt/SDCARD/.tmp_update/onionVersion/version.txt", "Onion") + + def test_allium(self): + self._miyoo_mini("/mnt/SDCARD/.allium/version.txt", "Allium") + + def test_koriki(self): + self._miyoo_mini("/mnt/SDCARD/Koriki/version.txt", "Koriki") + + def test_miyoocfw_default_and_config(self): + base = {"/etc/main": "export HOME=/mnt\n", "/usr/bin/miyooctl": ""} + root, found, name = self._one(base) + self.assertEqual((found, name), + (("retroarch", root / "mnt/.retroarch/system"), "MiyooCFW")) + root, found, _name = self._one( + {**base, "/mnt/.retroarch/retroarch.cfg": 'system_directory = "~/bios"\n'}) + self.assertEqual(found, ("retroarch", root / "mnt/bios")) + + def test_plumos(self): + root, found, name = self._one({"/mnt/plumos-user/": ""}, 'NAME="plumOS"\nID=plumos\n') + self.assertEqual((found, name), (("retroarch", root / "mnt/plumos-user/bios"), "plumOS")) + _root, found, _names, err = self._found({}, 'NAME="plumOS"\nID=plumos\n') + self.assertEqual(found, []) + self.assertIn("plumOS found here", err) + + def test_trimui_stock_and_minui_are_named_not_installed_to(self): + _root, found, _names, err = self._found({"/usr/trimui/": ""}) + self.assertEqual(found, []) + self.assertIn("TrimUI found here", err) + _root, found, _names, err = self._found( + {"/usr/trimui/": "", "/mnt/SDCARD/.system/res/": ""}) + self.assertEqual(found, []) + self.assertIn("MinUI found here", err) + self.assertNotIn("TrimUI", err) + + def test_two_unrelated_systems_are_refused(self): + _root, found, names, _err = self._found( + {"/mnt/SDCARD/Koriki/version.txt": "1\n"}, 'OS_NAME="JELOS"\n') + self.assertEqual(names, ["JELOS", "Koriki"]) + self.assertEqual(install.os_conflict(found), ["JELOS", "Koriki"]) + with unittest.mock.patch.object(sys, "argv", ["install.py", "--no-input"]), \ + unittest.mock.patch.object(install, "detect_os", return_value="linux"), \ + unittest.mock.patch.object(install, "detect_platforms", return_value=found), \ + unittest.mock.patch.object(install, "detect_frontends", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit) as stop: + install.main() + self.assertEqual(stop.exception.code, 1) + self.assertIn("JELOS and Koriki are both detected here", err.getvalue()) + + def test_the_system_is_announced_by_its_own_name(self): + root, found, _name = self._one({self.ELEC_CFG: ""}, 'NAME="AmberELEC"\n') + self.assertEqual(install.found_label(*found), + f"AmberELEC at {root / 'storage/roms/bios'} (RetroArch files)") + + def test_list_platforms_names_every_system(self): + with unittest.mock.patch.object(sys, "argv", ["install.py", "--list-platforms"]), \ + unittest.mock.patch.object(install, "detect_os", return_value="linux"), \ + unittest.mock.patch.object(install, "detect_platforms", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install.main() + for rule in install._EMBEDDED_RULES: + self.assertIn(f" {rule.name}: ", out.getvalue()) class BatoceraAndKnulliByWhatTheirBuildsWrite(unittest.TestCase): diff --git a/wiki/installer.md b/wiki/installer.md index 57dae073..126b7396 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -283,21 +283,56 @@ Files the list does not name are never read, moved or deleted. ## Platform detection -Detection reads what the platform itself writes, never a guess from a directory -name. The embedded systems are tested in the order below and the first match -wins, since a machine is only one of them. +Detection reads what the system itself writes, never a guess from a directory +name. A machine runs one embedded system, so every rule below is tested. A +system built on another one (KOLOS on JELOS, dArkOS on ArkOS, spruceOS on a +TrimUI) matches both and is the one meant. Two unrelated systems matching +are both named: at a terminal the run asks which one, otherwise it stops and +asks for `--dest`. The run names the system as it names itself, with the file +list it gets: `Found AmberELEC at /storage/roms/bios (RetroArch files)`. +`--list-platforms` lists every system below. -| Evidence | Platform | BIOS directory | -|----------|----------|----------------| -| `/etc/os-release` `OS_NAME=ROCKNIX` (or `ID=rocknix` on images before 2025) | ROCKNIX | `/storage/roms/bios` | -| `/etc/os-release` `OS_NAME=knulli` or `/usr/share/knulli/knulli.version` | Batocera (Knulli) | `/userdata/bios` | -| `/etc/os-release` `ID=lakka` | Lakka | `/storage/system` | -| `/usr/share/batocera/batocera.version` | Batocera | `/userdata/bios` | -| `/recalbox/recalbox.version` or `/usr/bin/recalbox-settings` | Recalbox | `/recalbox/share/bios` | -| `/opt/muos` | RetroArch (muOS) | `system_directory` of `/opt/muos/share/info/config/retroarch.cfg`, else of the default it is copied from: `/run/muos/storage/bios`, which muOS binds to `MUOS/bios` on the second card when that card holds one, else the first. Cores are read from that config's `libretro_directory` (`/opt/muos/share/core`) | -| `/mnt/mmc/MUOS/` | RetroArch | `/mnt/mmc/MUOS/bios` | -| `/home/ark` and `/opt/system` | RetroArch | `/roms/bios` | -| `/mnt/vendor/bin/dmenu.bin` | RetroArch | `/mnt/mmc/bios` | +Where a system's RetroArch config is named, its `system_directory` is the +folder, `~` being that system's home for RetroArch. The folder in the last +column is used when no config sets one. ArkOS and dArkOS keep two configs +(64-bit and 32-bit RetroArch), and their "Switch to SD2 for Roms" script +rewrites both to `/roms2/bios`: a second card is followed through them. + +| Evidence | System | File list | BIOS directory | +|----------|--------|-----------|----------------| +| `/etc/os-release` `OS_NAME=ROCKNIX` (or `ID=rocknix` on images before 2025) | ROCKNIX | ROCKNIX | `/storage/roms/bios` | +| `/etc/os-release` `OS_NAME=knulli` or `/usr/share/knulli/knulli.version` | KNULLI | Batocera | `/userdata/bios` | +| `/etc/os-release` `ID=lakka` | Lakka | Lakka | `/storage/system` | +| `/usr/share/batocera/batocera.version` | Batocera | Batocera | `/userdata/bios` | +| `/recalbox/recalbox.version` or `/usr/bin/recalbox-settings` | Recalbox | Recalbox | `/recalbox/share/bios` | +| `/opt/muos` | muOS | RetroArch | `system_directory` of `/opt/muos/share/info/config/retroarch.cfg`, else of the default it is copied from: `/run/muos/storage/bios`, which muOS binds to `MUOS/bios` on the second card when that card holds one, else the first. Cores are read from that config's `libretro_directory` (`/opt/muos/share/core`) | +| `/mnt/mmc/MUOS/` | muOS | RetroArch | `/mnt/mmc/MUOS/bios` | +| `/etc/os-release` `NAME=AmberELEC` (CHAos writes the same) | AmberELEC | RetroArch | `/storage/.config/retroarch/retroarch.cfg`, else `/storage/roms/bios` | +| `/usr/bin/batocera-info` naming PAN4ELEC, on AmberELEC | PAN4ELEC | RetroArch | as AmberELEC | +| `/etc/os-release` `NAME=EmuELEC` | EmuELEC | RetroArch | as AmberELEC | +| `/etc/os-release` `OS_NAME=JELOS` | JELOS | RetroArch | as AmberELEC | +| `/usr/lib/systemd/system/jelos-ready.service`, on JELOS | KOLOS | RetroArch | as AmberELEC | +| `/etc/os-release` `OS_NAME=UnofficialOS` | UnofficialOS | RetroArch | as AmberELEC | +| `/etc/os-release` `OS_NAME=ArchR` | ArchR | RetroArch | as AmberELEC | +| `/home/ark` and `/opt/system`, or `title=ArkOS` in `/usr/share/plymouth/themes/text.plymouth` | ArkOS | RetroArch | `/home/ark/.config/retroarch/retroarch.cfg` then `retroarch32/retroarch.cfg`, else `/roms/bios` | +| `title=dArkOS` in the same file (the dArkOSRE, dArkOSen and ArkOS4Clone images keep it) | dArkOS | RetroArch | as ArkOS | +| `title=RetroOZ` in the same file, `/opt/.retrooz` or `/etc/retrooz_version` | RetroOZ | RetroArch | `/home/odroid/.config/retroarch/retroarch.cfg` then `retroarch32/retroarch.cfg`, else `/roms/bios` | +| `/mnt/SDCARD/spruce` | spruceOS | RetroArch | `/mnt/SDCARD/Saves/ra-configs/retroarch-*.cfg`, else `/mnt/SDCARD/BIOS` | +| `/mnt/SDCARD/System/usr/trimui/crossmix-version.txt` | CrossMix-OS | RetroArch | `/mnt/SDCARD/RetroArch/retroarch.cfg`, else `/mnt/SDCARD/BIOS` | +| `/mnt/SDCARD/.tmp_update/onionVersion/version.txt` | Onion | RetroArch | `/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg`, else `/mnt/SDCARD/BIOS` | +| `/mnt/SDCARD/.allium` | Allium | RetroArch | as Onion | +| `/mnt/SDCARD/Koriki/version.txt` | Koriki | RetroArch | as Onion | +| `/etc/main` and `/usr/bin/miyooctl` | MiyooCFW | RetroArch | `/mnt/.retroarch/retroarch.cfg`, else `/mnt/.retroarch/system` | +| `/mnt/plumos-user` | plumOS | RetroArch | `/mnt/plumos-user/bios`, which its launcher appends to every RetroArch run | +| `/etc/os-release` `ID=plumos` without the folder above | plumOS | none | its builds choose a folder per launch: named, not installed to | +| `/usr/trimui` | TrimUI | none | no published source names the stock firmware's folder: named, not installed to | +| `/mnt/SDCARD/.system/res` or `/mnt/sdcard/.system/res` | MinUI | none | MinUI, MyMinUI and NextUI give each core its own `Bios//`, a layout not written yet: named, not installed to | +| `/mnt/vendor/bin/dmenu.bin` | Anbernic stock firmware | RetroArch | `/mnt/mmc/bios` | + +AmberELEC, EmuELEC and KNULLI get the file list of the platform whose cores +they ship until a list of their own is published. TheRA is not detected: no +image or source of its Odroid Go builds is published, so neither its marker +files nor its folder could be read. Batocera, Recalbox, ROCKNIX and Lakka also name the board they were built for, and that board narrows the install unless `--target` or `--every-board` says