From 3799d4a1a434ea272373ce2b55127f4256f1eee8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:55:39 +0200 Subject: [PATCH] fix: hold installer detection to the registry --- install.ps1 | 2 +- install.py | 73 +++++++++++++++-------------------------- install.sh | 2 +- install/batocera.json | 6 ++++ install/recalbox.json | 6 ++++ install/retroarch.json | 27 +++++++++++++++ install/rocknix.json | 5 ++- platforms/_registry.yml | 32 ++++++++++++++++-- tests/test_install.py | 45 ++++++++++++++++++++++--- 9 files changed, 139 insertions(+), 59 deletions(-) diff --git a/install.ps1 b/install.ps1 index acc331ca..a3f7ad4b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "8faaf944ff4e0cc23e9049299145077567163837eaabdd65c530554980080ed7" +$defaultInstallSha256 = "236441baeb94f442c15a93ac01a203ce63f2938553b491db13dfb19074bbef46" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index d66b3783..8eed9f68 100644 --- a/install.py +++ b/install.py @@ -621,55 +621,34 @@ def _parse_ps1_var(path: Path, key: str) -> str | None: return None +# Embedded systems, first match wins: Knulli also carries the Batocera +# markers. The registry publishes the same rules as each manifest's +# `detect`; a test holds the two equal. MiSTer is a path rule below. +_EMBEDDED_RULES: tuple[tuple[str, str, tuple[str, ...], str], ...] = ( + ("rocknix", "os_release", ("rocknix",), "/storage/roms/bios"), + ("batocera", "file_exists", ("/etc/knulli-release",), "/userdata/bios"), + ("lakka", "os_release", ("lakka",), "/storage/system"), + ("batocera", "file_exists", ("/etc/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",), "/mnt/mmc/MUOS/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"), +) + + def _detect_embedded() -> list[tuple[str, Path]]: """Check for embedded Linux retrogaming OSes.""" - found: list[tuple[str, Path]] = [] - os_release = _parse_os_release() - os_id = os_release.get("ID", "").lower() - - if os_id == "rocknix": - found.append(("rocknix", Path("/storage/roms/bios"))) - return found - - # MiSTer keeps the SD card at /media/fat with the main binary at its root - # (Main_MiSTer file_io.cpp:1150-1156), and cores read from games/ - # (file_io.h:168) - if Path("/media/fat/MiSTer").exists(): - found.append(("misterfpga", Path("/media/fat/games"))) - return found - - if Path("/etc/knulli-release").exists(): - found.append(("batocera", Path("/userdata/bios"))) - return found - - if os_id == "lakka": - found.append(("lakka", Path("/storage/system"))) - return found - - if Path("/etc/batocera-version").exists(): - found.append(("batocera", Path("/userdata/bios"))) - return found - - if ( - Path("/recalbox/recalbox.version").exists() - or Path("/usr/bin/recalbox-settings").exists() - ): - found.append(("recalbox", Path("/recalbox/share/bios"))) - return found - - if Path("/opt/muos").exists() or Path("/mnt/mmc/MUOS/").exists(): - found.append(("retroarch", Path("/mnt/mmc/MUOS/bios"))) - return found - - if Path("/home/ark").exists() and Path("/opt/system").exists(): - found.append(("retroarch", Path("/roms/bios"))) - return found - - if Path("/mnt/vendor/bin/dmenu.bin").exists(): - found.append(("retroarch", Path("/mnt/mmc/bios"))) - return found - - return found + os_id = _parse_os_release().get("ID", "").lower() + for platform_name, method, values, bios in _EMBEDDED_RULES: + if method == "os_release": + matched = os_id in values + else: + matched = all(Path(value).exists() for value in values) + if matched: + return [(platform_name, Path(bios))] + return [] def _android_storage_root() -> Path: diff --git a/install.sh b/install.sh index 79362611..39c5d91c 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="8faaf944ff4e0cc23e9049299145077567163837eaabdd65c530554980080ed7" +DEFAULT_INSTALL_SHA256="236441baeb94f442c15a93ac01a203ce63f2938553b491db13dfb19074bbef46" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/batocera.json b/install/batocera.json index 29e0d300..3bb36e1d 100644 --- a/install/batocera.json +++ b/install/batocera.json @@ -8,6 +8,12 @@ "generated": "2026-10-18T11:24:19Z", "base_destination": "bios", "detect": [ + { + "os": "linux", + "method": "file_exists", + "file": "/etc/knulli-release", + "bios_path": "/userdata/bios" + }, { "os": "linux", "method": "file_exists", diff --git a/install/recalbox.json b/install/recalbox.json index de0712e6..79666eb2 100644 --- a/install/recalbox.json +++ b/install/recalbox.json @@ -8,6 +8,12 @@ "generated": "2026-10-18T11:24:19Z", "base_destination": "bios", "detect": [ + { + "os": "linux", + "method": "file_exists", + "file": "/recalbox/recalbox.version", + "bios_path": "/recalbox/share/bios" + }, { "os": "linux", "method": "file_exists", diff --git a/install/retroarch.json b/install/retroarch.json index 34e70744..6182b531 100644 --- a/install/retroarch.json +++ b/install/retroarch.json @@ -38,6 +38,33 @@ "method": "config_file", "config": "%APPDATA%\\RetroArch\\retroarch.cfg", "parse_key": "system_directory" + }, + { + "os": "linux", + "method": "file_exists", + "file": "/opt/muos", + "bios_path": "/mnt/mmc/MUOS/bios" + }, + { + "os": "linux", + "method": "file_exists", + "file": "/mnt/mmc/MUOS", + "bios_path": "/mnt/mmc/MUOS/bios" + }, + { + "os": "linux", + "method": "file_exists", + "file": [ + "/home/ark", + "/opt/system" + ], + "bios_path": "/roms/bios" + }, + { + "os": "linux", + "method": "file_exists", + "file": "/mnt/vendor/bin/dmenu.bin", + "bios_path": "/mnt/mmc/bios" } ], "standalone_copies": [ diff --git a/install/rocknix.json b/install/rocknix.json index 2dcf635b..a3c35dfe 100644 --- a/install/rocknix.json +++ b/install/rocknix.json @@ -10,9 +10,8 @@ "detect": [ { "os": "linux", - "method": "file_exists", - "file": "/etc/os-release", - "match": "ID=rocknix", + "method": "os_release", + "id": "rocknix", "bios_path": "/storage/roms/bios" } ], diff --git a/platforms/_registry.yml b/platforms/_registry.yml index 271195b5..84b9be78 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -187,6 +187,24 @@ platforms: method: config_file config: '%APPDATA%\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). + - os: linux + method: file_exists + file: /opt/muos + bios_path: /mnt/mmc/MUOS/bios + - os: linux + method: file_exists + file: /mnt/mmc/MUOS + bios_path: /mnt/mmc/MUOS/bios + - os: linux + method: file_exists + file: [/home/ark, /opt/system] + bios_path: /roms/bios + - os: linux + method: file_exists + file: /mnt/vendor/bin/dmenu.bin + bios_path: /mnt/mmc/bios standalone_copies: *standalone_copies batocera: config: batocera.yml @@ -202,6 +220,11 @@ platforms: target_source: https://github.com/batocera-linux/batocera.linux install: detect: + # Knulli is a Batocera derivative and carries its markers too. + - os: linux + method: file_exists + file: /etc/knulli-release + bios_path: /userdata/bios - os: linux method: file_exists file: /etc/batocera-version @@ -223,6 +246,10 @@ platforms: target_source: https://gitlab.com/recalbox/recalbox/-/tree/master/configs install: detect: + - os: linux + method: file_exists + file: /recalbox/recalbox.version + bios_path: /recalbox/share/bios - os: linux method: file_exists file: /usr/bin/recalbox-settings @@ -388,9 +415,8 @@ platforms: install: detect: - os: linux - method: file_exists - file: /etc/os-release - match: 'ID=rocknix' + method: os_release + id: rocknix bios_path: /storage/roms/bios retropie: config: retropie.yml diff --git a/tests/test_install.py b/tests/test_install.py index eed1fff3..24bd3737 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -264,10 +264,15 @@ class TestEmbeddedDetection(unittest.TestCase): install._parse_os_release = real_parse def test_mister_detected_from_main_binary(self): - self.assertEqual( - self._detect_with({"/media/fat/MiSTer"}), - [("misterfpga", Path("/media/fat/games"))], - ) + # A path rule of the registry, read by _detect_by_path. + real_exists = install.Path.exists + install.Path.exists = lambda self: str(self) == "/media/fat/MiSTer" + try: + found: list = [] + install._detect_by_path("linux", found) + finally: + install.Path.exists = real_exists + self.assertEqual(found, [("misterfpga", Path("/media/fat/games"))]) def test_rocknix_wins_over_path_probes(self): self.assertEqual( @@ -940,6 +945,38 @@ class DetectionFollowsTheRegistry(unittest.TestCase): } self.assertEqual(set(install._PATH_RULES), rules) + def test_the_embedded_table_is_the_registry(self): + """Only the path rules were compared: Knulli, muOS, ArkOS and the + Recalbox version file were detected by the installer and absent + from every manifest's `detect`, and ROCKNIX was declared another + way. A changed bios_path in the registry reached no installer.""" + import yaml # noqa: PLC0415 + + registry = yaml.safe_load( + (Path(__file__).resolve().parents[1] / "platforms" / "_registry.yml").read_text() + ) + rules = set() + 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", (rule["id"],), rule["bios_path"])) + 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) + + def test_the_first_embedded_match_wins(self): + with unittest.mock.patch.object(install, "_parse_os_release", return_value={}), \ + unittest.mock.patch.object( + Path, "exists", + lambda p: str(p) in ("/etc/knulli-release", "/etc/batocera-version"), + ): + self.assertEqual(install._detect_embedded(), [("batocera", Path("/userdata/bios"))]) + with unittest.mock.patch.object(install, "_parse_os_release", return_value={"ID": "ROCKNIX"}): + self.assertEqual( + install._detect_embedded(), [("rocknix", Path("/storage/roms/bios"))] + ) + def _home(self) -> Path: home = Path(_tempdir(self)) patcher = unittest.mock.patch.dict(os.environ, {"HOME": str(home), "USERPROFILE": str(home)})