feat: detect handheld systems by their own files

AmberELEC, EmuELEC, JELOS, KOLOS, UnofficialOS, ArchR, PAN4ELEC, dArkOS,
RetroOZ, spruceOS, CrossMix-OS, Onion, Allium, Koriki, MiyooCFW and plumOS
are found by what their builds write, named as they name themselves, and
installed to the system_directory of their RetroArch config, which follows
ArkOS and dArkOS to a second card. TrimUI stock, MinUI and a plumOS build
without a known folder are named and not installed to. Two unrelated
systems matching stop a run without a terminal.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 03:29:41 +02:00
1 parent ff2137de9d
commit e27a583975
6 files changed
+897 -142

No files matched your search

+359 -67
View File
@@ -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
# (<date>)", 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-<platform>.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 <card>/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/<tag>/ (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/<tag>/ 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)