diff --git a/install.ps1 b/install.ps1 index 5ea6fd3b7..0d73153a0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "b3363820bb97bec559287f72d9712281e22307387fa752b663f6f7af023a0e33" + $defaultInstallSha256 = "50074085810dde029db4cbbc7b520e34d4e70d58fe524570ac2821501772e7d8" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 7acc9cbb4..74d94fe6e 100644 --- a/install.py +++ b/install.py @@ -546,6 +546,12 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # up to MAX_SCANNED_FILES, truncated past them), and the folders below # it on another drive, not entered. "scanned": ("folder", "files", "truncated", "other_drives"), + # Before the file events of a check: the systems the run's list lets + # one choose among, --system aside, in id order (other for the files + # no system names). What Choose systems lists, and what the plan counts + # a --system out of: it said 12 where Choose systems listed 11. A kind + # of its own, since schema 1 never adds a key to check. + "choices": ("platform", "systems"), # state: present (hash verified), wrong_hash or missing. "file": ("platform", "dest", "systems", "size", "state"), "system": ("platform", "system", "name", "files", "present", "missing", @@ -1609,9 +1615,10 @@ def found_label(plat: str, path: Path) -> str: moved = "".join(f", {folder}/ at {place}" for folder, place in sorted(_MOVED_FOLDERS.get(path, {}).items()) if folder in ("bios", "roms")) + folder = shown_path(setup_bios_folder(plat, path)) if not name or name == shown: - return f"{shown} at {shown_path(path)}{moved}" - return f"{name} at {shown_path(path)} ({shown} files){moved}" + return f"{shown} at {folder}{moved}" + return f"{name} at {folder} ({shown} files){moved}" def _android_storage_root() -> Path: @@ -2111,7 +2118,7 @@ def _rule_path(template: str) -> Path: # The registry's executable rules: (os, platform, glob under $HOME or # %USERPROFILE%, folder beside the executable that holds the BIOS). BizHawk is # a portable archive reading /Firmware (PathExtensions.cs -# DataDirectoryPath), wherever it was extracted; ~/.config/BizHawk/Firmware, +# DataDirectoryPath), wherever it was extracted. ~/.config/BizHawk/Firmware, # the old guess, is a folder no build reads. _EXE_RULES: tuple[tuple[str, str, tuple[str, ...], str], ...] = ( ("linux", "bizhawk", ("$HOME/BizHawk*/EmuHawkMono.sh", "$HOME/*/BizHawk*/EmuHawkMono.sh"), @@ -2245,7 +2252,7 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]: # default is a guess, and the user is told so. print( f"Warning: could not read paths.rd_home_path from " - f"{retrodeck_json}; assuming {home / 'retrodeck'}", + f"{retrodeck_json}: assuming {home / 'retrodeck'}", file=sys.stderr, ) rd_root = Path(rd_home) if rd_home else home / "retrodeck" @@ -3794,7 +3801,8 @@ def canonical_region(raw: str) -> str: hint = f" Did you mean: {', '.join(close)}?" if close else "" raise ValueError( f"unknown region: {raw.strip()}.{hint} " - f"Short forms: {', '.join(REGION_ALIASES)}; regions: {', '.join(sorted(REGIONS))}" + f"Short forms: {', '.join(REGION_ALIASES)}. Regions: " + f"{', '.join(sorted(REGIONS))}" ) return key @@ -4241,12 +4249,14 @@ def save_directory_for( def place_save_entries( files: list[dict], bios_path: Path, manifest: dict, override: Path | None, - display: str, + display: str, say: bool = True, ) -> tuple[list[dict], list[dict]]: """Find the save directory for the entries read below it. Returns the files to install and, when the save directory is unknown, - the entries left out as omissions, said once with the reason. + the entries left out as omissions, said once with the reason. Without + say nothing is printed or sent: the run says it (say_saves) once a + choice of systems has kept the files it counts. """ saved = [entry for entry in files if entry.get("root") == "save"] if not saved: @@ -4259,18 +4269,21 @@ def place_save_entries( where += f"{os.sep}" if place.by_core: where += f"{os.sep}" - print(f" {_plural(len(saved), 'file')} read from {display}'s save directory " - f"{'goes' if len(saved) == 1 else 'go'} to {where}") - EVENTS.emit("saves", platform=str(manifest.get("platform", "")), - files=len(saved), folder=where) + _SAVE_WHERE[bios_path] = where + if say: + print(f" {saves_line(display, len(saved), where)}") + EVENTS.emit("saves", platform=str(manifest.get("platform", "")), + files=len(saved), folder=where) return files, [] - print(f" {_plural(len(saved), 'file')} read from {display}'s save directory " - f"left out: {place}.") + _SAVE_WHY[bios_path] = place + if say: + print(f" {_plural(len(saved), 'file')} read from {display}'s save directory " + f"left out: {place}.") kept = [entry for entry in files if entry.get("root") != "save"] unplaced = [ {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, - "system": "", "required": False, "reason": "no_save_directory", - "frontend": display, + "system": "", "systems": list(entry.get("systems") or []), "required": False, + "reason": "no_save_directory", "frontend": display, "cores": entry.get("cores"), "root": "save", **({"core_folder": entry["core_folder"]} if entry.get("core_folder") else {})} for entry in saved @@ -4278,6 +4291,30 @@ def place_save_entries( return kept, unplaced +# Per BIOS folder, where its save files go in words, or why that is not +# known: read by the lines that count them once a choice is made. +_SAVE_WHERE: dict[Path, str] = {} +_SAVE_WHY: dict[Path, str] = {} + + +def say_saves(plat: str, files: list[dict], omitted: list[dict], bios_path: Path, + display: str, printed: bool) -> None: + """The save directory lines and the saves event, over the files the + run installs once narrowed: --system sony-playstation said 106 files + went to the save directory, none of them a PlayStation file. printed + is the menu having said them already.""" + saved = sum(1 for entry in files if entry.get("root") == "save") + where = _SAVE_WHERE.get(bios_path) + if saved and where is not None: + if not printed: + print(f" {saves_line(display, saved, where)}") + EVENTS.emit("saves", platform=plat, files=saved, folder=where) + if printed: + return + for line in save_lines([], omitted, bios_path, display): + print(f" {line}") + + def _classify_local(entry: dict, dest: Path) -> str: """Return 'up_to_date' or 'mismatched' for a file already on disk.""" expected = { @@ -5229,6 +5266,10 @@ _VALUED_OPTIONS = ( ) +# The options a choice at the questions sets, which replace the command's. +_CHOICE_OPTIONS = ("--system", "--core", "--region") + + class Invocation: """How this run was started, so that a hint gives the command back whole. @@ -5299,7 +5340,14 @@ class Invocation: words += ["--dest", self.word(str(dest.absolute()))] elif dest: words += ["--dest", dest] - for flag, value in [*self.options, *self.chosen.get(plat, [])]: + options = self.options + if plat in self.chosen: + # A choice made at the questions replaces the narrowing the + # command line gave: both were printed, and --system named + # the systems of the first run and of the second. + replaced = {*_CHOICE_OPTIONS, *(flag for flag, _value in self.chosen[plat])} + options = [option for option in options if option[0] not in replaced] + for flag, value in [*options, *self.chosen.get(plat, [])]: if flag in without: continue words += [flag] if value is None else [flag, self.word(value)] @@ -5323,8 +5371,38 @@ def _on_read_only_mount(path: Path) -> bool: return False -def _write_advice(path: Path, assumed: bool, plat: str = "") -> str: - """What to do about a folder this run could not write. +def read_only_words(path: Path) -> str: + """The remedy for a folder on a read-only file system, in one sentence + that the plan, the result and line mode all say: root cannot write it + either, and the plan offered sudo for it.""" + place = path.absolute() + card = any(mount == place or mount in place.parents for mount in _mounted_cards()) + return (f"The {'card' if card else 'drive'} is read-only: unlock it or mount it " + "read-write, then run again.") + + +def write_remedy(path: Path, plat: str, hint: Invocation, commands: bool = True) -> list[str]: + """What the screens say a user can do about a folder they cannot write: + the read-only remedy, or the same choice run as an administrator, the + command carrying every option of the run (hint). The sudo line dropped + --from-pack and --manifest, and stopped at the file list with + --offline. commands is False where no command can be typed (a pad).""" + if _on_read_only_mount(path): + return [read_only_words(path)] + if not commands: + return [] + if hint.os_type == "windows": + return ["Or run the installer as an administrator."] + # sudo changes the home folder, and with it what is detected: the folder + # is named so root writes here. + return ["Or run it as root, this folder named: " + + hint.command(plat, path, elevated=True)] + + +def _write_advice(path: Path, assumed: bool, plat: str = "", + hint: Invocation | None = None) -> str: + """What to do about a folder this run could not write, the command + built by hint (the run's own invocation by default). sudo fixes a root-owned BIOS folder on the console itself. On another machine it would create that folder on the wrong disk: a platform named @@ -5333,27 +5411,27 @@ def _write_advice(path: Path, assumed: bool, plat: str = "") -> str: termux-setup-storage has run. A read-only mount refuses root as well, so sudo is never offered for it. """ + hint = hint or _INVOCATION if _on_read_only_mount(path): - again = f"\n {_INVOCATION.command(plat, path)}" if plat else "" - return (f" {path} is on a file system mounted read-only, which no user can " - "write, sudo included. Mount it read-write again (a card or drive " + again = f"\n {hint.command(plat, path)}" if plat else "" + return (f" {read_only_words(path)} {path} is on a file system mounted " + "read-only, which no user can write, sudo included (a card or drive " "the system switched to read-only after an error needs a check " - "first: fsck, or chkdsk on Windows), then run the same command " - f"again.{again}") + f"first: fsck, or chkdsk on Windows).{again}") if assumed: return (f" {path} is where the platform keeps its BIOS on its own system, " "and it was not found here. If this machine prepares its card or " "share, run it again with --dest pointing at that folder:\n" - f" {_INVOCATION.command(plat, '')}") - if plat and _INVOCATION.os_type == "windows": + f" {hint.command(plat, '')}") + if plat and hint.os_type == "windows": return (" This user cannot write there. Run it again from a PowerShell window " "opened as administrator:\n" - f" {_INVOCATION.command(plat, path)}\n" + f" {hint.command(plat, path)}\n" " Or choose another folder with --dest.") - if plat and _INVOCATION.os_type == "android": + if plat and hint.os_type == "android": return (" This user cannot write there. Run termux-setup-storage once to give " "Termux the shared storage, then run it again:\n" - f" {_INVOCATION.command(plat, path)}\n" + f" {hint.command(plat, path)}\n" " Or choose another folder with --dest.") if plat: # sudo resets HOME to root's on Debian, Ubuntu, Arch and SteamOS: @@ -5361,9 +5439,9 @@ def _write_advice(path: Path, assumed: bool, plat: str = "") -> str: # under /root and reported success. return (" This user cannot write there. Run it again with sudo, naming the " "folder so root does not look in its own home:\n" - f" {_INVOCATION.command(plat, path, elevated=True)}\n" + f" {hint.command(plat, path, elevated=True)}\n" " Or choose another folder with --dest.") - if _INVOCATION.os_type == "windows": + if hint.os_type == "windows": return (" This user cannot write there: run the same command from a PowerShell " "window opened as administrator, or choose another folder with --dest.") return (" This user cannot write there: run the same command with sudo, " @@ -5597,7 +5675,7 @@ def _check_fetch_line(fetch: int, beside: str) -> str: return " Run the same command without --check to copy them into place." return (f" {_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " f"internet, which --offline does not use. {_offline_remedy()}") - return (" Run the same command without --check to fetch them; a file with a wrong " + return (" Run the same command without --check to fetch them. A file with a wrong " f"hash is replaced, and the one in place kept in {BACKUP_DIR} beside {beside}.") @@ -6408,7 +6486,8 @@ def _line_setups(setups: list | None = None, mounted: set | None = None, return ", ".join(names[:3]) + more choices = [] - for row in setup_list(setups or [], mounted or set(), "", os_type or detect_os()): + for row in setup_list(setups or [], mounted or set(), "", os_type or detect_os(), + LOCAL.serves()): name = _row_name(row) if row.action == "cardsystem": listed = list(_SETUP_DEVICES.get(name, ())) @@ -6422,13 +6501,24 @@ def _line_setups(setups: list | None = None, mounted: set | None = None, def _line_matches(choices: list[tuple[str, str]], typed: str, setups: list | None = None, - mounted: set | None = None, os_type: str = "") -> list[int]: - """The indexes of the choices a typed device or system name finds, with - the screen filter's own matcher: "miyoo" was refused, the line menu - knowing only setup names.""" - found = {_row_name(row) for row in setup_list( - setups or [], mounted or set(), typed, os_type or detect_os())} - return [index for index, (name, _label) in enumerate(choices) if name in found] + mounted: set | None = None, os_type: str = "") -> list[tuple[int, str]]: + """(index, label) of the choices a typed device or system name finds, + with the screen filter's own matcher: "miyoo" was refused, the line + menu knowing only setup names. The label names what was matched first, + as the screen's row does on its right: "miyoo mini" listed MinUI with + RG28XX, RG34XX and RG34XX SP.""" + found = {_row_name(row): row for row in setup_list( + setups or [], mounted or set(), typed, os_type or detect_os(), LOCAL.serves())} + matched = [] + for index, (name, label) in enumerate(choices): + row = found.get(name) + if row is None: + continue + named = row.right.split(", ") if row.right else [] + more = f", +{len(named) - 3}" if len(named) > 3 else "" + matched.append((index, f"{row.label} ({', '.join(named[:3])}{more})" if named + else label)) + return matched def _choose_platform(title: str, default: str = "", setups: list | None = None, @@ -6443,6 +6533,10 @@ def _choose_platform(title: str, default: str = "", setups: list | None = None, """ os_type = detect_os() choices = _line_setups(setups, mounted, os_type) + if default and default not in {name for name, _label in choices}: + # A file list for another setup: Enter takes the first row, where + # the screen's focus falls (_platform_focus). + default = choices[0][0] if choices else "" print(f"\n{title}") for i, (_name, label) in enumerate(choices, 1): print(f" {i}) {label}") @@ -6471,12 +6565,12 @@ def _choose_platform(title: str, default: str = "", setups: list | None = None, return name found = _line_matches(choices, choice, setups, mounted, os_type) if key else [] if len(found) == 1: - print(f" {choices[found[0]][1]}") - return choices[found[0]][0] + print(f" {found[0][1]}") + return choices[found[0][0]][0] if found: print(f" {len(found)} setups run on '{choice.strip()}':") - for index in found: - print(f" {index + 1}) {choices[index][1]}") + for index, label in found: + print(f" {index + 1}) {label}") print(" Type the number of yours.") default = "" continue @@ -6493,12 +6587,14 @@ def _prompt_manual_platform(os_type: str, ask: bool = True, check_typed: bool = A system that runs from its card gets the card's root asked for, as --platform rocknix does: /storage/roms/bios was offered on a PC. """ - named = _choose_platform(title or "No supported platform found here. Choose the one to " - "install for:", "retroarch", setups, mounted) + named = _choose_platform(title or "Nothing was found here. Which setup?", "retroarch", + setups, mounted) plat = normalize_platform(named, say=False) - if card_bound(named, plat) is not None: + folder, kind = setup_folder(os_type, setups or [], mounted or set(), plat, named) + if card_bound(named, plat) is not None and kind != "found": return [(plat, card_install_dest(named, plat, True, ask))] - return [(plat, ask_platform_folder(os_type, plat, ask, check_typed))] + return [(plat, ask_platform_folder(os_type, plat, ask, check_typed, + folder if kind == "found" else None))] # Systems a computer reaches over the network or on their SHARE drive: their @@ -6510,6 +6606,21 @@ _SHARE_FOLDERS = { } +def drive_example(os_type: str, label: str, below: str = "", plat: str = "") -> str: + """Where a drive mounted here is, as this system writes it, for the + example a refusal gives: macOS mounts it at /Volumes/