diff --git a/install.ps1 b/install.ps1 index f599cefe..88e16f93 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 } diff --git a/install.py b/install.py index 41b98b62..deaba930 100644 --- a/install.py +++ b/install.py @@ -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/ 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- (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)) diff --git a/install.sh b/install.sh index b4b41cca..2ac14608 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="23f334efb9e37ba1c2542b8599db7af6e67dfd5e0061d39cb507765f2594bd45" +DEFAULT_INSTALL_SHA256="bb0a0a88e16a6fb7aa93ec0a2b4cb5f1d667faacd50830aae9ce763e60d5d112" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index a9cb3fc5..3e437afc 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -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, diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index d43ca58d..97d98039 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -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 diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 1854d086..aa81f1b8 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -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") diff --git a/wiki/installer.md b/wiki/installer.md index 95167d5f..987dc54c 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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