chore: merge the per-probe detection guard

This commit is contained in:
Abdessamad Derraz committed 2026-10-24 02:25:44 +02:00
commit ff7db8b5fd
7 files changed
+387 -83

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "23f334efb9e37ba1c2542b8599db7af6e67dfd5e0061d39cb507765f2594bd45"
$defaultInstallSha256 = "bb0a0a88e16a6fb7aa93ec0a2b4cb5f1d667faacd50830aae9ce763e60d5d112"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+165 -80
View File
@@ -511,6 +511,11 @@ EVENT_SCHEMA = 1
EVENT_KEYS: dict[str, tuple[str, ...]] = {
# mode: install, check, dry-run or list.
"start": ("schema", "installer", "mode"),
# A folder detection could not look in, once each, before detect: the
# probes under it counted as absent and the others went on. reason is
# why in words (Permission denied). A kind of its own, since schema 1
# never adds a key to detect, and not an error: the run goes on.
"unsearched": ("folder", "reason", "message"),
# platforms: [{"platform", "name", "bios_dir"}], empty when none is found.
"detect": ("os", "os_name", "platforms"),
# stage: manifest (reason: network, not_found, invalid, clock for a
@@ -844,7 +849,7 @@ def detect_os() -> str:
if _is_android():
return "android"
proc_version = Path("/proc/version")
if proc_version.exists():
if _exists(proc_version):
try:
content = proc_version.read_text(encoding="utf-8", errors="replace")
if "microsoft" in content.lower():
@@ -863,7 +868,7 @@ def _parse_os_release() -> dict[str, str]:
"""Parse /etc/os-release KEY=value format."""
result: dict[str, str] = {}
path = _on_device("/etc/os-release")
if not path.exists():
if not _exists(path):
return result
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
@@ -964,7 +969,7 @@ def _steam_libraries() -> list[Path]:
roots: list[Path] = []
for var in ("ProgramFiles(x86)", "ProgramFiles"):
base = os.environ.get(var, "")
if base and (Path(base) / "Steam").is_dir():
if base and _is_dir(Path(base) / "Steam"):
roots.append(Path(base) / "Steam")
libraries = list(roots)
for root in roots:
@@ -1015,7 +1020,7 @@ def _windows_retroarch_dirs() -> list[Path]:
candidates += [
Path(root) / "apps" / "retroarch" / "current" for root in scoop_roots if root
]
return [folder for folder in candidates if (folder / "retroarch.exe").is_file()]
return [folder for folder in candidates if _is_file(folder / "retroarch.exe")]
def _macos_retroarch_base() -> Path:
@@ -1025,13 +1030,13 @@ def _macos_retroarch_base() -> Path:
HAVE_STEAM portable = YES`), with no portable.txt to say so: its
bundle under Steam's library, and no other RetroArch.app, mark it."""
support = Path.home() / "Library" / "Application Support" / "RetroArch"
if (support / "portable.txt").is_file():
if _is_file(support / "portable.txt"):
return support
steam = (
Path.home() / "Library" / "Application Support" / "Steam" / "steamapps"
/ "common" / "RetroArch" / "RetroArch.app"
)
if steam.is_dir() and not Path("/Applications/RetroArch.app").is_dir():
if _is_dir(steam) and not _is_dir(Path("/Applications/RetroArch.app")):
return support
return Path.home() / "Documents" / "RetroArch"
@@ -1051,7 +1056,7 @@ def _parse_retroarch_system_dir(
install keeps both. home is what '~' means to that RetroArch, the user's
home when not given.
"""
if not cfg_path.exists():
if not _exists(cfg_path):
return None
app_dir = app_dir or cfg_path.parent
system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system", home)
@@ -1100,7 +1105,7 @@ def _shell_unquote(value: str) -> str:
def _parse_bash_var(path: Path, key: str) -> str | None:
"""Extract the effective value of key= from a bash/shell file."""
if not path.exists():
if not _exists(path):
return None
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
@@ -1166,7 +1171,7 @@ def detect_board(platform: str) -> str | None:
def _parse_json_path(path: Path, *keys: str) -> str | None:
"""Extract a nested string value from a JSON file."""
if not path.exists():
if not _exists(path):
return None
try:
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
@@ -1179,7 +1184,7 @@ def _parse_json_path(path: Path, *keys: str) -> str | None:
def _parse_ps1_var(path: Path, key: str) -> str | None:
"""Extract value of $key= or key= from a PowerShell file."""
if not path.exists():
if not _exists(path):
return None
normalized = key.lstrip("$")
try:
@@ -1459,7 +1464,7 @@ def _config_files(pattern: str) -> list[tuple[Path, Path]]:
return [(_on_device(device), Path(folder))]
try:
return [(path, Path(folder)) for path in sorted(_on_device(folder).glob(name))
if path.is_file()]
if _is_file(path)]
except OSError:
return []
@@ -1594,6 +1599,25 @@ def _android_cfg_value(cfg: Path, key: str) -> str | None:
return None
# Set while detection runs on this thread: a probe this user may not make
# is then said once, and the probes of other threads (the files being
# hashed) stay silent.
_SETUP_SEARCH = threading.local()
@contextlib.contextmanager
def _refusals_expected() -> Iterator[None]:
"""Probes whose refusal is the answer, not a folder to report: another
app's private data on Android, which any app may test for existence and
none may enter, has a line of its own (android_unreachable_folders)."""
outer = getattr(_SETUP_SEARCH, "active", False)
_SETUP_SEARCH.active = False
try:
yield
finally:
_SETUP_SEARCH.active = outer
def _android_play_build(package: str, storage: Path, cfgs: tuple[Path, ...]) -> bool | None:
"""True for the Google Play build of this package, False for the
retroarch.com one, None when nothing it wrote tells them apart.
@@ -1609,9 +1633,9 @@ def _android_play_build(package: str, storage: Path, cfgs: tuple[Path, ...]) ->
code = _android_cfg_value(cfg, "bundle_assets_extract_version_current")
if code and code.isdigit() and int(code) > 0:
return int(code) < _ANDROID_PLAY_VERSION_CEILING
if (storage / "Android" / "data" / package / "files" / "RetroArch").is_dir():
if _is_dir(storage / "Android" / "data" / package / "files" / "RetroArch"):
return True
if (storage / "RetroArch").is_dir():
if _is_dir(storage / "RetroArch"):
return False
return None
@@ -1621,8 +1645,8 @@ def _android_writable(folder: Path) -> bool:
writable. Android 11 and later keep Android/data/<package> of other apps
from Termux."""
for parent in (folder, *folder.parents):
if parent.exists():
return parent.is_dir() and os.access(parent, os.W_OK)
if _exists(parent):
return _is_dir(parent) and os.access(parent, os.W_OK)
return False
@@ -1632,6 +1656,7 @@ def _android_out_of_reach(name: str, folder: Path, what: str) -> str:
f"{folder} without root: {what} are not copied there.")
@_refusals_expected()
def _android_retroarch_builds() -> list[tuple[Path | None, str]]:
"""Each installed RetroArch build: its system folder when this shell can
write it, else None and the line that says why.
@@ -1650,7 +1675,7 @@ def _android_retroarch_builds() -> list[tuple[Path | None, str]]:
builds: list[tuple[Path | None, str]] = []
for package in ANDROID_RETROARCH_PACKAGES:
external = storage / "Android" / "data" / package / "files"
if not (ANDROID_APP_DATA / package).exists() and not external.exists():
if not _exists(ANDROID_APP_DATA / package) and not _exists(external):
continue
cfgs = (external / "retroarch.cfg",
ANDROID_APP_DATA / package / "files" / "retroarch.cfg")
@@ -1693,7 +1718,7 @@ def _detect_android() -> list[tuple[str, Path]]:
if folder is not None and ("retroarch", folder) not in found:
found.append(("retroarch", folder))
default_root = _android_storage_root() / "RetroArch"
if not builds and default_root.exists():
if not builds and _exists(default_root):
found.append(("retroarch", default_root / "system"))
return found
@@ -1715,6 +1740,7 @@ _ANDROID_APP_FOLDERS = (
)
@_refusals_expected()
def android_unreachable_folders() -> list[str]:
"""A line for each Android emulator installed here whose BIOS folder
this shell cannot write, said instead of skipping it in silence. A
@@ -1730,9 +1756,9 @@ def android_unreachable_folders() -> list[str]:
target = Path(folder.replace("$STORAGE", storage, 1))
if folder.startswith("/data/data/"):
target = ANDROID_APP_DATA / Path(folder).relative_to("/data/data")
if not (ANDROID_APP_DATA / package).exists() and not target.exists():
if not _exists(ANDROID_APP_DATA / package) and not _exists(target):
continue
if target.is_dir() and os.access(target, os.W_OK):
if _is_dir(target) and os.access(target, os.W_OK):
continue
lines.append(_android_out_of_reach(name, target, ", ".join(files)))
return lines
@@ -1780,7 +1806,7 @@ def launchbox_root(os_type: str) -> Path | None:
/ "LaunchBox"
/ "LaunchBox.lnk"
)
if lnk.exists():
if _exists(lnk):
target = _lnk_target(lnk, "LaunchBox.exe")
if target:
# The shortcut points at Core/LaunchBox.exe
@@ -1790,7 +1816,7 @@ def launchbox_root(os_type: str) -> Path | None:
if userprofile:
candidates.append(Path(userprofile) / "LaunchBox")
for root in candidates:
if (root / "Data" / "Emulators.xml").exists():
if _exists(root / "Data" / "Emulators.xml"):
return root
return None
@@ -1803,7 +1829,7 @@ def launchbox_emulators(emulators_xml: Path) -> dict[str, Path]:
the lowercase executable names.
"""
found: dict[str, Path] = {}
if not emulators_xml.exists():
if not _exists(emulators_xml):
return found
try:
if emulators_xml.stat().st_size > 10 * 1024 * 1024:
@@ -1823,7 +1849,7 @@ def launchbox_emulators(emulators_xml: Path) -> dict[str, Path]:
exe = Path(app_path)
if not exe.is_absolute():
exe = lb_root / exe
if exe.parent.is_dir():
if _is_dir(exe.parent):
found.setdefault(exe.name.lower(), exe.parent)
return found
@@ -1852,7 +1878,7 @@ def _pcsx2_bios_dir(emu_dir: Path) -> Path:
"""
documents = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "Documents"
portable_txt = emu_dir / "portable.txt"
portable = (emu_dir / "portable.ini").exists() or portable_txt.exists()
portable = _exists(emu_dir / "portable.ini") or _exists(portable_txt)
if portable:
subpath = ""
try:
@@ -1885,14 +1911,14 @@ def _xemu_bios_dir(emu_dir: Path) -> Path:
"""
appdata = os.environ.get("APPDATA", "")
toml = emu_dir / "xemu.toml"
if not toml.exists() and appdata:
if not _exists(toml) and appdata:
toml = Path(appdata) / "xemu" / "xemu" / "xemu.toml"
if toml.exists():
if _exists(toml):
try:
for line in toml.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith(("bootrom_path", "flashrom_path")):
parts = line.split("'")
if len(parts) > 1 and Path(parts[1]).exists():
if len(parts) > 1 and _exists(Path(parts[1])):
return Path(parts[1]).parent
except OSError:
pass
@@ -1914,7 +1940,7 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]:
if emu_dir:
dirs["xemu"] = _xemu_bios_dir(emu_dir)
emu_dir = emulators.get("dolphin.exe")
if emu_dir and (emu_dir / "portable.txt").exists():
if emu_dir and _exists(emu_dir / "portable.txt"):
# portable.txt beside the executable moves the user directory to
# User (SetUserDirectory in Source/Core/UICommon/UICommon.cpp,
# PORTABLE_USER_DIR in Source/Core/Common/CommonPaths.h)
@@ -1922,32 +1948,59 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]:
return dirs
_DETECTION_REFUSED: list[str] = []
# (folder, why in words) for each folder detection could not look in.
_DETECTION_REFUSED: list[tuple[Path, str]] = []
def refused_words(folder: Path, why: str, shown: str = "") -> str:
"""The line said for a folder detection could not look in. shown is
the folder as the screen writes it (~/.config/EmuDeck), the full path
by default."""
return (f"Could not look for setups: this user cannot open {shown or folder} ({why}). "
"Run as its owner (su - or sudo -H), or name the BIOS folder "
"with --dest.")
def _note_refused(path: Path, exc: OSError) -> None:
"""Say once that a folder detection looks in is closed to this user.
A probe refused there counts as absent and the others go on: a config
folder a sudo run left at 0700 (~/.config/EmuDeck) hid a RetroArch whose
retroarch.cfg was readable, and the run fell back to a folder it does
not read.
"""
if not getattr(_SETUP_SEARCH, "active", False) or not isinstance(exc, PermissionError):
return
refused = closed_folder(path) or (path, exc.strerror or str(exc))
if refused in _DETECTION_REFUSED:
return
_DETECTION_REFUSED.append(refused)
words = refused_words(*refused)
EVENTS.emit("unsearched", folder=str(refused[0]), reason=refused[1], message=words)
print(f" {screen_text(words)}", file=sys.stderr)
def _detection_guarded(detect: Callable[..., list]) -> Callable[..., list]:
"""Detection that finds nothing, and says why once, where this user
cannot look.
"""Detection whose probes treat a folder this user cannot open as
absent, and say so once.
HOME kept across `su` without `-`: every probe of a config under it
raised PermissionError, and the run ended on a traceback before its
first line. A named --dest still installs.
Each probe goes through _exists, _is_dir or _is_file, which records the
refusal. A probe that still raises PermissionError (a glob on a Python
whose pathlib lets it through) is the last net: the call finds nothing
and says why, where HOME kept across `su` without `-` once ended the
run on a traceback.
"""
@functools.wraps(detect)
def guarded(*args: object, **kwargs: object) -> list:
outer = getattr(_SETUP_SEARCH, "active", False)
_SETUP_SEARCH.active = True
try:
return detect(*args, **kwargs)
except PermissionError as exc:
closed = closed_folder(Path(exc.filename)) if exc.filename else None
folder, why = closed or (Path(str(exc.filename or "")), exc.strerror or str(exc))
words = (f"Could not look for setups: this user cannot open {folder} ({why}). "
"Run as its owner (su - or sudo -H), or name the BIOS folder "
"with --dest.")
if words not in _DETECTION_REFUSED:
_DETECTION_REFUSED.append(words)
print(f" {screen_text(words)}", file=sys.stderr)
_note_refused(Path(str(exc.filename or "")), exc)
return []
guarded.__doc__ = detect.__doc__
guarded.__name__ = detect.__name__
finally:
_SETUP_SEARCH.active = outer
return guarded
@@ -1961,7 +2014,7 @@ def detect_frontends(os_type: str, home: Path | None = None) -> list[str]:
home = home or Path.home()
frontends: list[str] = []
esde_env = os.environ.get("ESDE_APPDATA_DIR", "")
if (esde_env and Path(esde_env).is_dir()) or (home / "ES-DE").is_dir():
if (esde_env and _is_dir(Path(esde_env))) or _is_dir(home / "ES-DE"):
frontends.append("esde")
if launchbox_root(os_type) is not None:
frontends.append("launchbox")
@@ -2026,7 +2079,7 @@ def _detect_by_executable(os_type: str, found: list[tuple[str, Path]]) -> None:
base, _, rest = pattern.partition("/" if "/" in pattern else "\\")
glob = "/".join(part for part in re.split(r"[\\/]", rest) if part)
try:
hits.extend(path for path in _rule_path(base).glob(glob) if path.is_file())
hits.extend(path for path in _rule_path(base).glob(glob) if _is_file(path))
except OSError:
continue
if hits:
@@ -2088,7 +2141,7 @@ def _detect_by_registry(os_type: str, found: list[tuple[str, Path]]) -> None:
if not folder:
continue
base = _host_path(folder.rstrip("\\/") or folder)
if base.is_dir():
if _is_dir(base):
found.append((platform_name, base / subdir))
@@ -2098,7 +2151,7 @@ def _detect_by_path(os_type: str, found: list[tuple[str, Path]]) -> None:
for rule_os, platform_name, path, bios in _PATH_RULES:
if rule_os not in systems or any(name == platform_name for name, _ in found):
continue
if _rule_path(path).exists():
if _exists(_rule_path(path)):
found.append((platform_name, _rule_path(bios or path)))
@@ -2120,7 +2173,7 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
# dddb3022) reads $emudeckFolder/settings.sh on both, and vars.sh:2
# sets emudeckFolder to $HOME/.config/EmuDeck.
emudeck_settings = Path.home() / ".config" / "EmuDeck" / "settings.sh"
if emudeck_settings.exists():
if _exists(emudeck_settings):
emu_path = _parse_bash_var(emudeck_settings, "emulationPath")
if emu_path:
found.append(("emudeck", Path(emu_path) / "bios"))
@@ -2132,7 +2185,7 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
retrodeck_conf_dir = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck"
retrodeck_json = retrodeck_conf_dir / "retrodeck.json"
retrodeck_cfg = retrodeck_conf_dir / "retrodeck.cfg"
if retrodeck_json.exists():
if _exists(retrodeck_json):
rd_home = _parse_json_path(retrodeck_json, "paths", "rd_home_path")
if not rd_home:
# A home moved to an SD card lives only in this file: the
@@ -2145,7 +2198,7 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
rd_root = Path(rd_home) if rd_home else home / "retrodeck"
_record_moved_folders(rd_root, retrodeck_json)
found.append(("retrodeck", rd_root))
elif retrodeck_cfg.exists():
elif _exists(retrodeck_cfg):
rd_home = _parse_bash_var(retrodeck_cfg, "rdhome")
found.append(("retrodeck", Path(rd_home) if rd_home else home / "retrodeck"))
@@ -2261,7 +2314,7 @@ def _emudeck_windows_bios() -> Path | None:
"""
candidates = [Path(os.environ[var]) / "EmuDeck" / "settings.ps1"
for var in ("APPDATA", "USERPROFILE") if os.environ.get(var)]
settings = next((path for path in candidates if path.is_file()), None)
settings = next((path for path in candidates if _is_file(path)), None)
if settings is None:
return None
emu_path = _parse_ps1_var(settings, "$emulationPath")
@@ -2312,10 +2365,11 @@ def _bizhawk_unix_firmware() -> Path | None:
$XDG_DATA_HOME/emuhawk-monort-<version> (Dist/launch-scripts.nix).
"""
data_home = os.environ.get("BIZHAWK_DATA_HOME", "").strip()
if data_home and Path(data_home).is_dir():
if data_home and _is_dir(Path(data_home)):
return Path(data_home) / "Firmware"
xdg = os.environ.get("XDG_DATA_HOME", "").strip() or str(Path.home() / ".local" / "share")
homes = sorted(Path(xdg).glob("emuhawk-monort-*"), key=lambda p: p.stat().st_mtime)
homes = sorted((home for home in Path(xdg).glob("emuhawk-monort-*") if _is_dir(home)),
key=lambda home: home.stat().st_mtime)
return homes[-1] / "Firmware" if homes else None
@@ -2344,7 +2398,7 @@ def core_dirs(plat: str, bios_path: Path) -> list[Path]:
RetroArch's own directory.
"""
found = _retroarch_config_for(bios_path)
if found is None and (bios_path.parent / "retroarch.cfg").is_file():
if found is None and _is_file(bios_path.parent / "retroarch.cfg"):
found = (bios_path.parent / "retroarch.cfg", bios_path.parent)
dirs: list[Path] = []
if found is not None:
@@ -4196,28 +4250,29 @@ def _held(path: Path) -> bool:
return True
def _probed(probe: Callable[[], bool], path: Path) -> bool:
"""probe() for path, False where the place cannot be looked at. A
refusal met while looking for setups is said once (_note_refused)."""
try:
return probe()
except OSError as exc:
_note_refused(path, exc)
return False
def _exists(path: Path) -> bool:
"""Path.exists(), False where the place cannot be looked at."""
try:
return path.exists()
except OSError:
return False
return _probed(path.exists, path)
def _is_dir(path: Path) -> bool:
"""Path.is_dir(), False where the place cannot be looked at."""
try:
return path.is_dir()
except OSError:
return False
return _probed(path.is_dir, path)
def _is_file(path: Path) -> bool:
"""Path.is_file(), False where the place cannot be looked at."""
try:
return path.is_file()
except OSError:
return False
return _probed(path.is_file, path)
def closed_folder(path: Path) -> tuple[Path, str] | None:
@@ -9247,11 +9302,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
elif not listing:
# A system_directory under /root read from retroarch.cfg, run without
# sudo: the case probe raised PermissionError before any line.
for _name, path in platforms:
for name, path in platforms:
if closed_folder(path) is not None and len(platforms) > 1:
print(" Nothing was installed. Name one platform with --platform "
"to install it alone.", file=sys.stderr)
_refuse_closed(path, "detected here (name another with --dest)")
_refuse_closed(path, (
f"taken as {display_name(name)}'s default, {display_name(name)} "
"not being found here" if path in assumed_dests
else "detected here") + " (name another with --dest)")
total_downloaded = 0
total_up_to_date = 0
@@ -10173,6 +10231,8 @@ class TuiState:
# (platform, folder, label) for each setup found here.
self.setups: list = []
self.notes: list = []
# The folders detection could not look in, one line each.
self.refused: list = []
self.platform: str = ""
self.dest: Path | None = None
# A --dest given without --platform: the list asks whose files go there.
@@ -10927,9 +10987,19 @@ def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = No
if (folder is not None and state.browse_for != "from"
and folder in (state.home, Path(folder.anchor))):
# No frontend reads its BIOS files from HOME or a drive's root: A
# pressed through the screens installed into ~. A goes deeper there.
# pressed through the screens installed into ~. A goes into a
# visible subfolder there, never a card or drive listed first (A
# then took /mnt/d, the Windows D: drive, for the BIOS folder) nor
# .cache. With none, Use this folder keeps the focus and says where.
focus = next((index for index, row in enumerate(state.browse_rows)
if row.action == "dir" and row.value != folder.parent), 0)
if row.action == "dir" and isinstance(row.value, Path)
and row.label == f"{row.value.name}/"
and not row.value.name.startswith(".")), 0)
if not focus and not state.status:
state.status = ("This is your home folder: no setup reads its BIOS files "
"here. Go back to type a path." if folder == state.home
else "This is the root of a drive: no setup reads its BIOS "
"files here. Go back to type a path.")
for index, row in enumerate(state.browse_rows):
if came_from is not None and row.action == "dir" and row.value == came_from:
focus = index
@@ -11663,14 +11733,25 @@ def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> l
return lines[first:end] + [_styled(where, "dim")]
# The rows a screen keeps for its list under pinned lines.
TUI_PINNED_ROWS = 4
def _compose(
state: TuiState, width: int, height: int, before: list, rows: list[TuiRow],
after: list = (), footer: list = (),
after: list = (), footer: list = (), pinned: list = (),
) -> list[TuiLine]:
"""Text, then the rows, then more text, in height lines. The rows are
always shown, scrolled around the focus when they do not fit."""
always shown, scrolled around the focus when they do not fit. pinned
comes first and stays when the rows fill the screen, leaving them
TUI_PINNED_ROWS lines at least."""
footer_lines = _text_lines(list(footer), width)
room = max(1, height - len(footer_lines))
top_lines = _text_lines(list(pinned), width)[:max(0, room - TUI_PINNED_ROWS)]
if top_lines:
top_lines.append(_styled(""))
room -= len(top_lines)
return top_lines + _compose(state, width, room, before, rows, after) + footer_lines
focus = max(0, min(state.focus.get(state.screen, 0), len(rows) - 1)) if rows else 0
row_lines = [_row_line(row, index == focus, width) for index, row in enumerate(rows)]
if len(row_lines) >= room:
@@ -11877,7 +11958,8 @@ def _view_setup(state: TuiState, width: int, height: int) -> tuple:
before = ["Found on this machine:" if state.setups else "Nothing was found here."]
status = [state.status] if state.status else []
return ("Where do you play?",
_compose(state, width, height, before, rows, after + status + state.notes),
_compose(state, width, height, before, rows, after + status + state.notes,
pinned=state.refused),
("A/Enter Continue", "B/Esc Quit", "? Help"))
@@ -11892,7 +11974,7 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple:
needle = state.filters.get("platforms", "")
after = [f"No setup matches '{needle}'."] if len(rows) == 1 and needle else []
after += [state.status] if state.status else []
return (title, _compose(state, width, height, before, rows, after),
return (title, _compose(state, width, height, before, rows, after, pinned=state.refused),
("A/Enter Choose", "B/Esc Back", "/ Filter", "? Help"))
@@ -11909,7 +11991,8 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple:
title = "Which folder?"
before = [f"No usual {name} folder here: browse for it or type it."]
after = [state.status] if state.status else []
return (title, _compose(state, width, height, before, tui_rows(state), after),
return (title, _compose(state, width, height, before, tui_rows(state), after,
pinned=state.refused),
("A/Enter Choose", "B/Esc Back", "? Help"))
@@ -12615,9 +12698,11 @@ def tui_start(
found = detect_platforms(os_type)
frontends = detect_frontends(os_type)
cards = [(card_system(mount), mount) for mount in _mounted_cards()]
if _DETECTION_REFUSED:
# Said on the normal screen before this one covered it.
state.status = _DETECTION_REFUSED[0]
# Said on the normal screen before this one covered it, and kept
# above the list: a status line under 18 setups was never drawn.
state.refused = [refused_words(folder, why, _short_path(folder, state.home))
for folder, why in _DETECTION_REFUSED]
unsaid = {refused_words(*refused) for refused in _DETECTION_REFUSED}
state.setups = [(name, path, _setup_label(name, path)) for name, path in found]
# On the device itself its own card is the system found, by its
# own path (muOS reads /run/muos/storage/bios, a bind of MUOS/bios).
@@ -12632,7 +12717,7 @@ def tui_start(
state.notes.append("ES-DE found: its BIOS files go to each emulator, "
"not to ES-DE itself.")
state.notes += [line.strip() for line in captured.getvalue().splitlines()
if line.strip()]
if line.strip() and line.strip() not in unsaid]
if plat and args.dest is not None:
state.stack = []
return state, _choose(state, plat, Path(args.dest))
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="23f334efb9e37ba1c2542b8599db7af6e67dfd5e0061d39cb507765f2594bd45"
DEFAULT_INSTALL_SHA256="bb0a0a88e16a6fb7aa93ec0a2b4cb5f1d667faacd50830aae9ce763e60d5d112"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+28
View File
@@ -11,6 +11,7 @@
"event": {
"enum": [
"start",
"unsearched",
"detect",
"error",
"scanned",
@@ -35,6 +36,9 @@
{
"$ref": "#/$defs/start"
},
{
"$ref": "#/$defs/unsearched"
},
{
"$ref": "#/$defs/detect"
},
@@ -117,6 +121,30 @@
}
}
},
"unsearched": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"folder",
"reason",
"message"
],
"properties": {
"event": {
"const": "unsearched"
},
"folder": {
"type": "string"
},
"reason": {
"type": "string"
},
"message": {
"type": "string"
}
}
},
"detect": {
"type": "object",
"additionalProperties": false,
+78
View File
@@ -665,6 +665,84 @@ class AHomeThisUserCannotOpen(unittest.TestCase):
self.assertIn(f"Could not look for setups: this user cannot open {home}",
runs["detected"].stderr)
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_an_assumed_folder_is_not_called_detected(self):
"""The refusal ended "than the one detected here" for the built-in
default taken because RetroArch was not detected."""
with FakeRepository() as repo:
home = repo.root / "home"
home.mkdir()
env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux"}
os.chmod(home, 0o000)
try:
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--check",
"--no-input", "--platform", "retroarch"],
env=env, capture_output=True, text=True, timeout=60,
stdin=subprocess.DEVNULL)
finally:
os.chmod(home, 0o755)
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
said = " ".join(proc.stderr.split())
self.assertNotIn("the one detected here", said)
self.assertIn("than the one taken as RetroArch's default, RetroArch not being "
"found here (name another with --dest).", said)
class APartlyClosedHome(unittest.TestCase):
"""~/.config/EmuDeck at 000 (a sudo run leaves it owned by root) made
detection return nothing: the readable retroarch.cfg beside it was
never read, and the run fell back to ~/.config/retroarch/system, a
folder that RetroArch does not read."""
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_retroarch_is_found_past_the_closed_folder(self):
with FakeRepository() as repo:
home = repo.root / "home"
closed = home / ".config" / "EmuDeck"
closed.mkdir(parents=True)
cfg = home / ".config" / "retroarch" / "retroarch.cfg"
cfg.parent.mkdir()
system = repo.root / "elsewhere" / "system"
system.mkdir(parents=True)
cfg.write_text(f'system_directory = "{system}"\n')
env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux"}
runs = {}
os.chmod(closed, 0o000)
try:
for name, extra in (("detected", []),
("named", ["--platform", "retroarch"]),
("events", ["--events"])):
runs[name] = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--check",
"--no-input", *extra],
env=env, capture_output=True, text=True, timeout=60,
stdin=subprocess.DEVNULL)
finally:
os.chmod(closed, 0o755)
refusal = (f"Could not look for setups: this user cannot open {closed} "
"(Permission denied)")
for name, proc in runs.items():
with self.subTest(run=name):
self.assertNotIn("Traceback", proc.stderr)
self.assertEqual(" ".join(proc.stderr.split()).count(refusal), 1,
proc.stderr)
self.assertNotIn(str(home / ".config" / "retroarch" / "system"),
proc.stdout + proc.stderr)
self.assertIn(f"Found RetroArch at {system}", runs["detected"].stdout)
self.assertIn("0/5 present", runs["named"].stdout)
self.assertNotIn("not detected", runs["named"].stdout)
events = [json.loads(line) for line in runs["events"].stdout.splitlines()]
kinds = [event["event"] for event in events]
self.assertLess(kinds.index("unsearched"), kinds.index("detect"))
unsearched = events[kinds.index("unsearched")]
self.assertEqual((unsearched["folder"], unsearched["reason"]),
(str(closed), "Permission denied"))
detect = events[kinds.index("detect")]
self.assertEqual([entry["bios_dir"] for entry in detect["platforms"]], [str(system)])
class OutputToAFileAsksNothing(unittest.TestCase):
"""`install.py > log.txt` at a terminal: the menu went into the file
+106
View File
@@ -2967,6 +2967,112 @@ class AFolderThisUserCannotOpen(unittest.TestCase):
def test_the_nearest_folder_stops_above_it(self):
self.assertEqual(install._nearest_folder(self.dest / "deeper"), self.lock)
def test_a_closed_folder_under_home_hides_nothing_else(self):
"""~/.config/EmuDeck at 000 made detection return nothing, and the
folder screen offered ~/.config/retroarch/system, which this
RetroArch does not read: its retroarch.cfg names another."""
home = self.lock.parent / "home"
closed = home / ".config" / "EmuDeck"
closed.mkdir(parents=True)
cfg = home / ".config" / "retroarch" / "retroarch.cfg"
cfg.parent.mkdir()
system = self.lock.parent / "elsewhere" / "system"
system.mkdir(parents=True)
cfg.write_text(f'system_directory = "{system}"\n')
os.chmod(closed, 0o000)
self.addCleanup(os.chmod, closed, 0o755)
install._DETECTION_REFUSED.clear()
self.addCleanup(install._DETECTION_REFUSED.clear)
with mock.patch.dict(os.environ, {"HOME": str(home)}), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_RETROARCH_CONFIGS", {}):
state, _effects = install.tui_start(_args(), "linux")
self.assertEqual([setup[:2] for setup in state.setups], [("retroarch", system)])
screen = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
self.assertEqual(screen.count("Could not look for setups: this user cannot open "
"~/.config/EmuDeck (Permission denied)"), 1, screen)
def test_the_refusal_is_drawn_above_a_full_list(self):
"""With a closed HOME the first screen is the 18 setups: the line,
drawn under them, was dropped at 80x24 and at 40x15."""
home = self.lock.parent / "home"
home.mkdir()
os.chmod(home, 0o000)
self.addCleanup(os.chmod, home, 0o755)
install._DETECTION_REFUSED.clear()
self.addCleanup(install._DETECTION_REFUSED.clear)
with mock.patch.dict(os.environ, {"HOME": str(home)}), \
mock.patch.object(install, "_mounted_cards", return_value=[]):
state, _effects = install.tui_start(_args(), "linux")
self.assertEqual(state.screen, "platforms")
for cols, rows in ((80, 24), (40, 15)):
with self.subTest(size=f"{cols}x{rows}"):
lines = install.tui_view(state, cols, rows)
self.assertEqual(len(lines), rows)
screen = " ".join(" ".join(lines).split())
self.assertIn("Could not look for setups", screen)
self.assertIn("name the BIOS folder with --dest.", screen)
# The list keeps its focused row in view.
self.assertIn("RetroArch", screen)
class AGoesIntoASubfolder(unittest.TestCase):
"""At HOME or a drive root A went into the first "dir" row: a mounted
card or drive listed first (/mnt/d, the Windows D: drive, then took
the BIOS files at its root) or .cache."""
def setUp(self):
self.home = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, self.home, True)
self.card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, self.card, True)
self.state = install.TuiState(os_type="linux", home=self.home)
self.state.platform = "retroarch"
self.state.stack = ["folder", "browse"]
def focused(self) -> install.TuiRow:
return self.state.browse_rows[self.state.focus["browse"]]
def test_a_card_and_cache_are_passed_over(self):
(self.home / ".cache").mkdir()
(self.home / "Games").mkdir()
with mock.patch.object(install, "_mounted_cards", return_value=[self.card]):
install._browse_to(self.state, self.home)
self.assertEqual(self.state.browse_rows[2].value, self.card)
self.assertEqual(self.focused().label, "Games/")
self.assertEqual(self.state.status, "")
def test_with_no_visible_subfolder_use_stays_and_says_where(self):
(self.home / ".cache").mkdir()
(self.home / ".config").mkdir()
with mock.patch.object(install, "_mounted_cards", return_value=[self.card]):
install._browse_to(self.state, self.home)
self.assertEqual(self.focused().action, "use")
self.assertIn("This is your home folder", self.state.status)
screen = " ".join(" ".join(install.tui_view(self.state, 80, 24)).split())
self.assertIn("This is your home folder: no setup reads its BIOS files here.",
screen)
def test_on_windows_the_other_drives_are_passed_over(self):
"""The card rows are drive roots there (E:\\): at D:\\ the first
was E:\\, and at E:\\ it was D:\\."""
(self.home / "Games").mkdir()
drives = [Path("D:\\"), Path("E:\\")]
windows = WindowsOs()
windows.path = types.SimpleNamespace(
**{name: getattr(os.path, name) for name in dir(os.path)
if not name.startswith("_")})
windows.path.exists = lambda path: str(path) in ("C:\\", "D:\\", "E:\\")
with mock.patch.object(install, "os", windows), \
mock.patch.object(install, "_mounted_cards", return_value=drives):
install._browse_to(self.state, self.home)
self.assertEqual(self.focused().label, "Games/")
# The drives list keeps its first drive focused.
install._browse_to(self.state, None)
self.assertEqual([row.label for row in self.state.browse_rows],
["C:\\", "D:\\", "E:\\"])
self.assertEqual(self.state.focus["browse"], 0)
# POSIX modules a Windows Python does not have: importing one raises there.
POSIX_ONLY = ("termios", "tty", "pty", "fcntl", "select", "resource", "pwd", "grp")
+8 -1
View File
@@ -1288,6 +1288,7 @@ other schemas under `api/v1/schemas/`.
| Kind | Keys | When |
|------|------|------|
| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` |
| `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence |
| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found |
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). `message` says it in words |
| `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered |
@@ -1306,7 +1307,7 @@ other schemas under `api/v1/schemas/`.
| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing |
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`), never a key of an
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder), never a key of an
existing kind: a reader skips a kind it does not know. An option refused
before the run starts (`install.py: error:`, code 2) writes
no event at all. A program reads a missing `end` as that case, or as a
@@ -1388,6 +1389,12 @@ starts: `this user cannot open /root (Permission denied), which holds
/root/.config/retroarch/system`. The full-screen interface says the same on
the folder screen and asks for another folder.
A folder the search for setups cannot open (`~/.config/EmuDeck` left to root
by a `sudo` run) is named once, `Could not look for setups: this user cannot
open ...`, and the search goes on everywhere else: a RetroArch whose
`retroarch.cfg` is readable is still found at the folder it reads. The
full-screen interface keeps that line above the list of setups.
A file system mounted read-only refuses root too, so the installer does not
suggest `sudo` for it. Linux remounts a card or drive read-only after an error,
an unclean removal for one: check it (`fsck`, or `chkdsk` on Windows) and