fix: search past a folder this user cannot open

One closed folder under HOME (~/.config/EmuDeck at 000) made the whole
detection return nothing: a RetroArch whose retroarch.cfg was readable
was lost and the run fell back to a folder it does not read. Every
detection probe now goes through _exists, _is_dir or _is_file, which
count a refused place as absent, name the closed folder once, and let
the other probes go on. The decorator stays as the last net.

The refusal is an unsearched event before detect. The interface keeps
the line above the list of setups. At HOME or a drive root A goes into
a visible subfolder, never a card or drive row nor .cache, and with
none it stays on Use this folder and says where it is. A default folder
taken because the platform was not found is no longer called detected.
This commit is contained in:
Abdessamad Derraz committed 2026-10-24 02:23:57 +02:00
1 parent fd33ad0d96
commit d2ba7e18ba
7 files changed
+387 -83

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "23f334efb9e37ba1c2542b8599db7af6e67dfd5e0061d39cb507765f2594bd45" $defaultInstallSha256 = "bb0a0a88e16a6fb7aa93ec0a2b4cb5f1d667faacd50830aae9ce763e60d5d112"
$maximumInstallerBytes = 2MB $maximumInstallerBytes = 2MB
$installerExit = 0 $installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $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, ...]] = { EVENT_KEYS: dict[str, tuple[str, ...]] = {
# mode: install, check, dry-run or list. # mode: install, check, dry-run or list.
"start": ("schema", "installer", "mode"), "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. # platforms: [{"platform", "name", "bios_dir"}], empty when none is found.
"detect": ("os", "os_name", "platforms"), "detect": ("os", "os_name", "platforms"),
# stage: manifest (reason: network, not_found, invalid, clock for a # stage: manifest (reason: network, not_found, invalid, clock for a
@@ -844,7 +849,7 @@ def detect_os() -> str:
if _is_android(): if _is_android():
return "android" return "android"
proc_version = Path("/proc/version") proc_version = Path("/proc/version")
if proc_version.exists(): if _exists(proc_version):
try: try:
content = proc_version.read_text(encoding="utf-8", errors="replace") content = proc_version.read_text(encoding="utf-8", errors="replace")
if "microsoft" in content.lower(): if "microsoft" in content.lower():
@@ -863,7 +868,7 @@ def _parse_os_release() -> dict[str, str]:
"""Parse /etc/os-release KEY=value format.""" """Parse /etc/os-release KEY=value format."""
result: dict[str, str] = {} result: dict[str, str] = {}
path = _on_device("/etc/os-release") path = _on_device("/etc/os-release")
if not path.exists(): if not _exists(path):
return result return result
try: try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
@@ -964,7 +969,7 @@ def _steam_libraries() -> list[Path]:
roots: list[Path] = [] roots: list[Path] = []
for var in ("ProgramFiles(x86)", "ProgramFiles"): for var in ("ProgramFiles(x86)", "ProgramFiles"):
base = os.environ.get(var, "") 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") roots.append(Path(base) / "Steam")
libraries = list(roots) libraries = list(roots)
for root in roots: for root in roots:
@@ -1015,7 +1020,7 @@ def _windows_retroarch_dirs() -> list[Path]:
candidates += [ candidates += [
Path(root) / "apps" / "retroarch" / "current" for root in scoop_roots if root 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: 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 HAVE_STEAM portable = YES`), with no portable.txt to say so: its
bundle under Steam's library, and no other RetroArch.app, mark it.""" bundle under Steam's library, and no other RetroArch.app, mark it."""
support = Path.home() / "Library" / "Application Support" / "RetroArch" support = Path.home() / "Library" / "Application Support" / "RetroArch"
if (support / "portable.txt").is_file(): if _is_file(support / "portable.txt"):
return support return support
steam = ( steam = (
Path.home() / "Library" / "Application Support" / "Steam" / "steamapps" Path.home() / "Library" / "Application Support" / "Steam" / "steamapps"
/ "common" / "RetroArch" / "RetroArch.app" / "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 support
return Path.home() / "Documents" / "RetroArch" 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 install keeps both. home is what '~' means to that RetroArch, the user's
home when not given. home when not given.
""" """
if not cfg_path.exists(): if not _exists(cfg_path):
return None return None
app_dir = app_dir or cfg_path.parent app_dir = app_dir or cfg_path.parent
system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system", home) 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: def _parse_bash_var(path: Path, key: str) -> str | None:
"""Extract the effective value of key= from a bash/shell file.""" """Extract the effective value of key= from a bash/shell file."""
if not path.exists(): if not _exists(path):
return None return None
try: try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): 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: def _parse_json_path(path: Path, *keys: str) -> str | None:
"""Extract a nested string value from a JSON file.""" """Extract a nested string value from a JSON file."""
if not path.exists(): if not _exists(path):
return None return None
try: try:
data = json.loads(path.read_text(encoding="utf-8", errors="replace")) 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: def _parse_ps1_var(path: Path, key: str) -> str | None:
"""Extract value of $key= or key= from a PowerShell file.""" """Extract value of $key= or key= from a PowerShell file."""
if not path.exists(): if not _exists(path):
return None return None
normalized = key.lstrip("$") normalized = key.lstrip("$")
try: try:
@@ -1459,7 +1464,7 @@ def _config_files(pattern: str) -> list[tuple[Path, Path]]:
return [(_on_device(device), Path(folder))] return [(_on_device(device), Path(folder))]
try: try:
return [(path, Path(folder)) for path in sorted(_on_device(folder).glob(name)) return [(path, Path(folder)) for path in sorted(_on_device(folder).glob(name))
if path.is_file()] if _is_file(path)]
except OSError: except OSError:
return [] return []
@@ -1594,6 +1599,25 @@ def _android_cfg_value(cfg: Path, key: str) -> str | None:
return 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: 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 """True for the Google Play build of this package, False for the
retroarch.com one, None when nothing it wrote tells them apart. 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") code = _android_cfg_value(cfg, "bundle_assets_extract_version_current")
if code and code.isdigit() and int(code) > 0: if code and code.isdigit() and int(code) > 0:
return int(code) < _ANDROID_PLAY_VERSION_CEILING 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 return True
if (storage / "RetroArch").is_dir(): if _is_dir(storage / "RetroArch"):
return False return False
return None return None
@@ -1621,8 +1645,8 @@ def _android_writable(folder: Path) -> bool:
writable. Android 11 and later keep Android/data/<package> of other apps writable. Android 11 and later keep Android/data/<package> of other apps
from Termux.""" from Termux."""
for parent in (folder, *folder.parents): for parent in (folder, *folder.parents):
if parent.exists(): if _exists(parent):
return parent.is_dir() and os.access(parent, os.W_OK) return _is_dir(parent) and os.access(parent, os.W_OK)
return False 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.") f"{folder} without root: {what} are not copied there.")
@_refusals_expected()
def _android_retroarch_builds() -> list[tuple[Path | None, str]]: def _android_retroarch_builds() -> list[tuple[Path | None, str]]:
"""Each installed RetroArch build: its system folder when this shell can """Each installed RetroArch build: its system folder when this shell can
write it, else None and the line that says why. 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]] = [] builds: list[tuple[Path | None, str]] = []
for package in ANDROID_RETROARCH_PACKAGES: for package in ANDROID_RETROARCH_PACKAGES:
external = storage / "Android" / "data" / package / "files" 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 continue
cfgs = (external / "retroarch.cfg", cfgs = (external / "retroarch.cfg",
ANDROID_APP_DATA / package / "files" / "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: if folder is not None and ("retroarch", folder) not in found:
found.append(("retroarch", folder)) found.append(("retroarch", folder))
default_root = _android_storage_root() / "RetroArch" 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")) found.append(("retroarch", default_root / "system"))
return found return found
@@ -1715,6 +1740,7 @@ _ANDROID_APP_FOLDERS = (
) )
@_refusals_expected()
def android_unreachable_folders() -> list[str]: def android_unreachable_folders() -> list[str]:
"""A line for each Android emulator installed here whose BIOS folder """A line for each Android emulator installed here whose BIOS folder
this shell cannot write, said instead of skipping it in silence. A 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)) target = Path(folder.replace("$STORAGE", storage, 1))
if folder.startswith("/data/data/"): if folder.startswith("/data/data/"):
target = ANDROID_APP_DATA / Path(folder).relative_to("/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 continue
if target.is_dir() and os.access(target, os.W_OK): if _is_dir(target) and os.access(target, os.W_OK):
continue continue
lines.append(_android_out_of_reach(name, target, ", ".join(files))) lines.append(_android_out_of_reach(name, target, ", ".join(files)))
return lines return lines
@@ -1780,7 +1806,7 @@ def launchbox_root(os_type: str) -> Path | None:
/ "LaunchBox" / "LaunchBox"
/ "LaunchBox.lnk" / "LaunchBox.lnk"
) )
if lnk.exists(): if _exists(lnk):
target = _lnk_target(lnk, "LaunchBox.exe") target = _lnk_target(lnk, "LaunchBox.exe")
if target: if target:
# The shortcut points at Core/LaunchBox.exe # The shortcut points at Core/LaunchBox.exe
@@ -1790,7 +1816,7 @@ def launchbox_root(os_type: str) -> Path | None:
if userprofile: if userprofile:
candidates.append(Path(userprofile) / "LaunchBox") candidates.append(Path(userprofile) / "LaunchBox")
for root in candidates: for root in candidates:
if (root / "Data" / "Emulators.xml").exists(): if _exists(root / "Data" / "Emulators.xml"):
return root return root
return None return None
@@ -1803,7 +1829,7 @@ def launchbox_emulators(emulators_xml: Path) -> dict[str, Path]:
the lowercase executable names. the lowercase executable names.
""" """
found: dict[str, Path] = {} found: dict[str, Path] = {}
if not emulators_xml.exists(): if not _exists(emulators_xml):
return found return found
try: try:
if emulators_xml.stat().st_size > 10 * 1024 * 1024: 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) exe = Path(app_path)
if not exe.is_absolute(): if not exe.is_absolute():
exe = lb_root / exe exe = lb_root / exe
if exe.parent.is_dir(): if _is_dir(exe.parent):
found.setdefault(exe.name.lower(), exe.parent) found.setdefault(exe.name.lower(), exe.parent)
return found return found
@@ -1852,7 +1878,7 @@ def _pcsx2_bios_dir(emu_dir: Path) -> Path:
""" """
documents = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "Documents" documents = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "Documents"
portable_txt = emu_dir / "portable.txt" 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: if portable:
subpath = "" subpath = ""
try: try:
@@ -1885,14 +1911,14 @@ def _xemu_bios_dir(emu_dir: Path) -> Path:
""" """
appdata = os.environ.get("APPDATA", "") appdata = os.environ.get("APPDATA", "")
toml = emu_dir / "xemu.toml" toml = emu_dir / "xemu.toml"
if not toml.exists() and appdata: if not _exists(toml) and appdata:
toml = Path(appdata) / "xemu" / "xemu" / "xemu.toml" toml = Path(appdata) / "xemu" / "xemu" / "xemu.toml"
if toml.exists(): if _exists(toml):
try: try:
for line in toml.read_text(encoding="utf-8", errors="replace").splitlines(): for line in toml.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith(("bootrom_path", "flashrom_path")): if line.startswith(("bootrom_path", "flashrom_path")):
parts = line.split("'") 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 return Path(parts[1]).parent
except OSError: except OSError:
pass pass
@@ -1914,7 +1940,7 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]:
if emu_dir: if emu_dir:
dirs["xemu"] = _xemu_bios_dir(emu_dir) dirs["xemu"] = _xemu_bios_dir(emu_dir)
emu_dir = emulators.get("dolphin.exe") 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 # portable.txt beside the executable moves the user directory to
# User (SetUserDirectory in Source/Core/UICommon/UICommon.cpp, # User (SetUserDirectory in Source/Core/UICommon/UICommon.cpp,
# PORTABLE_USER_DIR in Source/Core/Common/CommonPaths.h) # PORTABLE_USER_DIR in Source/Core/Common/CommonPaths.h)
@@ -1922,32 +1948,59 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]:
return dirs 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]: def _detection_guarded(detect: Callable[..., list]) -> Callable[..., list]:
"""Detection that finds nothing, and says why once, where this user """Detection whose probes treat a folder this user cannot open as
cannot look. absent, and say so once.
HOME kept across `su` without `-`: every probe of a config under it Each probe goes through _exists, _is_dir or _is_file, which records the
raised PermissionError, and the run ended on a traceback before its refusal. A probe that still raises PermissionError (a glob on a Python
first line. A named --dest still installs. 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: def guarded(*args: object, **kwargs: object) -> list:
outer = getattr(_SETUP_SEARCH, "active", False)
_SETUP_SEARCH.active = True
try: try:
return detect(*args, **kwargs) return detect(*args, **kwargs)
except PermissionError as exc: except PermissionError as exc:
closed = closed_folder(Path(exc.filename)) if exc.filename else None _note_refused(Path(str(exc.filename or "")), exc)
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)
return [] return []
guarded.__doc__ = detect.__doc__ finally:
guarded.__name__ = detect.__name__ _SETUP_SEARCH.active = outer
return guarded return guarded
@@ -1961,7 +2014,7 @@ def detect_frontends(os_type: str, home: Path | None = None) -> list[str]:
home = home or Path.home() home = home or Path.home()
frontends: list[str] = [] frontends: list[str] = []
esde_env = os.environ.get("ESDE_APPDATA_DIR", "") 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") frontends.append("esde")
if launchbox_root(os_type) is not None: if launchbox_root(os_type) is not None:
frontends.append("launchbox") 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 "\\") base, _, rest = pattern.partition("/" if "/" in pattern else "\\")
glob = "/".join(part for part in re.split(r"[\\/]", rest) if part) glob = "/".join(part for part in re.split(r"[\\/]", rest) if part)
try: 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: except OSError:
continue continue
if hits: if hits:
@@ -2088,7 +2141,7 @@ def _detect_by_registry(os_type: str, found: list[tuple[str, Path]]) -> None:
if not folder: if not folder:
continue continue
base = _host_path(folder.rstrip("\\/") or folder) base = _host_path(folder.rstrip("\\/") or folder)
if base.is_dir(): if _is_dir(base):
found.append((platform_name, base / subdir)) 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: 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): if rule_os not in systems or any(name == platform_name for name, _ in found):
continue continue
if _rule_path(path).exists(): if _exists(_rule_path(path)):
found.append((platform_name, _rule_path(bios or 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 # dddb3022) reads $emudeckFolder/settings.sh on both, and vars.sh:2
# sets emudeckFolder to $HOME/.config/EmuDeck. # sets emudeckFolder to $HOME/.config/EmuDeck.
emudeck_settings = Path.home() / ".config" / "EmuDeck" / "settings.sh" emudeck_settings = Path.home() / ".config" / "EmuDeck" / "settings.sh"
if emudeck_settings.exists(): if _exists(emudeck_settings):
emu_path = _parse_bash_var(emudeck_settings, "emulationPath") emu_path = _parse_bash_var(emudeck_settings, "emulationPath")
if emu_path: if emu_path:
found.append(("emudeck", Path(emu_path) / "bios")) 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_conf_dir = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck"
retrodeck_json = retrodeck_conf_dir / "retrodeck.json" retrodeck_json = retrodeck_conf_dir / "retrodeck.json"
retrodeck_cfg = retrodeck_conf_dir / "retrodeck.cfg" 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") rd_home = _parse_json_path(retrodeck_json, "paths", "rd_home_path")
if not rd_home: if not rd_home:
# A home moved to an SD card lives only in this file: the # 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" rd_root = Path(rd_home) if rd_home else home / "retrodeck"
_record_moved_folders(rd_root, retrodeck_json) _record_moved_folders(rd_root, retrodeck_json)
found.append(("retrodeck", rd_root)) found.append(("retrodeck", rd_root))
elif retrodeck_cfg.exists(): elif _exists(retrodeck_cfg):
rd_home = _parse_bash_var(retrodeck_cfg, "rdhome") rd_home = _parse_bash_var(retrodeck_cfg, "rdhome")
found.append(("retrodeck", Path(rd_home) if rd_home else home / "retrodeck")) 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" candidates = [Path(os.environ[var]) / "EmuDeck" / "settings.ps1"
for var in ("APPDATA", "USERPROFILE") if os.environ.get(var)] 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: if settings is None:
return None return None
emu_path = _parse_ps1_var(settings, "$emulationPath") 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). $XDG_DATA_HOME/emuhawk-monort-<version> (Dist/launch-scripts.nix).
""" """
data_home = os.environ.get("BIZHAWK_DATA_HOME", "").strip() 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" return Path(data_home) / "Firmware"
xdg = os.environ.get("XDG_DATA_HOME", "").strip() or str(Path.home() / ".local" / "share") 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 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. RetroArch's own directory.
""" """
found = _retroarch_config_for(bios_path) 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) found = (bios_path.parent / "retroarch.cfg", bios_path.parent)
dirs: list[Path] = [] dirs: list[Path] = []
if found is not None: if found is not None:
@@ -4196,28 +4250,29 @@ def _held(path: Path) -> bool:
return True 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: def _exists(path: Path) -> bool:
"""Path.exists(), False where the place cannot be looked at.""" """Path.exists(), False where the place cannot be looked at."""
try: return _probed(path.exists, path)
return path.exists()
except OSError:
return False
def _is_dir(path: Path) -> bool: def _is_dir(path: Path) -> bool:
"""Path.is_dir(), False where the place cannot be looked at.""" """Path.is_dir(), False where the place cannot be looked at."""
try: return _probed(path.is_dir, path)
return path.is_dir()
except OSError:
return False
def _is_file(path: Path) -> bool: def _is_file(path: Path) -> bool:
"""Path.is_file(), False where the place cannot be looked at.""" """Path.is_file(), False where the place cannot be looked at."""
try: return _probed(path.is_file, path)
return path.is_file()
except OSError:
return False
def closed_folder(path: Path) -> tuple[Path, str] | None: 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: elif not listing:
# A system_directory under /root read from retroarch.cfg, run without # A system_directory under /root read from retroarch.cfg, run without
# sudo: the case probe raised PermissionError before any line. # 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: if closed_folder(path) is not None and len(platforms) > 1:
print(" Nothing was installed. Name one platform with --platform " print(" Nothing was installed. Name one platform with --platform "
"to install it alone.", file=sys.stderr) "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_downloaded = 0
total_up_to_date = 0 total_up_to_date = 0
@@ -10173,6 +10231,8 @@ class TuiState:
# (platform, folder, label) for each setup found here. # (platform, folder, label) for each setup found here.
self.setups: list = [] self.setups: list = []
self.notes: list = [] self.notes: list = []
# The folders detection could not look in, one line each.
self.refused: list = []
self.platform: str = "" self.platform: str = ""
self.dest: Path | None = None self.dest: Path | None = None
# A --dest given without --platform: the list asks whose files go there. # 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" if (folder is not None and state.browse_for != "from"
and folder in (state.home, Path(folder.anchor))): and folder in (state.home, Path(folder.anchor))):
# No frontend reads its BIOS files from HOME or a drive's root: A # 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) 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): for index, row in enumerate(state.browse_rows):
if came_from is not None and row.action == "dir" and row.value == came_from: if came_from is not None and row.action == "dir" and row.value == came_from:
focus = index 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")] return lines[first:end] + [_styled(where, "dim")]
# The rows a screen keeps for its list under pinned lines.
TUI_PINNED_ROWS = 4
def _compose( def _compose(
state: TuiState, width: int, height: int, before: list, rows: list[TuiRow], state: TuiState, width: int, height: int, before: list, rows: list[TuiRow],
after: list = (), footer: list = (), after: list = (), footer: list = (), pinned: list = (),
) -> list[TuiLine]: ) -> list[TuiLine]:
"""Text, then the rows, then more text, in height lines. The rows are """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) footer_lines = _text_lines(list(footer), width)
room = max(1, height - len(footer_lines)) 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 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)] row_lines = [_row_line(row, index == focus, width) for index, row in enumerate(rows)]
if len(row_lines) >= room: 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."] before = ["Found on this machine:" if state.setups else "Nothing was found here."]
status = [state.status] if state.status else [] status = [state.status] if state.status else []
return ("Where do you play?", 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")) ("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", "") needle = state.filters.get("platforms", "")
after = [f"No setup matches '{needle}'."] if len(rows) == 1 and needle else [] after = [f"No setup matches '{needle}'."] if len(rows) == 1 and needle else []
after += [state.status] if state.status 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")) ("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?" title = "Which folder?"
before = [f"No usual {name} folder here: browse for it or type it."] before = [f"No usual {name} folder here: browse for it or type it."]
after = [state.status] if state.status else [] 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")) ("A/Enter Choose", "B/Esc Back", "? Help"))
@@ -12615,9 +12698,11 @@ def tui_start(
found = detect_platforms(os_type) found = detect_platforms(os_type)
frontends = detect_frontends(os_type) frontends = detect_frontends(os_type)
cards = [(card_system(mount), mount) for mount in _mounted_cards()] cards = [(card_system(mount), mount) for mount in _mounted_cards()]
if _DETECTION_REFUSED: # Said on the normal screen before this one covered it, and kept
# Said on the normal screen before this one covered it. # above the list: a status line under 18 setups was never drawn.
state.status = _DETECTION_REFUSED[0] 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] 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 # 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). # 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, " state.notes.append("ES-DE found: its BIOS files go to each emulator, "
"not to ES-DE itself.") "not to ES-DE itself.")
state.notes += [line.strip() for line in captured.getvalue().splitlines() 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: if plat and args.dest is not None:
state.stack = [] state.stack = []
return state, _choose(state, plat, Path(args.dest)) return state, _choose(state, plat, Path(args.dest))
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER="" TEMP_INSTALLER=""
TEMP_DIRECTORY="" TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="23f334efb9e37ba1c2542b8599db7af6e67dfd5e0061d39cb507765f2594bd45" DEFAULT_INSTALL_SHA256="bb0a0a88e16a6fb7aa93ec0a2b4cb5f1d667faacd50830aae9ce763e60d5d112"
MAX_INSTALLER_BYTES=2097152 MAX_INSTALLER_BYTES=2097152
cleanup() { cleanup() {
+28
View File
@@ -11,6 +11,7 @@
"event": { "event": {
"enum": [ "enum": [
"start", "start",
"unsearched",
"detect", "detect",
"error", "error",
"scanned", "scanned",
@@ -35,6 +36,9 @@
{ {
"$ref": "#/$defs/start" "$ref": "#/$defs/start"
}, },
{
"$ref": "#/$defs/unsearched"
},
{ {
"$ref": "#/$defs/detect" "$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": { "detect": {
"type": "object", "type": "object",
"additionalProperties": false, "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}", self.assertIn(f"Could not look for setups: this user cannot open {home}",
runs["detected"].stderr) 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): class OutputToAFileAsksNothing(unittest.TestCase):
"""`install.py > log.txt` at a terminal: the menu went into the file """`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): def test_the_nearest_folder_stops_above_it(self):
self.assertEqual(install._nearest_folder(self.dest / "deeper"), self.lock) 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 modules a Windows Python does not have: importing one raises there.
POSIX_ONLY = ("termios", "tty", "pty", "fcntl", "select", "resource", "pwd", "grp") 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 | | 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` | | `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 | | `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 | | `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 | | `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 | | `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)) | | `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 existing kind: a reader skips a kind it does not know. An option refused
before the run starts (`install.py: error:`, code 2) writes 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 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 /root/.config/retroarch/system`. The full-screen interface says the same on
the folder screen and asks for another folder. 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 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, 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 an unclean removal for one: check it (`fsck`, or `chkdsk` on Windows) and