diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 98f52c7dc..5353269cd 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -150,6 +150,13 @@ jobs: npm run build --prefix website python scripts/validate_site.py --site-dir website/dist + # The one-liner the site serves: each copy pins its own installer and + # says what a refusal means for it. Validation runs these too, but in + # another workflow this one never waits for: a copy that refuses every + # install must stop the deploy, the last good pair staying served. + - name: Check the served bootstraps + run: python -m unittest tests.test_site_bootstrap + # The queries a visitor types must reach the page that answers them, # read through the site's own search box, and the home page must stay # under its weight budget. A miss blocks the deploy. The browser driver diff --git a/install.ps1 b/install.ps1 index 871b82fbd..eaf69702f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,12 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54" + # What a refused installer most likely means for this copy. The copy the + # site serves reads a commit's installer, which no cache serves stale, + # and Invoke-WebRequest throws on a cut transfer before the hash is read: + # the site's build rewrites this line there (scripts/sitebootstrap.py). + $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." + $defaultInstallSha256 = "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } @@ -58,8 +63,7 @@ throw ($mismatch + "{0} is set in this session and decides which installer is accepted: " + "remove it to use the installer this script names, or set it to match.") -f ($overrides -join " and ") } - throw ($mismatch + "The download was most likely cut short: try again. Within minutes of an update " + - "of this project the two files can come from caches of different ages. Waiting five minutes settles that.") + throw ($mismatch + $mismatchAdvice) } $installer = $temporary } diff --git a/install.py b/install.py index 565aeb5a2..47ddc9a84 100644 --- a/install.py +++ b/install.py @@ -5999,7 +5999,7 @@ def _report_outcome( kept = _kept_lines() + ([ f"{_plural(len(unplaced), 'file')} read from " f"{unplaced[0].get('frontend') or 'the frontend'}'s save directory left out: " - "its place is not known. Name it with --save-dir next time."] if unplaced else []) + f"its place is not known. {LINE_SAVE_REMEDY}"] if unplaced else []) kept += list(notes or []) if interrupted: say(f"\nStopped. {_plural(downloaded, 'file')} installed.") @@ -6068,7 +6068,7 @@ def _report_outcome( # Under the files it is about: after the save-directory line, "They" # read as that one file. say(f" {remedy}") - say(f"Location: {_locations(paths)}") + say(f"BIOS folder: {_locations(paths)}") for line in kept + advice: say(line) sys.exit(1 if downloaded else 2) @@ -6079,12 +6079,15 @@ def _report_outcome( for _d, r in failures): # The folder was never the problem: naming it as the place being # written to sent people looking at their disk. - say(f"Destination: {_locations(paths)}") + say(f"BIOS folder: {_locations(paths)}") + elif _retry_words(failures): + # Nothing was written: every file is one the pack lacks. + say(f"BIOS folder: {_locations(paths)}") else: - say(f"Location that was being written to: {_locations(paths)}") + say(f"BIOS folder that was being written to: {_locations(paths)}") for line in kept + advice: say(line) - say("Run the same command again once that is resolved. " + say(_retry_words(failures) or "Run the same command again once that is resolved. " "Files already installed are kept.") sys.exit(2) @@ -6095,20 +6098,24 @@ def _report_outcome( say(f" {sources}") if installed_kinds: say(installed_kinds) - say(f"Location: {_locations(paths)}") + say(f"BIOS folder: {_locations(paths)}") for line in kept + advice: say(line) - if not all(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []): + retry = _retry_words(failures) + if retry: + say(retry) + elif not all(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []): say("Run the same command again to retry only those.") sys.exit(1) - say(f"\nDone. {_plural(downloaded, 'file')} installed, " - f"{_plural(up_to_date, 'file')} already up to date.") + # No zero count: "0 files already up to date" said nothing. + say(f"\nDone. {_plural(downloaded, 'file')} installed" + + (f", {_plural(up_to_date, 'file')} already up to date." if up_to_date else ".")) if sources: say(f" {sources}") if installed_kinds: say(installed_kinds) - say(f"Location: {_locations(paths)}") + say(f"BIOS folder: {_locations(paths)}") for line in kept: say(line) if omitted > len(unplaced): @@ -6128,13 +6135,15 @@ def _report_outcome( say("To check this later, run the same command with --check.") -def _offline_remedy(folders: list[str] | None = None) -> str: +def _offline_remedy(folders: list[str] | None = None, named: bool = False) -> str: """Where files --offline does not fetch can still come from: the folders --from named (folders, on the screen) are not suggested again. One wording for line mode, a run without questions, --check and the - screen.""" + screen. named: the line before already says what the folders lack.""" labels = (folders if folders is not None else [index.label for index in LOCAL.folders]) + if labels and named: + return f"Run the installer again with a pack (--from-pack) or {offline_undo()}." if labels: named = ", ".join(screen_text(shown_path(label)) for label in labels) return (f"They are not in {named}: run the installer again with a pack " @@ -6143,9 +6152,33 @@ def _offline_remedy(folders: list[str] | None = None) -> str: f"again with a pack (--from-pack) or {offline_undo()}.") -def _check_fetch_line(fetch: int, beside: str) -> str: +def _retry_words(failures: list | None) -> str: + """What to do when every failure is a file the pack does not hold: a + second run fails them the same way, and "run it again to retry only + those" sent people round (code_audit_21 P3-3). "" otherwise.""" + lacking = [dest for dest, reason in failures or [] if str(reason) == NOT_IN_PACK] + if not lacking or len(lacking) != len([ + dest for dest, reason in failures or [] if not str(reason).startswith(NOT_KEPT)]): + return "" + name = LOCAL.pack.name if LOCAL.pack is not None else "The pack" + return (f"{name} does not hold {'it' if len(lacking) == 1 else 'them'}: a newer pack, " + "or a run without --from-pack, installs " + f"{'it' if len(lacking) == 1 else 'them'}.") + + +def _check_fetch_line(fetch: int, beside: str, lacking: int = 0) -> str: """What --check says a run without it does with the files not in place: - offline, those it cannot fetch are said so, not "run it to fetch them".""" + offline, those it cannot fetch are said so, not "run it to fetch them". + With a pack, those it holds are copied and those it lacks are said lacking + (lacking): "fetch them" was said with a pack and no network.""" + if LOCAL.pack is not None: + held = fetch - lacking + if not held: + return (f" {LOCAL.pack.name} holds none of them: a newer pack, or a run " + "without --from-pack, installs them.") + return (f" Run the same command without --check to copy {held} of them from " + f"{LOCAL.pack.name}. A file with a wrong hash is replaced, and the one in " + f"place kept in {BACKUP_DIR} beside {beside}.") if _offline_only(): if not fetch: return " Run the same command without --check to copy them into place." @@ -6171,7 +6204,7 @@ DATA_FILES_WORDS = ("data files: the files an emulator reads from a data folder # The definitions in a few words, for a help with no room for the sentences. _SHORT_DEFINITIONS = ( ("Game data:", "Game data: what a game reads, of no use to other cores."), - ("Data files:", "Data files: what an emulator reads from its own data folder."), + ("Data files:", "Data files: an emulator's own data folder."), ) @@ -6319,12 +6352,17 @@ def _print_plan( trees = [(entry, needed) for entry, needed in trees if needed] tree_counts = tally(meta for _entry, needed in trees for meta in needed.values()) tree_files = tree_counts.files - parts = [] - if to_download: - parts.append(f"{_plural(counts.files, 'file')} ({format_size(counts.size)})") - if trees: - parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_counts.size)}, " - f"extracted from {_plural(len(trees), 'archive')})") + # One count for the plan, the screen's (code_audit_21 P3-8): "8252 + # files, and 2636 data files" read as two kinds of one thing. The kinds + # follow, adding up to it. + total = counts.files + tree_files + parts = ([f"{_plural(total, 'file')} ({format_size(counts.size + tree_counts.size)})"] + if total else []) + split = parts_words(counts if (manifest or {}).get("categorized") + else tally({"size": e.get("size")} for e in to_download), + tree_files, tree_counts.size) + if split and trees: + split += f", the data files extracted from {_plural(len(trees), 'archive')}" # A pack is copied from, not downloaded: "Would download 23 files" was # said of a run that makes no network request. verb = "copy" if LOCAL.pack is not None else "download" @@ -6335,13 +6373,15 @@ def _print_plan( # --offline fetches none of them: "Would download" promised a # transfer the run never makes, where the screen says "need the # internet". - one = len(to_download) + tree_files == 1 + one = total == 1 print(f"\n{' and '.join(parts)} {'needs' if one else 'need'} the internet, which " f"{offline_named()} does not use. Run {offline_undo()}, and they would go " f"to {shown_path(bios_path)}:") elif parts: source = " from the pack" if LOCAL.pack is not None else "" print(f"\nWould {verb} {' and '.join(parts)}{source} to {shown_path(bios_path)}:") + if parts and split: + print(f" {split}.") for line in copied: print(line) if not parts: @@ -7203,7 +7243,51 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: targets.append((f"{_drive_folder(mount, below)} ({what})", found[1])) else: targets.append((f"{_drive_folder(mount, 'bios')} (card or drive)", mount / "bios")) - return targets + # A handheld's card no file names first: its bios folder is where its + # system reads (unnamed_cards). + cards = {mount / "bios" for mount in unnamed_cards()} + return sorted(targets, key=lambda target: target[1] not in cards) + + +# The folders a handheld card's games part holds, one per console: the +# names people type for a system (SYSTEM_SHORT_NAMES) and the ArkOS and +# AmberELEC roms folders those miss. +_CONSOLE_FOLDERS = frozenset({*SYSTEM_SHORT_NAMES, "arcade", "atari2600", "atarilynx", + "fbneo", "gamegear", "mame", "mastersystem", "megadrive", + "neogeo", "pcengine", "psp", "segacd", "wonderswan"}) + + +def unnamed_cards() -> list[Path]: + """The mounted drives no file names whose root holds a bios folder and + console folders: ArkOS's EASYROMS, AmberELEC's GAMES. Which system runs + them cannot be told (their own files are on a part a computer does not + open), but they are a handheld's card: "Nothing was found here" over + one, and two presses of Enter filled this computer's folder for a + RetroArch that was not there (tui_audit_14 P2-3).""" + cards = [] + for mount in _mounted_cards(): + if (card_system(mount) is not None or share_doubt(mount)[0] + or not _is_dir(mount / "bios")): + continue + if any(name.casefold() in _CONSOLE_FOLDERS and _is_dir(mount / name) + for name in _listed(mount)): + cards.append(mount) + return cards + + +def unnamed_card_words(cards: list[Path], tail: str = ".") -> str: + """What the first screen and line mode say of those cards.""" + names = ", ".join(_mount_name(mount) for mount in cards) + what = "A card is" if len(cards) == 1 else "Cards are" + return f"{what} in the reader ({names}): choose its system{tail}" + + +def say_unnamed_cards() -> None: + """Line mode's words for the cards no file names, where it says what + was not found here.""" + cards = unnamed_cards() + if cards: + print(f" {unnamed_card_words(cards)}") def _drive_folder(mount: Path, below: str) -> str: @@ -7258,9 +7342,17 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, name = display_name(plat) shared = plat in _SHARE_FOLDERS and found is None default = found if found is not None else None if shared else _default_dest(os_type, plat) + # A handheld's card in the reader and no setup found here: Enter puts + # the files on the card, never in this computer's folder for a RetroArch + # that is not there (tui_audit_14 P2-3). + cards = [] if shared or found is not None else unnamed_cards() print(f"\nWhich folder do {name}'s BIOS files go to?") if shared: print(f" {share_words(plat)}") + elif cards: + default = cards[0] / "bios" + print(f" The card in the reader keeps them in its bios folder: " + f"{shown_path(default)}.") elif found is not None: print(f" {name} reads its BIOS files from: {shown_path(found)}.") elif default is not None: @@ -7271,13 +7363,21 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, # rows: never its root, and never for a system that reads another list. targets = (share_targets(plat) if shared else [*(other_side(os_type, plat) if found is None else []), *drive_targets(plat)]) + if cards: + # The card first, this computer's usual folder after it. + targets = [*targets, (f"{side_name(os_type, plat)}'s usual folder", + _default_dest(os_type, plat))] + targets = [target for target in targets if target[1] is not None] for index, (label, folder) in enumerate(targets, 1): - made = "" if _is_dir(folder) else ", created" + # Said of a folder that is missing, as the screen says it: "created" + # was said of folders nobody had created (code_audit_21 P3-7). + missing = "" if _is_dir(folder) else "does not exist yet" # A label that spells its folder (the Windows side's) is not # followed by it again. shown = shown_path(folder) - print(f" {index}) {label}" + (f" ({shown}{made})" if shown not in label - else f" ({made[2:]})" if made else "")) + print(f" {index}) {label}" + ( + f" ({shown}{', ' + missing if missing else ''})" if shown not in label + else f" ({missing})" if missing else "")) left_out = "" if shared else drives_left_out(plat) if left_out: print(f" {left_out}") @@ -7286,7 +7386,8 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, why = ("" if default is not None or shared else " (no default, a folder above could not be opened)" if _default_set_aside(os_type, plat) else " (no default on this system)") - enter = (f"the folder {name} reads" if found is not None else "the default above") + enter = (f"the folder {name} reads" if found is not None + else "the card's bios folder" if cards else "the default above") prompt = (f"BIOS folder{numbers}, q to quit (Enter for {enter}): " if default else f"BIOS folder{numbers}, q to quit{why}: ") usage = f" Type a folder{', a number' if targets else ''}, or q to quit." @@ -7556,11 +7657,20 @@ def _pick( print(f"\n{title}") width = len(str(len(rows))) shown = {name: screen_text((labels or {}).get(name, name)) for name, _files in rows} - # The sizes in one column, whatever the longest label: cut at 40, the - # longer names pushed theirs right (tui_audit_13 P3-13). - name_width = max(cell_width(label) for label in shown.values()) - for index, (name, files) in enumerate(rows, 1): - print(f" {index:>{width}}) {_pad(shown[name], name_width)} {sizer(files)}") + sizes = {name: sizer(files) for name, files in rows} + # The sizes in one column, as wide as the terminal leaves the names: the + # longest label set it for every row, and 53 of 66 rows were folded at + # 80 columns (code_audit_21 P3-9). A longer label takes its own line. + lead = 2 + width + 2 + room = (shutil.get_terminal_size((80, 24)).columns - 1 - lead - 2 + - max(cell_width(size) for size in sizes.values())) + name_width = min(max(cell_width(label) for label in shown.values()), max(16, room)) + for index, (name, _files) in enumerate(rows, 1): + if cell_width(shown[name]) > name_width: + print(f" {index:>{width}}) {shown[name]}") + print(f"{' ' * (lead + name_width)} {sizes[name]}") + continue + print(f" {index:>{width}}) {_pad(shown[name], name_width)} {sizes[name]}") if note: print(f"\n{note}") hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5" @@ -7803,11 +7913,18 @@ def _prompt_custom_selection( # choice that holds none. games = tally(menu_choice(files, omitted, installed, current, False)[0] if cut is not None else []) + kept = menu_choice(files, omitted, installed, current, cutting)[0] + # A choice that keeps no file says so in the headline, as the screen + # does, and offers no Enter and no command: both installed nothing + # (code_audit_21 P3-6). + emptied = (emptied_words([system_title(name_, manifest) for name_ in current[0]], + cutting) + if isinstance(current, tuple) and not kept else "") _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path, current, object() if toggled else given, manifest, narrowed, menu_choice(files, omitted, installed, current, cutting), - games.games if cutting else 0) - if (current is not given or toggled) and command is not None: + games.games if cutting else 0, emptied) + if (current is not given or toggled) and command is not None and not emptied: print(f" Same choice without questions: {screen_text(command(current))}") offline = _offline_only() if offline: @@ -7820,7 +7937,9 @@ def _prompt_custom_selection( # Offline with nothing to copy from, nothing can be installed but # what is in place already. whole = "everything" if current is None else "these" - if dry_run: + if emptied: + pass + elif dry_run: print(f" Enter) list {whole}, nothing is written") elif offline and LOCAL.folders: print(" Enter) install what is found in " @@ -7846,22 +7965,14 @@ def _prompt_custom_selection( print(f" g) {game_data_state(cut[0], games)}: " f"{'keep it' if cut[0] else 'leave it out'}") print(" q) quit") - keys = ["", "q", *(["c"] if choosable else []), + keys = [*([] if emptied else [""]), "q", *(["c"] if choosable else []), *(["i"] if installed is not None and current != INSTALLED_ONLY else []), *(["f"] if copying else []), *(["g"] if games.games else [])] + others = [key for key in keys if key not in ("", "q")] answer = _ask("> ").lower() while answer not in keys: - print(f" Type {', '.join(['Enter', *keys[2:]])} or q.") + print(f" Type {', '.join([*([] if emptied else ['Enter']), *others])} or q.") answer = _ask("> ").lower() - kept = menu_choice(files, omitted, installed, current, cutting)[0] - if answer == "" and isinstance(current, tuple) and not kept: - # A choice that keeps no file stops here, as the screen offers - # no Install for it: Enter answered "All files up to date" - # (tui_audit_13 P3-11). - names = [system_title(name, manifest) for name in current[0]] - print(f" {emptied_words(names, cutting)} Type " - f"{', '.join(keys[2:])} or q.") - continue if answer == "": return current if answer == "q": @@ -7882,7 +7993,7 @@ def _print_menu_head( installed: tuple[list[dict], list[dict]] | None, name: str, trees: list[dict], bios_path: Path | None, current: object, given: object, manifest: dict | None, narrowed: bool, chosen: tuple[list[dict], list[dict]] | None = None, - cut_games: int = 0, + cut_games: int = 0, emptied: str = "", ) -> None: """The plan above the line menu: what the choice holds, its folder, what stands in its way and where the save files go, as the screen's plan. @@ -7917,9 +8028,16 @@ def _print_menu_head( if current is not given: print("\n Counted again for your choices. Nothing is written before Enter.") counts = tally(shown) - print(f"\n{name or display_name(plat)}: {sized_words(counts)}" - + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") - + named + ".") + if emptied: + # The screen's headline for a choice that keeps no file. + print(f"\n{name or display_name(plat)}: {emptied}") + if bios_path is not None: + print(f" BIOS folder: {shown_path(shown_bios_folder(bios_path, files))}") + return + # One total, data files in it, the kinds on the line under it adding up + # to it, as the screen's headline (code_audit_21 P3-8). + print(f"\n{name or display_name(plat)}: {_plural(counts.files + data, 'file')}, " + f"{format_size(counts.size + data_size)}" + named + ".") categorized = bool((manifest or {}).get("categorized")) kinds = counts if categorized else tally({"size": e.get("size")} for e in shown) parts = parts_words(kinds, data, data_size, cut_games) @@ -8697,6 +8815,13 @@ def pack_lacks(entries: list[dict]) -> list[dict]: return [] if LOCAL.pack is None else [e for e in entries if LOCAL.pack.find(e) is None] +def pack_holds_trees(data_dirs: list[dict]) -> bool: + """Whether the pack given to this run holds a file of any of these data + trees, which an install takes from it.""" + return LOCAL.pack is not None and any( + next(LOCAL.pack.tree(entry.get("dest", "")), None) is not None for entry in data_dirs) + + def pack_lacks_words(count: int, total: int) -> str: """The pack's missing files, said before the plan names a size.""" name = LOCAL.pack.name if LOCAL.pack is not None else "the pack" @@ -12117,6 +12242,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # RetroArch first, as the screen offers them. Its folder question # alone left ArkOS, muOS and JELOS out of reach (tui_audit_11 P1-2). print(f" {display_name(args.platform)} was not found here.") + say_unnamed_cards() platforms = _prompt_manual_platform( os_type, not (args.check or args.dry_run or args.yes), True, f"Which setup is the {display_name(args.platform)} list for?", setups, mounted) @@ -12125,6 +12251,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # filled with a card mounted in the reader, and with nothing # found here, without a question. print(f" {display_name(args.platform)} was not found here.") + say_unnamed_cards() platforms = [(args.platform, ask_platform_folder( os_type, args.platform, not (args.check or args.dry_run or args.yes)))] elif getattr(args, "platform_from_list", False): @@ -12145,9 +12272,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: hint = Invocation() hint.os_type, hint.options = os_type, _INVOCATION.options example = _default_dest(os_type, args.platform) + # The run's own way of answering, kept: a script copying the + # example stopped at a question (tui_audit_14 P3-12). + answered = "".join(f" {flag}" for flag, on in ( + ("--yes", args.yes), ("--no-input", args.no_input)) if on) print(" Name its folder with --dest, for example:\n " + hint.command(args.platform, example if example is not None - else ""), file=sys.stderr) + else "") + answered, file=sys.stderr) sys.exit(1) elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: @@ -12218,7 +12349,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if not platforms: # The screen's words where a person answers: "platform" was said # nowhere else. - print(" Nothing was found here." if interactive and not listing + unnamed = unnamed_cards() if interactive and not listing else [] + print(f" {unnamed_card_words(unnamed)}" + if unnamed else " Nothing was found here." if interactive and not listing else " No supported platform detected.") EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[]) # Standalone emulators read their BIOS from folders of their own, @@ -12607,20 +12740,25 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # The count every later line of the platform divides: the files # checked, planned, offered and installed are these. run_counts = tally(files) + # Whether the list was narrowed: the data trees kept are then those + # of the files kept. Computed once, for the head and the run, which + # must count the same trees (code_audit_21 P3-10). + narrowed = bool( + target or args.system or args.core or args.region or args.installed_cores + or args.declared_only or len(files) + game_data_cut + len(unplaced) + != len(manifest.get("files", [])) + len(variants if case_kept else []) + ) + data_dirs = select_data_directories(manifest, files, narrowed) # The kinds as the plan and the menu head say them (parts_words), # the data files of the folders kept among them: this head said # BIOS files and game data beside a plan of three kinds, neither - # defined (code_audit_20 P3-5). - head_trees = select_data_directories(manifest, files, bool( - target or args.system or args.core or args.region or args.installed_cores - or args.declared_only or len(files) + game_data_cut + len(unplaced) - != len(manifest.get("files", [])) + len(variants if case_kept else []))) - head_data = sum(len(entry.get("files") or {}) for entry in head_trees) - head_bytes = sum(meta.get("size", 0) for entry in head_trees + # defined (code_audit_20 P3-5). One total above them, the data files + # in it (code_audit_21 P3-8). + head_data = sum(len(entry.get("files") or {}) for entry in data_dirs) + head_bytes = sum(meta.get("size", 0) for entry in data_dirs for meta in (entry.get("files") or {}).values()) - print(f" {_plural(run_counts.files, 'file')} ({format_size(run_counts.size)})" - + (f", and {_plural(head_data, 'data file')} ({format_size(head_bytes)})" - if head_data else "")) + print(f" {_plural(run_counts.files + head_data, 'file')} " + f"({format_size(run_counts.size + head_bytes)})") categorized_list = bool(manifest.get("categorized")) kinds = run_counts if categorized_list else tally( {"size": entry.get("size")} for entry in files) @@ -12684,10 +12822,17 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: files, bios_path, jobs=args.jobs, term=term ) present = len(up_to_date) + len(mismatched) - print( - f" {present}/{run_counts.files} present " - f"({len(up_to_date)} verified, {len(mismatched)} wrong hash)" - ) + # The screen's words: "0/22 present (0 verified, 0 wrong hash)" + # beside its "0 in place, 22 missing, 0 wrong copies" (tui_audit_14 + # P3-10). The data files the head counts are among them, as the + # screen counts them (tui_audit_14 P3-7). + wrong = len(mismatched) + trees_missing = sum(len(data_directory_state(entry, bios_path)[1]) + for entry in data_dirs) + trees_held = head_data - trees_missing + print(f" {len(up_to_date) + trees_held} in place, " + f"{run_counts.files - present + trees_missing} missing, " + f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") # A partial copy left by an interrupted run is useless once the file # is in place: it goes. @@ -12719,12 +12864,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: accepted=core_choices, ) - narrowed = bool( - target or args.system or args.core or args.region or args.installed_cores - or args.declared_only or len(files) + game_data_cut + len(unplaced) - != len(manifest.get("files", [])) + len(variants if case_kept else []) - ) - data_dirs = select_data_directories(manifest, files, narrowed) data_states = [ (entry, data_directory_state(entry, bios_path)) for entry in data_dirs ] if args.check or EVENTS.on else [] @@ -12753,14 +12892,21 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for line in split_pack_lines(bios_path): print(line) offline = _offline_only() + # What the pack lacks is said before the count, as the + # screen says it (code_audit_21 P3-3). + lacking = pack_lacks([f for f in to_download if id(f) not in copies]) + if lacking: + print(f"\n {pack_lacks_words(len(lacking), len(to_download) - len(copies))}") print(f"\n {_plural(len(to_download), 'file')} " - + ("not in place:" if offline else "to download:")) + + ("not in place:" if offline or LOCAL.pack is not None + else "to download:")) for line in check_lines(missing, mismatched, files, manifest, bios_path, args.verbose, up_to_date, bios_index, copies): print(line) for line in unreadable_lines(unread, args.verbose): print(line) - print(_check_fetch_line(len(to_download) - len(copies), "the BIOS folder")) + print(_check_fetch_line(len(to_download) - len(copies), "the BIOS folder", + len(lacking))) check_pending += len(to_download) else: print("\n All files up to date.") @@ -12788,7 +12934,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: {"dest": f"{entry['dest']}/{rel}" if entry.get("dest") else rel} for entry, needed in trees for rel in needed ], bios_path) - _print_plan([f for f in to_download if id(f) not in copies], bios_path, term, + planned = [f for f in to_download if id(f) not in copies] + # Said before the plan promises a count and a size, as an + # install says it: the dry run promised 23 files where the pack + # held 20 (code_audit_21 P3-3). + lacking = {id(f) for f in pack_lacks(planned)} + if lacking: + print(f"\n {pack_lacks_words(len(lacking), len(planned))}") + _print_plan([f for f in planned if id(f) not in lacking], bios_path, term, trees, kept, copy_plan_lines(to_download, copies, bios_index, bios_path, files, args.verbose, True), verbose=args.verbose, manifest=manifest) @@ -12818,6 +12971,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: lacking = {id(f) for f in pack_lacks(fetched)} if lacking: print(f"\n {pack_lacks_words(len(lacking), len(fetched))}") + if lacking and len(lacking) == len(to_download) and not pack_holds_trees(data_dirs): + # Nothing here can come from the pack: no "Copying 0 files", no + # folder made, each file failed as not in it. + failures.extend((f["dest"], NOT_IN_PACK) for f in to_download) + total_errors += len(to_download) + files_pending += len(to_download) + total_up_to_date += len(up_to_date) + continue dl_size = sum(f.get("size", 0) for f in to_download if id(f) not in lacking) fetch_size = _bytes([f for f in fetched if id(f) not in lacking]) if not to_download: @@ -12852,8 +13013,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for line in copy_plan_lines(to_download, copies, bios_index, bios_path, files, args.verbose, False): print(line) + held = len(fetched) - len(lacking) rest = (f" and {'copying' if LOCAL.pack is not None else 'downloading'} " - f"{len(fetched)}" if fetched else "") + f"{held}" if held else "") where = f" from {LOCAL.pack.name}" if fetched and LOCAL.pack is not None else "" # Named by the folder they come from, as the line above # names it: "found on this machine" followed "from E:\". @@ -12868,8 +13030,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(f"\nCopying {_plural(len(copies), 'file')}{'' if rest else sized} " f"{origin}{rest}{where}{sized if rest else ''}...") elif LOCAL.pack is not None: - print(f"\nCopying {len(to_download)} files ({format_size(dl_size)}) " - f"from {LOCAL.pack.name}...") + # The files the pack holds: "Copying 23 files (85.0 KB)" sat + # beside the size of 20 (code_audit_21 P3-3). + print(f"\nCopying {_plural(len(to_download) - len(lacking), 'file')} " + f"({format_size(dl_size)}) from {LOCAL.pack.name}...") else: print(f"\nDownloading {len(to_download)} files ({format_size(dl_size)})...") print(" Press Ctrl+C to stop. Running the same command again " @@ -12962,7 +13126,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: line = f" {entry['ref']}: {counts['installed']} installed, " \ f"{counts['present']} already up to date" if counts["changed"] or counts["absent"]: - line += (f", {counts['changed'] + counts['absent']} no longer in " + # From a pack, the pack is the source that lacks them. + line += (f", {counts['changed'] + counts['absent']} not in " + f"{LOCAL.pack.name}" if LOCAL.pack is not None else + f", {counts['changed'] + counts['absent']} no longer in " "the source (the pack has them)") print(line) if counts["error"]: @@ -13444,6 +13611,94 @@ def _glued_words(text: str) -> list[str]: _SHELL_WORD = re.compile(r"""(?:'[^']*'|"[^"]*"|[^\s'"]+|['"])+""") +def _path_tail(text: str, width: int) -> str: + """A path cut to its last folders that fit after ".../", as a person + reads it on a narrow screen (".../retroarch/system"), a word that is no + path cut in its middle.""" + if cell_width(text) <= width: + return text + separator = "\\" if "\\" in text and "/" not in text else "/" + parts = text.rstrip(separator).split(separator) + tail = "" + for part in reversed(parts): + longer = f"{part}{separator}{tail}" if tail else part + if cell_width(f"...{separator}{longer}") > width: + break + tail = longer + return f"...{separator}{tail}" if tail else middle(text, width) + + +def _command_groups(text: str) -> list[str]: + """A command's shell words as they stay together on a line: a long + option with its value. "--platform" ended a line, its value on the next + (tui_audit_14 P3-8).""" + groups: list[str] = [] + for word in _SHELL_WORD.findall(text): + last = groups[-1] if groups else "" + if (last.startswith("--") and len(last) > 2 and " " not in last and "=" not in last + and not word.startswith("-") and word != "|"): + groups[-1] = f"{last} {word}" + else: + groups.append(word) + return groups + + +def _continuation() -> str: + """The character that ends a line of a command the shell reads on: a + backtick in PowerShell, a backslash in sh.""" + return "`" if os.environ.get("RETROBIOS_BOOTSTRAP") == "ps1" or os.name == "nt" else "\\" + + +def _command_wrap(text: str, width: int, indent: str = " ") -> list[str]: + """A command too wide for width, broken between its shell words, each + line but the last ending with the continuation the shell accepts, so a + copy of the lines runs as the command does. A line one group wide that + fits only without the indent loses it. A path still wider keeps its + last folders (_path_tail), said by TUI_SHORTENED: folded by the + terminal, it read as broken inside a word (tui_audit_14 P3-8). A web + address is never cut.""" + lead = text[:len(text) - len(text.lstrip(" "))] + tail = " " + _continuation() + groups = _command_groups(text) + lines: list[str] = [] + line = "" + + def close(done: str, last: bool) -> str: + end = "" if last else tail + prefix = lead + indent if lines else lead + for fewer in (lead, ""): + if cell_width(prefix + done + end) > width >= cell_width(fewer + done + end): + prefix = fewer + return prefix + done + end + + for number, group in enumerate(groups): + end = 0 if number == len(groups) - 1 else len(tail) + room = width - cell_width(lead + indent) - end + # A group is an option and its value: split at the first space, a + # quoted value keeping its own. + option, _space, value = (group.partition(" ") if group.startswith("--") and " " in group + else ("", "", group)) + if cell_width(group) > room and "://" not in value and _is_path_word(value.strip("'\"")): + keep = cell_width(option) + 1 if option else 0 + quote = value[0] if value[:1] in "'\"" and value[-1:] == value[:1] else "" + bare = value[1:-1] if quote else value + group = (f"{option} " if option else "") + quote + _path_tail( + bare, max(8, room - keep - 2 * len(quote))) + quote + prefix = lead + indent if lines else lead + longer = f"{line} {group}" if line else group + if not line or cell_width(prefix + longer + (tail if end else "")) <= width: + line = longer + continue + lines.append(close(line, False)) + line = group + lines.append(close(line, True)) + return lines + + +# Said under a command a path of which was cut to fit (_command_wrap). +TUI_SHORTENED = "Shortened to fit this window." + + def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[str]: """Lines of at most width cells, the ones after the first indented. @@ -13454,10 +13709,12 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st and a backup folder's dated name fell on a line of its own.""" if cell_width(text) <= width: return [text] + if whole == "command": + return _command_wrap(text, width, indent) lead = text[:len(text) - len(text.lstrip(" "))] lines: list[str] = [] line = "" - words = (_SHELL_WORD.findall(text) if whole == "command" else _glued_words(text)) + words = _glued_words(text) def close(done: str) -> str: # A line one word wide that fits without the indent loses it: one @@ -13493,8 +13750,12 @@ def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[st # The rest of a word broken at a slash joins it without a space: # a path was drawn "retroarch/ system", and copied wrong. longer = f"{line}{'' if index else ' '}{piece}" if line else piece + # A path's pieces keep the indent, which they are cut to fit: + # joined past it, the last piece of a folder lost its indent + # (tui_audit_14 P3-9). if not line or cell_width(prefix + longer) <= width or ( - lines and " " not in longer and cell_width(lead + longer) <= width): + lines and " " not in longer and cell_width(lead + longer) <= width + and not _is_path_word(word)): line = longer else: lines.append(close(line)) @@ -13544,7 +13805,11 @@ def _bar_spans(text: str, first: str) -> list[tuple[int, int, str]]: spans = [] for number, entry in enumerate(re.finditer(r"\S+(?: \S+)*", text)): key = entry.group().split(" ", 1)[0] - if key in _bar_keys() and " " in entry.group(): + if entry.group().endswith(TUI_ARMED): + # Armed by a first tap: the whole entry stands out, a plain + # ": tap again" looked like no change at all (tui_audit_14 P3-2). + spans.append((entry.start(), entry.end(), first)) + elif key in _bar_keys() and " " in entry.group(): spans.append((entry.start(), entry.start() + len(key), first if number == 0 else "muted")) return spans @@ -13651,6 +13916,9 @@ class TuiState: # (platform, folder) of the setups that are a card in a reader, not # this machine: a setup chosen for that system asks for its card. self.mounted_setups: set = set() + # The mounted drives that are a handheld's card no file names + # (unnamed_cards): their bios folder comes first at Which folder?. + self.unnamed: list = [] self.notes: list = [] # The folders detection could not look in, one line each. self.refused: list = [] @@ -13713,6 +13981,10 @@ class TuiState: # those of them that hold nothing under its options. self.ticked_names: dict[str, str] = {} self.emptied: list[str] = [] + # Systems ticked that the last check did not list, game data left + # out: still the choice, ticked again by the check that lists them + # (tui_audit_14 P2-2), as line mode's g keeps its choice. + self.hidden_ticks: set[str] = set() # Choose systems asked on a plan --system narrowed: the systems are # opened once the check of every system is in. self.widen: bool = False @@ -14491,14 +14763,22 @@ def tui_rows(state: TuiState) -> list[TuiRow]: # The Windows side's folder said missing where it is: taken, the # plan said RetroArch reads a folder no RetroArch had (tui_audit_13 # P3-9). + # Every row whose folder is missing says so, in both modes: the + # Windows row alone did (code_audit_21 P3-7). drives = [] if state.folder_kind == "share" else [ - TuiRow("drive", label, value=folder) - for label, folder in [*((label + ("" if _is_dir(folder) - else " (does not exist yet)"), folder) - for label, folder in ( - other_side(state.os_type, state.platform) - if state.folder_kind == "usual" else [])), + TuiRow("drive", label + ("" if _is_dir(folder) else " (does not exist yet)"), + value=folder) + for label, folder in [*(other_side(state.os_type, state.platform) + if state.folder_kind == "usual" else []), *drive_targets(state.platform)]] + cards = {mount / "bios" for mount in state.unnamed} + if state.folder_kind == "usual" and any(row.value in cards for row in drives): + # A handheld's card in the reader, no setup found here: the + # card first, under A (tui_audit_14 P2-3). + first = [row for row in drives if row.value in cards] + return first + rows + [TuiRow("browse", "Browse for a folder"), + TuiRow("type", "Type a path")] + [ + row for row in drives if row not in first] return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + drives # Only the cards whose own files say they run the system: A on the @@ -14573,6 +14853,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "confirm": if state.running or state.dry is None: return [] + if not _needed(state.dry) and not state.dry.get("data_missing"): + # Counted again, every file is in place: Install wrote nothing + # and said Done (code_audit_21 P3-4). + return [TuiRow("close", "Close"), TuiRow("back", "Back")] if _offline_only() and not _dry_copies(state): # The plan's rule: Install would only fail every file. return [TuiRow("back", "Back")] @@ -14600,9 +14884,27 @@ def _emptied_words(state: TuiState) -> str: return emptied_words(state.emptied, state.no_game_data) +def _choice_in_place(state: TuiState) -> str: + """The plan's headline when every file of the systems ticked is in + place, while the check counted the whole list: "NEC PC Engine is in + place", Install offered nothing (code_audit_21 P3-4). "" otherwise.""" + if not state.systems or not state.ticked or not _tui_estimating(state): + return "" + chosen = [row for row in state.systems if row["system"] in state.ticked] + if not chosen or any(_needed(row) for row in chosen) or any( + event["state"] != "present" for event in _ticked_files(state)): + return "" + if _plan_data(state, state.check or {})[0]: + return "" + titles = _system_titles(state) + names = [_tui_system_label(row, titles) for row in chosen] + subject = (", ".join(names[:-1]) + " and " + names[-1]) if len(names) > 1 else names[0] + return f"{subject} {'are' if len(names) > 1 else 'is'} in place: nothing is left to install." + + def _plan_rows(state: TuiState) -> list[TuiRow]: check = state.check or {} - if state.emptied and state.systems and not state.ticked: + if (state.emptied and state.systems and not state.ticked) or _choice_in_place(state): # The choice emptied by an option: Choose systems first, never an # Install that would refuse or take the whole list. rows = [TuiRow("systems", "Choose systems")] @@ -14625,8 +14927,9 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: files = {event["dest"] for event in state.files} if (LOCAL.pack is not None and files and not _plan_copies(state, check) and len(_tui_pack_lacks(state, sorted(files))) == len(files) - and not check.get("data_missing")): - # The pack holds none of them: Install would fail every one. + and not (check.get("data_missing") and _tui_pack_trees(state))): + # The pack holds none of them, nor the data files: Install would + # fail every one (tui_audit_14 P3-1). rows = [] if _on_its_card(state) and (state.facts.get("writable") is False or _short_of_space(state, check)): @@ -14688,20 +14991,31 @@ def _tui_tally(state: TuiState, events: Iterable[dict]) -> Tally: def _tui_estimating(state: TuiState) -> bool: """Whether the plan counts the systems ticked since the check, not the check itself.""" - return bool(_choice_line(state) and state.systems and state.check is not None - and not _offline_only()) + if not (_choice_line(state) and state.systems and state.check is not None): + return False + if _offline_only(): + # Offline the estimate is of the ticks alone: a folder to copy from + # is counted by the check it starts. + def ticked(flags: list) -> list: + return [flag for flag in flags if flag[0] == "--system"] + return ticked(tui_flags(state)) != ticked(state.checked_flags) + return True -def _choice_entries(state: TuiState) -> list[dict]: +def _choice_entries(state: TuiState, hidden: bool = False) -> list[dict]: """The list's files the choice made keeps, game data included: the systems ticked (or named with --system), the cores and the regions, as line mode's menu_choice narrows them. The game data row, the save line and the data files count these: over the whole list, a plan of one - system said 5089 game data files and 2.5 GB nothing would install.""" + system said 5089 game data files and 2.5 GB nothing would install. + hidden adds the systems ticked that game data left out hides + (hidden_ticks), whose files the game data row brings back.""" manifest = state.manifest or {} systems = [value for flag, value in tui_flags(state) if flag == "--system" and value] if state.emptied and not state.ticked: systems = sorted(state.ticked_names) + elif hidden and systems: + systems = sorted({*systems, *state.hidden_ticks}) # The second spellings the check installs, as line mode adds them where # the folder keeps case. variants = [entry for entry in manifest.get("case_variants") or [] @@ -14734,7 +15048,7 @@ def _game_data_row(state: TuiState) -> TuiRow | None: has none, as line mode offers no g then: one decision in both modes.""" if not _tui_categories(state) or state.cli_no_game_data: return None - games = tally(entry for entry in _choice_entries(state) + games = tally(entry for entry in _choice_entries(state, hidden=True) if entry.get("category") == GAME_DATA) if not games.games: return None @@ -14960,10 +15274,14 @@ def _keep_choice(state: TuiState) -> None: state.keep_ticked = set(state.ticked_names) return needed = {row["system"] for row in state.systems if _needed(row)} - state.keep_ticked = None if needed <= state.ticked else set(state.ticked) + state.keep_ticked = (None if needed <= state.ticked and not state.hidden_ticks + else state.ticked | state.hidden_ticks) titles = _system_titles(state) - state.ticked_names = {row["system"]: _tui_system_label(row, titles) - for row in state.systems if row["system"] in state.ticked} + state.ticked_names = { + **{name: label for name, label in state.ticked_names.items() + if name in state.hidden_ticks}, + **{row["system"]: _tui_system_label(row, titles) + for row in state.systems if row["system"] in state.ticked}} def tui_quit(state: TuiState, code: int | None = None, asked: bool = True) -> list[tuple]: @@ -15685,6 +16003,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "install": state.stack.pop() return _install_now(state) + if action == "close": + state.exit_code = 0 + return [] return _back(state) if screen == "error": if action == "retry": @@ -16528,6 +16849,14 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: # Lines one notch of the wheel moves. TUI_WHEEL_LINES = 3 +# Seconds after a screen is drawn during which a press is taken for the +# second tap of a double tap made on the screen before, and dropped. A +# double click is two presses at most 400 ms (GTK, GNOME) to 500 ms +# (Windows, macOS) apart, and the new screen is drawn on the first release, +# about 100 ms after the first press: 0.4 s after the draw covers both. A +# deliberate tap comes later, a reader taking longer than that to find it. +TUI_TAP_SETTLE = 0.4 + def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: """What a click, a tap or the wheel does, read against the frame on @@ -16563,11 +16892,13 @@ def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]: return [] button = event.button & 3 # A tap is a press and a release on one target of one frame: a release - # on a frame drawn after its press is dropped, so the second tap of a - # double tap that changed the screen does nothing on the new one. + # on a frame drawn after its press is dropped, and so is a press within + # TUI_TAP_SETTLE of a new screen, so the second tap of a double tap that + # changed the screen does nothing on the new one (tui_audit_14 P2-1). seen_on = (hits.frame, state.screen, len(state.stack), state.help, target) if not event.release: - state.pressed = seen_on if button == 0 else None + settled = state.now - hits.shown >= TUI_TAP_SETTLE + state.pressed = seen_on if button == 0 and settled else None return [] pressed, state.pressed = state.pressed, None if button not in (0, 3) or pressed != seen_on: @@ -16614,26 +16945,34 @@ TUI_TICK_ROWS = ("system", "core", "region") def _bar_commits(state: TuiState, key: TuiKey) -> bool: - """Whether a bar entry's key installs, quits or goes on to the last look - before an install: those take two taps, as a row does.""" - if key.name == "ctrl-c": - return True + """Whether a bar entry's key writes files with no screen after it to + look first: the plan's Install when it installs at once, and Put it on + the card. Those take a second tap. Anything else, Quit, Close, Continue + and Install on Confirm (itself the look before the install) included, + acts on one: TUI_TAP_SETTLE already keeps a double tap from reaching + the next screen.""" if key.name != "enter": return False rows = tui_rows(state) if not rows: return False row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] - return row.action in ("install", "quit", "close") or ( - state.screen == "systems" and row.action == "system") + if state.screen == "plan" and row.action == "install": + # Counted again first when the options changed: Confirm comes next. + return bool(state.ticked or not state.systems) and \ + tui_flags(state) == state.checked_flags + return state.screen == "result" and row.action == "addapp" def _bar_tap(state: TuiState, key: TuiKey) -> list[tuple]: - """A tap on a bar entry. One that installs, quits or opens Confirm is - drawn armed by the first tap and pressed by the second on the same - screen: the first entry keeps its cell from screen to screen, and a - double tap installed from the plan's bar, or quit from the Checking - screen with Ctrl-C's code (tui_audit_13 P2-1).""" + """A tap on a bar entry. One that writes files (_bar_commits) is drawn + armed by the first tap and pressed by the second on the same screen.""" + if key.name == "ctrl-c": + if state.run == "install" and state.running: + return tui_interrupt(state) + # A quit chosen with a finger is a quit chosen: QUIT_CODE, never + # the code of an interrupted run. + return tui_quit(state) if not _bar_commits(state, key): return tui_update(state, key) armed = ("bar", state.screen, len(state.stack), key) @@ -16641,12 +16980,6 @@ def _bar_tap(state: TuiState, key: TuiKey) -> list[tuple]: state.tapped = armed return [] state.tapped = None - if key.name == "ctrl-c": - if state.run == "install" and state.running: - return tui_interrupt(state) - # A quit chosen with a finger is a quit chosen: QUIT_CODE, never - # the code of an interrupted run. - return tui_quit(state) return tui_update(state, key) @@ -16655,12 +16988,17 @@ def _armed_hint(state: TuiState, parts: tuple) -> tuple: presses it.""" tapped = state.tapped if (state.help or not tapped or tapped[0] != "bar" or tapped[1] != state.screen - or tapped[2] != len(state.stack)): + or tapped[2] != len(state.stack) or not _bar_commits(state, tapped[3])): return parts - return tuple(f"{part}: tap again" if _bar_click(state, part.split(" ", 1)[0]) == tapped[3] + return tuple(f"{part}: {TUI_ARMED}" if _bar_click(state, part.split(" ", 1)[0]) == tapped[3] and " " in part else part for part in parts) +# What an armed bar entry adds to its name, drawn in the accent: a mouse +# clicks and a finger taps, and the screen cannot tell which it was. +TUI_ARMED = "click or tap again" + + def _wheel(state: TuiState, hits: TuiHits, step: int) -> list[tuple]: """A notch of the wheel: the list, the message or the text moves, and the focus moves only when it would leave the rows in view.""" @@ -16842,8 +17180,11 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: return _fail(state, tail, code) state.ticked = {row["system"] for row in state.systems if _needed(row)} state.emptied = [] + state.hidden_ticks = set() if state.keep_ticked is not None: - state.ticked = state.keep_ticked & {row["system"] for row in state.systems} + listed = {row["system"] for row in state.systems} + state.ticked = state.keep_ticked & listed + state.hidden_ticks = state.keep_ticked - listed if state.keep_ticked and not state.ticked: # Every system chosen holds nothing under these options: # said, never taken as every system (tui_audit_13 P1-1). @@ -17071,7 +17412,16 @@ def _text_lines(items: list, width: int, whole: bool = False) -> list[TuiLine]: command = style == "command" or getattr(item, "command", False) kind = "command" if command else "text" if whole else "" folder = str(item).lstrip(" ").startswith(_FOLDER_LABEL) - for number, piece in enumerate(_wrap(str(item), width, whole=kind)): + # A folder on its own line wraps at its own indent. + pieces = _wrap(str(item), width, "" if style == "path" and str(item).startswith(" ") + else " ", whole=kind) + if command and "..." in "".join(pieces) and "..." not in str(item): + # Its path cut to fit: said under it, never as part of it. + pieces.append(TUI_SHORTENED) + for number, piece in enumerate(pieces): + if piece is TUI_SHORTENED: + lines.append(_styled(fit(piece, width), "dim")) + continue if cell_width(piece) <= width: # The rest of a BIOS folder's line keeps its weight. line = _styled(fit(piece, width), "folder" if folder and number @@ -17172,8 +17522,15 @@ def _compose( budget -= len(top) gap = [_styled("")] if top and row_lines and budget > 0 else [] budget -= len(gap) - tail = _text_lines(list(after), width) - bottom = ([_styled("")] + tail)[:budget] if tail and budget > 1 else [] + # Whole items only: cut to the room left, a note ended in the + # middle of its sentence at 40x12 (tui_audit_14 P3-5). + tail: list[TuiLine] = [] + for item in after: + lines = _text_lines([item], width) + if 1 + len(tail) + len(lines) > budget: + break + tail += lines + bottom = [_styled(""), *tail] if tail else [] body = top + gap + row_lines + bottom body += [_styled("")] * (room - len(body)) return body + footer_lines @@ -17299,13 +17656,16 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: data_bytes = check.get("data_bytes", 0) if data else 0 if elsewhere: # The folder by its last name: the whole path pushed the BIOS - # folder off a 40x15 screen. The Options row spells it out. + # folder off a 40x15 screen. The Options row spells it out. The + # data files are among those to fetch: one count (code_audit_21 + # P3-8). where = ", ".join(f"{len(events)} found in {_last_name(folder)}" for folder, events in elsewhere.items()) - headline = (f"{where}, {fetch} need the internet ({offline_named()})" - if fetch and offline - else f"{where}, {fetch} to download ({format_size(fetch_bytes)})" - if fetch else f"{where}, nothing to download") + rest = fetch + data + headline = (f"{where}, {rest} need the internet ({offline_named()})" + if rest and offline + else f"{where}, {rest} to download ({format_size(fetch_bytes + data_bytes)})" + if rest else f"{where}, nothing to download") elif need and fetch and offline: both = fetch + data > 1 headline = (f"{_plural(fetch + data, 'file')} {'need' if both else 'needs'} the " @@ -17324,22 +17684,31 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: elif need and fetch: headline = (f"{_plural(fetch + data, 'file')} to download, " f"{format_size(fetch_bytes + data_bytes)}") + elif need and data: + copied_bytes = sum(sizes[dest_] for dest_ in copied) + headline = (f"{_plural(len(copied) + data, 'file')} to install, " + f"{format_size(copied_bytes + data_bytes)}") elif need: headline = f"{_plural(len(copied), 'file')} to copy from this machine" # The data trees' size comes from their own event: "2636 data files" # beside "6.6 GB" said nothing of what they weigh. data_size = (f" ({format_size(check['data_bytes'])})" if data and "data_bytes" in check else "") - if need and data and (elsewhere or not fetch): - headline += f", and {_plural(data, 'data file')}{data_size}" if not need: headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data else "Everything is in place") elif lacking and not fetch and not copied: - headline = pack_lacks_words(len(lacking), len(lacking)) + # The data files too, when the pack holds none of them: "8252 + # files" said nothing of the 2636 it cannot give either. + lacked = len(lacking) + (data if data and not _tui_pack_trees(state) else 0) + headline = pack_lacks_words(lacked, lacked) wrong = check["wrong_hash"] + # Where some were found, the plan names the rest by kind ("Not in old: + # 10 BIOS files and 6 game data files.", _view_plan), and the remedy + # follows it: "They" sat under a split of all 22 (tui_audit_14 P3-7). offline_lines = ([_offline_remedy([_last_name(str(folder)) - for folder in state.from_folders])] + for folder in state.from_folders], + named=bool(elsewhere) and not dry)] if offline and fetch else []) # The folder comes second: on a 40x12 screen it is what is left once # the rows are drawn, and nothing is written before it is seen. @@ -17360,7 +17729,10 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # Said again where it is the BIOS folder: the browser said it, and the # plan named ~ alone. lines += filter(None, [_no_setup_reads(state, dest)]) - counts = (f"{check['present']} in place, {check['missing']} missing, " + # The data files missing among the missing: the headline counts them, + # and "10888 need the internet" sat over "8252 missing" (tui_audit_14 + # P3-7). + counts = (f"{check['present']} in place, {check['missing'] + data} missing, " f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") # A first install's missing files are its normal state, not a warning. lines.append(_styled(counts, "quiet") if not check["present"] and not wrong else counts) @@ -17442,6 +17814,12 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: return lines[:at] + offline_lines + lines[at:] +def _tui_pack_trees(state: TuiState) -> bool: + """Whether the pack given to the run holds a file of the list's data + trees.""" + return pack_holds_trees((state.manifest or {}).get("data_directories") or []) + + def _tui_pack_lacks(state: TuiState, dests: list[str]) -> list[str]: """The destinations of a plan the pack given to the run does not hold.""" if LOCAL.pack is None or not dests: @@ -17511,9 +17889,11 @@ def _saves_lines(state: TuiState, saves: dict | None, whole: bool = False) -> li words, folder = save_where_words(saves["folder"]) # The folder alone is cut in the middle: cut with the sentence, it # lost "directory goes to" even at 80 columns. + # The folder on its own line at the BIOS folder's indent: at the + # label's, the two folders of a plan sat at two (tui_audit_14 P3-9). return [f"{_plural(count, 'file')} read from {name}'s save directory " f"{'goes' if count == 1 else 'go'} to{' ' + words if words else ''}:", - shown_path(folder) if whole else _short_path(Path(folder), state.home)] + " " + (shown_path(folder) if whole else _short_path(Path(folder), state.home))] def _unplaced_lines(summary: dict | None, state: TuiState, @@ -17529,6 +17909,13 @@ def _unplaced_lines(summary: dict | None, state: TuiState, f"its place is not known. {remedy or TUI_SAVE_REMEDY}"] +# Said when ES-DE is found, and in fewer words where the long form would +# give way: at 40x12 it ended in the middle of its sentence (tui_audit_14 +# P3-5). +TUI_ESDE_NOTE = "ES-DE found: its BIOS files go to each emulator, not to ES-DE itself." +TUI_ESDE_SHORT = "ES-DE: files go to each emulator." + + def _view_setup(state: TuiState, width: int, height: int) -> tuple: rows = tui_rows(state) focus = rows[min(state.focus.get("setup", 0), len(rows) - 1)] @@ -17538,10 +17925,15 @@ def _view_setup(state: TuiState, width: int, height: int) -> tuple: else "A drive here could be either of these:" if state.doubts 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, - pinned=state.refused), - ("A/Enter Continue", "B/Esc Quit", "? Help")) + body = _compose(state, width, height, before, rows, after + status + state.notes, + pinned=state.refused) + if TUI_ESDE_NOTE in state.notes and not any( + str(line).strip().startswith("ES-DE found") for line in body): + # The note gave way whole: its short form, where it fits. + notes = [TUI_ESDE_SHORT if note == TUI_ESDE_NOTE else note for note in state.notes] + body = _compose(state, width, height, before, rows, after + status + notes, + pinned=state.refused) + return ("Where do you play?", body, ("A/Enter Continue", "B/Esc Quit", "? Help")) def _view_platforms(state: TuiState, width: int, height: int) -> tuple: @@ -17559,7 +17951,11 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: # A handheld's owner knows its device, not its system's name: the # filter finds it, and a pad that cannot type finds the cards right # after RetroArch (_cards_first). - pinned.append("Nothing was found here. Choose the setup, or type your device's " + pinned.append(unnamed_card_words(state.unnamed, ", or type your device's name (/)." + if state.pad in ("both", "keys") else + ", handhelds after RetroArch.") + if state.unnamed else + "Nothing was found here. Choose the setup, or type your device's " "name (/)." if state.pad in ("both", "keys") else "Nothing was found here. Choose the setup: handhelds come after " "RetroArch.") @@ -17614,6 +18010,10 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: f"{side_name(state.os_type, state.platform)} usually reads its BIOS " "files from:") before = [lead, str(state.folder_default)] + if (state.folder_kind == "usual" and tui_rows(state) + and tui_rows(state)[0].value in {mount / "bios" for mount in state.unnamed}): + before = ["The card in the reader keeps them in its bios folder, first below. " + f"{lead}", str(state.folder_default)] elif state.folder_kind == "closed": title = "Which folder?" before = [f"Choose another folder for {name}: browse for it or type it."] @@ -17750,19 +18150,44 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: if state.emptied and state.systems and not state.ticked: lines = [_styled(_emptied_words(state), "title"), *lines[1:2]] estimating = False + elif _choice_in_place(state): + lines = [_styled(_choice_in_place(state), "title"), *lines[1:2]] + choice = [] + estimating = False elif estimating: # The headline counts the systems ticked, as the list just did: at # 40x12 the line below it was dropped, and the plan showed the # total of all of them. Their data files too, as line mode's head. chosen = _ticked_files(state) - more = (f", and {_plural(data, 'data file')} ({format_size(data_size or 0)})" - if data else "") - lines[0] = _styled(f"About {_plural(len(chosen), 'file')}, " - f"{format_size(sum(e['size'] for e in chosen))}{more}, for " - f"{len(state.ticked)} of {len(state.systems)} systems", "title") + # One total, the data files in it, the kinds under the folder + # adding up to it (code_audit_21 P3-8). + lines[0] = _styled(f"About {_plural(len(chosen) + data, 'file')}, " + f"{format_size(sum(e['size'] for e in chosen) + (data_size or 0))}" + f", for {len(state.ticked)} of {len(state.systems)} systems", "title") + if _offline_only(): + # Offline, what the folders copied from hold of the systems + # ticked: the plan counted the whole list beside "For your 1 of + # 11 systems" (code_audit_21 P3-5). + took = {event["dest"] for events in _tui_found_elsewhere(state).values() + for event in events} + held = [event for event in chosen if event["dest"] in took] + rest = len(chosen) - len(held) + data + lines[0] = _styled( + f"About {_plural(len(held), 'file')}, " + f"{format_size(sum(e['size'] for e in held))}, for {len(state.ticked)} of " + f"{len(state.systems)} systems" + + (f", {rest} {'needs' if rest == 1 else 'need'} the internet " + f"({offline_named()})" if rest else ""), "title") + if not rest: + # The remedy was for files of the whole list: none of the + # choice needs the internet. + names = [_last_name(str(folder)) for folder in state.from_folders] + remedies = {_offline_remedy(names), _offline_remedy(names, named=True)} + lines = [line for line in lines if str(line) not in remedies] # The in place and missing counts are the whole list's: under an # estimate for some systems they read as its own. - whole = (f"{check['present']} in place, {check['missing']} missing, " + whole = (f"{check['present']} in place, " + f"{check['missing'] + check.get('data_missing', 0)} missing, " f"{check['wrong_hash']} wrong ") lines = [line for line in lines if not str(line).startswith(whole)] # Under the folder and what named it, in this order: the card's note, @@ -17782,8 +18207,23 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: # estimates them, those to install otherwise. Under the folder, never # above it: inserted second, it pushed the folder off a 40x12 plan # (tui_audit_12 P3-10). - parts, defined = _kinds_lines(state, _ticked_files(state) if estimating else [ - event for event in state.files if event["state"] != "present"], data, data_size) + kinds_of = (_ticked_files(state) if estimating else + [event for event in state.files if event["state"] != "present"]) + found = (_tui_found_elsewhere(state) if not estimating and _offline_only() + and state.check is check else {}) + if found: + # Some were found in a folder: the kinds split those the headline + # says need the internet, named as such (tui_audit_14 P3-7). + took = {event["dest"] for events in found.values() for event in events} + kinds_of = [event for event in kinds_of if event["dest"] not in took] + parts, defined = _kinds_lines(state, kinds_of, data, data_size, whole=bool(found)) + if _choice_in_place(state) or (state.emptied and state.systems and not state.ticked): + # Nothing of the choice is left to install: a split of the whole + # list under its headline counted files it does not hold. + parts = defined = "" + if found and parts: + parts = (f"Not in {', '.join(_last_name(str(folder)) for folder in found)}: " + f"{parts}") lines[at:at] = [*([state.card_note] if state.card_note and state.card_note != state.status else []), *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice, @@ -17810,7 +18250,8 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: # one way on the plan and another on the result (tui_audit_13 P3-3). lines = [piece for line in lines for piece in ( [_styled(part, "folder" if index and str(line).startswith(_FOLDER_LABEL) - else "") for index, part in enumerate(_wrap(str(line), width))] + else "") for index, part in enumerate(_wrap( + str(line), width, "" if str(line).startswith(" ") else " "))] if getattr(line, "style", "") == "path" and cell_width(str(line)) > width else [line])] rows = tui_rows(state) @@ -17835,10 +18276,38 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: system = detected_os(state.platform, state.dest) if state.dest is not None else "" name = display_name(state.platform, state.manifest) return (f"{system} ({name} files)" if system and system != name else name, - _compose(state, width, height, lines, rows), + _plan_body(state, width, height, lines, rows), ("A/Enter Install", "B/Esc Back", "Y/o Options", "? Help")) +def _plan_body(state: TuiState, width: int, height: int, lines: list, + rows: list[TuiRow]) -> list[TuiLine]: + """The plan's text and rows. Where both do not fit, the headline, the + whole BIOS folder and the version note stay and the rows scroll under + them: at 40x12 the plan named ~/.var/app/ as the folder, and dropped the + note that the list is for another version (tui_audit_14 P3-6).""" + body = _compose(state, width, height, lines, rows) + at = next((index for index, line in enumerate(lines) + if str(line).startswith(_FOLDER_LABEL.rstrip())), None) + kept = list(lines[:1]) + if at is not None: + # The folder line and the rest of the folder wrapped after it. + end = at + 1 + while end < len(lines) and getattr(lines[end], "style", "") == "folder": + end += 1 + kept += lines[at:end] + kept += [line for line in lines if state.version and str(line) == state.version] + top = _text_lines(kept, width) + drawn = [str(line).strip() for line in body] + # Three lines at least for the rows: two of them and where the list is, + # the blank line above them given up first. + if (all(str(line).strip() in drawn for line in top) + or len(top) + min(len(rows), 3) > height): + return body + gap = [_styled("")] if len(top) + 1 + min(len(rows), 3) <= height else [] + return top + gap + _compose(state, width, height - len(top) - len(gap), [], rows) + + def _plan_data(state: TuiState, check: dict) -> tuple[int, int | None]: """The data files the plan counts and their size: the check's, or, while the plan estimates the systems ticked, those of the data folders @@ -17852,10 +18321,10 @@ def _plan_data(state: TuiState, check: dict) -> tuple[int, int | None]: def _kinds_lines(state: TuiState, events: list[dict], data: int, - data_size: int | None) -> tuple[str, str]: + data_size: int | None, whole: bool = False) -> tuple[str, str]: """The plan's files by kind (parts_words) and what each kind is: the same words as line mode's head, game data left out counted over the - choice.""" + choice. whole: the kinds are said even when there is one.""" categories = _tui_categories(state) categorized = bool(categories) if state.no_game_data: @@ -17867,6 +18336,8 @@ def _kinds_lines(state: TuiState, events: list[dict], data: int, if categorized and state.no_game_data: left = sum(1 for entry in _choice_entries(state) if entry.get("category") == GAME_DATA) parts = parts_words(counts, data, data_size, left) + if whole and not parts and counts.files: + parts = f"{_plural(counts.files, 'file')} ({format_size(counts.size)})" # Game data named as missing is defined too, on its own line: the # definition was spliced into that sentence (tui_audit_13 P3-5). missing = any(entry.get("category") == GAME_DATA for entry in _unavailable(state)[0]) @@ -18084,7 +18555,8 @@ def _view_confirm(state: TuiState, width: int, height: int) -> tuple: # Back alone: one Back on the bar, and no word of an Install. Titled # for what it is: "Confirm" over nothing to confirm. return ("Nothing to install", _compose(state, width, height, lines, tui_rows(state)), - ("A/Enter Back", "? Help")) + ("A/Enter Back", "B/Esc Back", "? Help") + if tui_rows(state)[0].action == "close" else ("A/Enter Back", "? Help")) return ("Confirm", _compose(state, width, height, lines, tui_rows(state)), ("A/Enter Install", "B/Esc Back", "? Help")) @@ -18303,6 +18775,11 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # twice, the remedy between them (tui_audit_11 P3-18). lines[0] = sources sources = "" + elif title == "Done" and sources and "," not in sources and not summary.get("kept"): + # Every file from one place: "22 files installed." over "22 files + # copied from packx." said one count twice (tui_audit_14 P3-10). + lines[0] = sources + head[head.index(".") + 1:] + sources = "" if sources: lines.append(sources) if left: @@ -18360,6 +18837,14 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: shown += 1 if shown > 20: lines.append(f" ... and {shown - 20} more") + expected = (state.dry if state.dry is not None else state.check or {}).get( + "data_missing", 0) + landed = max(0, installed - sum(1 for _dest, good, _why in state.finished if good)) + if expected > landed and not left and title != "Stopped" and summary: + # The data files the plan counted, never named on a result that + # counted the BIOS files alone (tui_audit_14 P3-1). + lines.append(f"{_plural(expected - landed, 'data file')} not installed" + + (f": not in {LOCAL.pack.name}." if LOCAL.pack is not None else ".")) if state.dest is not None and any(failure["reason"] == NOT_WRITABLE for failure in failed): # The plan's own advice: the result offered nothing after 22 files @@ -18394,13 +18879,16 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: if state.mouse and typed: # A drag over the command selected nothing: the terminal sends the # press as a report. Said where it is read (tui_audit_13 P3-10). - lines.append(_styled(TUI_RESULT_COPY, "dim")) + lines.append(_styled(TUI_RESULT_AGAIN if state.os_type == "android" + else TUI_RESULT_COPY, "dim")) return title, lines # How to copy from the result, under its command while the mouse is on. -TUI_RESULT_COPY = ("Shift and a drag select text. All of this is shown again in the " - "terminal after Exit.") +# Termux has no Shift key under a finger: there the second sentence alone +# (tui_audit_14 P3-3). +TUI_RESULT_AGAIN = "All of this is shown again in the terminal after Exit." +TUI_RESULT_COPY = f"Shift and a drag select text. {TUI_RESULT_AGAIN}" def _one_kind(state: TuiState, installed: int) -> bool: @@ -18473,10 +18961,15 @@ def _view_error(state: TuiState, width: int, height: int) -> tuple: def _view_quit(state: TuiState, width: int, height: int) -> tuple: + rows = tui_rows(state) + focus = max(0, min(state.focus.get("quit", 0), len(rows) - 1)) + # On Stay, A says Stay: a second "B/Esc Stay" beside it named one choice + # twice (tui_audit_14 P3-11). + hint = (("A/Enter Choose",) if rows[focus].action == "stay" + else ("A/Enter Choose", "B/Esc Stay")) return ("Quit?", _compose(state, width, height, ["Quit without installing? Nothing was written."], - tui_rows(state)), - ("A/Enter Choose", "B/Esc Stay")) + rows), hint) _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = { @@ -18569,8 +19062,16 @@ def _tap_help(state: TuiState) -> list[tuple[str, str]]: if state.screen in _PAGERS and not tui_rows(state): return [("Tap", "A bottom line entry presses its key"), *([("Shift+drag", "Select text to copy")] if state.screen == "result" + and state.os_type != "android" and state.pad not in TUI_PAD_ROUTES else [])] - return [("Tap", "Choose a row, tap it again to open it")] + # Said at every size, 40x12 included: Install below waits for a second + # tap, and the help is where a reader looks when nothing happened. + armed = [row.action for row in tui_rows(state) if row.action in ("install", "addapp")] + if state.screen == "plan" and "install" in armed: + return [("Tap", "A row or Install: click or tap twice")] + if state.screen == "result" and armed: + return [("Tap", "A row or Put it on the card: tap twice")] + return [("Tap", "A row: tap twice to open it")] def _help_key(state: TuiState) -> list[tuple[str, str]]: @@ -18627,19 +19128,27 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: # Cut from its end at 40x12, the help lost Quit and This help with no # sign: what a list's keys also say elsewhere goes first. for spare in ("First, last", "All, none", "extra", "Filter by setup or device name", - "Filter", "Options", "Choose a row, tap it again to open it", "This help", - "definitions"): + "Filter", "Options", "This help", "gap", "definitions"): if (len(drawn(keys)) + len(extra) + len(defined) + len(_text_lines(more, width)) <= height): break if spare == "extra": extra = [] + elif spare == "gap": + # The blank line before the definitions, before a definition + # is shortened: the Tap line that says Install takes a second + # tap is kept at every size (tui_audit_14 P3-4). + defined = defined[1:] if defined[:1] == [""] else defined elif spare == "definitions": # The short form at 40x12, whole: the long one was cut in the # middle of its second sentence. - short = " ".join(brief for long_form, brief in _SHORT_DEFINITIONS - if long_form in words) - defined = ["", *textwrap.wrap(short, width)] if short else [] + short = [brief for long_form, brief in _SHORT_DEFINITIONS if long_form in words] + room = height - len(drawn(keys)) - len(extra) - len(_text_lines(more, width)) + # Still too long: a definition left out whole, never cut in a + # sentence. + while len(short) > 1 and len(textwrap.wrap(" ".join(short), width)) > room: + short.pop() + defined = textwrap.wrap(" ".join(short), width) if short else [] else: keys = [entry for entry in keys if entry[1] != spare] return ("Keys", _compose(state, width, height, drawn(keys) + defined + extra + more, []), @@ -18713,6 +19222,9 @@ class TuiHits(NamedTuple): # whose targets differ from the one before it is another frame. depth: int = 0 frame: int = 0 + # When this screen was first drawn (state.now): a press sooner than + # TUI_TAP_SETTLE after it belongs to a tap made on the screen before. + shown: float = 0.0 def at(self, col: int, line: int) -> tuple | None: return next((target for row, first, end, target in self.targets @@ -18777,8 +19289,13 @@ def tui_hits(state: TuiState, lines: list[str]) -> TuiHits: rows, scroll, len(state.stack)) before = state.hits same = before is not None and before[:7] == hits[:7] + # Another screen, its help or its size: what a finger aimed at was + # elsewhere, and the settle time starts again. + moved = before is None or (before.screen, before.help, before.size, before.depth) != ( + hits.screen, hits.help, hits.size, hits.depth) return hits._replace(frame=before.frame if same else - (before.frame + 1 if before is not None else 0)) + (before.frame + 1 if before is not None else 0), + shown=state.now if moved else before.shown) def _setup_label(plat: str, path: Path) -> str: @@ -18917,6 +19434,7 @@ def tui_start( captured = io.StringIO() with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured): state.setups, state.mounted_setups = found_setups(os_type) + state.unnamed = unnamed_cards() frontends = detect_frontends(os_type) # Said on the normal screen before this one covered it, and kept # above the list: a status line under 18 setups was never drawn. @@ -18924,8 +19442,7 @@ def tui_start( for folder, why in _DETECTION_REFUSED] unsaid = {refused_words(*refused) for refused in _DETECTION_REFUSED} if "esde" in frontends: - state.notes.append("ES-DE found: its BIOS files go to each emulator, " - "not to ES-DE itself.") + state.notes.append(TUI_ESDE_NOTE) state.notes += [line.strip() for line in captured.getvalue().splitlines() if line.strip() and line.strip() not in unsaid] if plat and args.dest is not None: @@ -19442,7 +19959,7 @@ LINE_SAVE_REMEDY = "Name it with --save-dir next time." def _terminal_words(lines: list[str]) -> list[str]: """The result's lines as the terminal prints them after the screen.""" return [line.replace(TUI_SAVE_REMEDY, LINE_SAVE_REMEDY) for line in lines - if line != TUI_RESULT_COPY] + if line not in (TUI_RESULT_COPY, TUI_RESULT_AGAIN)] def tui_quit_chosen(state: TuiState) -> bool: diff --git a/install.sh b/install.sh index 5d71ca7af..cb52eff70 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,12 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54" +# What a refused installer most likely means for this copy. The copy the +# site serves reads a commit's installer, which no cache serves stale, and +# curl or wget stop on a cut transfer before the hash is read: the site's +# build rewrites this line there (scripts/sitebootstrap.py). +MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." +DEFAULT_INSTALL_SHA256="67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7" MAX_INSTALLER_BYTES=2097152 cleanup() { @@ -110,9 +115,7 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then echo " accepted: unset it to use the installer this script names, or set it to match." >&2 exit 1 fi - echo " The download was most likely cut short: try again. Within minutes of an update" >&2 - echo " of this project the two files can come from caches of different ages." >&2 - echo " Waiting five minutes settles that." >&2 + echo " $MISMATCH_ADVICE" >&2 exit 1 fi INSTALLER="$TEMP_INSTALLER" diff --git a/install/launchers/index.json b/install/launchers/index.json index 4a3181c78..4f0d7be40 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 910839, - "sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54", + "size": 939291, + "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", "executable": false }, { @@ -62,8 +62,8 @@ { "dest": "roms/ports/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35599, - "sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c", + "size": 35984, + "sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1", "executable": false } ] @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 963631, - "sha256": "e82035ecc39b3a62898ebf7273c717c04880d2011ade0c09488924209796ab2a" + "size": 992468, + "sha256": "06761ebb41ef22fa2c4b64c5e27fab5ed5508dbcd63c5a634527d03a7cdaa7d1" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 910839, - "sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54", + "size": 939291, + "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", "executable": false }, { @@ -141,8 +141,8 @@ { "name": "RetroBIOS/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35599, - "sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c", + "size": 35984, + "sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1", "executable": false } ] @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 910839, - "sha256": "d6d3483bdaf5c54fae595a6469525015323defb7a4f9f22adbda80029fa3ee54", + "size": 939291, + "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", "executable": false }, { @@ -208,8 +208,8 @@ { "dest": ".config/modules/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35599, - "sha256": "1925428b1bb42384980a3336fa95051c173600367c6d15a4656bae08e6a6473c", + "size": 35984, + "sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1", "executable": false } ] diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index b591a1294..2ad8c88c9 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -542,6 +542,15 @@ rb_font_restore() { RB_FONT_BACK="" } +# Whether a job this shell started is still running. +rb_job_running() { + local job + for job in $(jobs -rp); do + [ "$job" = "$1" ] && return 0 + done + return 1 +} + # The number of the console in front, which a switch has to come back to. rb_active_vt() { local active @@ -558,13 +567,15 @@ rb_active_vt() { # chvt, and whether the console really came to the front. chvt waits for the # switch (VT_WAITACTIVE): a compositor that keeps the display would hold it -# forever, so it gets 3 s. +# forever, so it gets 3 s. The switch is done once chvt has ended too: the +# console to go back to is often in front already, and returning on that +# alone left the launcher ending before a chvt started late had run. rb_chvt() { local pid waited=0 $ESUDO chvt "$1" >> "$RB_LOG" 2>&1 & pid=$! while [ "$waited" -lt 12 ]; do - [ "$(rb_active_vt)" = "$1" ] && return 0 + [ "$(rb_active_vt)" = "$1" ] && ! rb_job_running "$pid" && return 0 rb_nap 0.25 waited=$((waited + 1)) done diff --git a/scripts/sitebootstrap.py b/scripts/sitebootstrap.py index 05d80e22f..6856d86f1 100644 --- a/scripts/sitebootstrap.py +++ b/scripts/sitebootstrap.py @@ -15,6 +15,7 @@ Invoke-RestMethod reads any type that is neither JSON nor XML as text. from __future__ import annotations +import hashlib import os import re import subprocess @@ -32,6 +33,22 @@ SOURCES: dict[str, tuple[str, re.Pattern[str]]] = { SH_NAME: ("install.sh", re.compile(r'^(DEFAULT_INSTALL_URL=")[^"]*(")', re.M)), PS1_NAME: ("install.ps1", re.compile(r'^(\s*\$defaultInstallUrl = ")[^"]*(")', re.M)), } +# The line each bootstrap says a refused installer with, and the pin. +ADVICE: dict[str, re.Pattern[str]] = { + SH_NAME: re.compile(r'^(MISMATCH_ADVICE=")[^"]*(")', re.M), + PS1_NAME: re.compile(r'^(\s*\$mismatchAdvice = ")[^"]*(")', re.M), +} +PIN: dict[str, re.Pattern[str]] = { + SH_NAME: re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"', re.M), + PS1_NAME: re.compile(r'^\s*\$defaultInstallSha256 = "([0-9a-f]{64})"', re.M), +} +# What the served copy says of a refused installer. It names a commit's +# install.py, which never changes, and both downloaders fail on a short +# transfer before the hash is read: neither a cut download nor caches of +# two ages explain a mismatch there (code_audit_21 P3-1). +SERVED_ADVICE = ("The file was changed on its way here, most often by a proxy or an " + "antivirus that rewrites downloads. Run the command again from another " + "network, or with that web protection paused.") def build_commit(root: Path) -> str: @@ -52,14 +69,30 @@ def installer_url(commit: str) -> str: def served_text(name: str, root: Path, commit: str) -> str: - """The bootstrap served as name, its installer read from commit.""" + """The bootstrap served as name, its installer read from commit and its + advice fitted to that. A pin that is not the SHA-256 of root's + install.py stops the build: published, that copy refused every install + until the next good deploy, where a failed build keeps the last good + pair served (code_audit_21 P3-2).""" source, line = SOURCES[name] text = (root / source).read_text(encoding="utf-8") + pinned = PIN[name].findall(text) + if len(pinned) != 1: + raise ValueError(f"{source}: {len(pinned)} installer pins, expected one") + actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest() + if pinned[0] != actual: + raise ValueError(f"{source} pins {pinned[0][:12]}, install.py is {actual[:12]}: " + "re-pin the bootstrap before the site serves it") served, count = line.subn( lambda m: f"{m.group(1)}{installer_url(commit)}{m.group(2)}", text ) if count != 1: raise ValueError(f"{source}: {count} installer address lines, expected one") + served, count = ADVICE[name].subn( + lambda m: f"{m.group(1)}{SERVED_ADVICE}{m.group(2)}", served + ) + if count != 1: + raise ValueError(f"{source}: {count} mismatch advice lines, expected one") return served diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt index 43e542aa8..376973b2e 100644 --- a/tests/fixtures/tui/colour-120x40.txt +++ b/tests/fixtures/tui/colour-120x40.txt @@ -618,8 +618,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system --system - nintendo-gba + python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system \ + --system nintendo-gba @@ -743,8 +743,8 @@ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py \ + --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Same choice without questions: python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt index de15134f9..b1eac105c 100644 --- a/tests/fixtures/tui/colour-40x12.txt +++ b/tests/fixtures/tui/colour-40x12.txt @@ -9,7 +9,7 @@ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. ^[[0;1;35mA^[[0m Continue ^[[0;2mB^[[0m Quit ^[[0;2m?^[[0m Help === platforms ^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m @@ -198,11 +198,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - +^[[0;2m Shortened to fit this window.^[[0m ^[[0;1;35mA^[[0m Back @@ -242,7 +242,7 @@ The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py + To put them back: python3 install.py \ ^[[0;2m 1-8 of 16^[[0m ^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll === error diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt index 1b2064684..a1914ccc5 100644 --- a/tests/fixtures/tui/colour-40x15.txt +++ b/tests/fixtures/tui/colour-40x15.txt @@ -243,11 +243,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - +^[[0;2m Shortened to fit this window.^[[0m @@ -296,10 +296,10 @@ The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py - --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + To put them back: python3 install.py \ + --restore .../2026-10-23_1200 +^[[0;2m Shortened to fit this window.^[[0m + Same choice without questions: ^[[0;2m 1-11 of 16^[[0m ^[[0;1;35mA^[[0m Exit ^[[0;2m^v^[[0m Scroll === error diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt index 8bf355418..2501614e0 100644 --- a/tests/fixtures/tui/colour-80x24.txt +++ b/tests/fixtures/tui/colour-80x24.txt @@ -378,8 +378,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba @@ -456,11 +456,11 @@ ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404) The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py \ + --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/command-120x40.txt b/tests/fixtures/tui/command-120x40.txt index 37fee17e2..45ddf97fc 100644 --- a/tests/fixtures/tui/command-120x40.txt +++ b/tests/fixtures/tui/command-120x40.txt @@ -2,8 +2,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system --system - nintendo-gba + python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system \ + --system nintendo-gba diff --git a/tests/fixtures/tui/command-40x12.txt b/tests/fixtures/tui/command-40x12.txt index 1b955d059..09b184eac 100644 --- a/tests/fixtures/tui/command-40x12.txt +++ b/tests/fixtures/tui/command-40x12.txt @@ -2,11 +2,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - + Shortened to fit this window. A Back diff --git a/tests/fixtures/tui/command-40x15.txt b/tests/fixtures/tui/command-40x15.txt index 3e8530cf0..67a248f0c 100644 --- a/tests/fixtures/tui/command-40x15.txt +++ b/tests/fixtures/tui/command-40x15.txt @@ -2,11 +2,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - + Shortened to fit this window. diff --git a/tests/fixtures/tui/command-80x24.txt b/tests/fixtures/tui/command-80x24.txt index 1b0a8b3ef..7fe74c7b9 100644 --- a/tests/fixtures/tui/command-80x24.txt +++ b/tests/fixtures/tui/command-80x24.txt @@ -2,8 +2,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba diff --git a/tests/fixtures/tui/result-120x40.txt b/tests/fixtures/tui/result-120x40.txt index de7a4e732..246502f05 100644 --- a/tests/fixtures/tui/result-120x40.txt +++ b/tests/fixtures/tui/result-120x40.txt @@ -4,8 +4,8 @@ BIOS folder: /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py \ + --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Same choice without questions: python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index aacffd558..9b9270843 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -7,6 +7,6 @@ The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py + To put them back: python3 install.py \ 1-8 of 16 A Exit ^v Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index 3b7775d28..caf7d87d0 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -7,9 +7,9 @@ The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py - --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + To put them back: python3 install.py \ + --restore .../2026-10-23_1200 + Shortened to fit this window. + Same choice without questions: 1-11 of 16 A Exit ^v Scroll diff --git a/tests/fixtures/tui/result-80x24.txt b/tests/fixtures/tui/result-80x24.txt index 9ec8aec5b..172cd1c52 100644 --- a/tests/fixtures/tui/result-80x24.txt +++ b/tests/fixtures/tui/result-80x24.txt @@ -5,11 +5,11 @@ failed: scph5500.bin (HTTP 404) The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py \ + --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index d43544f91..dea4a502d 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -9,7 +9,7 @@ BIOS folder: /home/user/.config/retroarch/system - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. A Continue B Quit === setup 40x15 RetroBIOS: Where do you play? @@ -755,8 +755,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 4e688a03b..be42befe4 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -9,7 +9,7 @@ BIOS folder: /home/user/.config/retroarch/system - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. A Continue B Quit === setup 40x15 RetroBIOS: Where do you play? @@ -755,8 +755,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index fec9d3208..cd39e4a38 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -9,7 +9,7 @@ BIOS folder: /home/user/.config/retroarch/system - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. A Continue B Quit === setup 40x15 RetroBIOS: Where do you play? @@ -755,8 +755,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 74169d344..189f0f71e 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -9,7 +9,7 @@ BIOS folder: /home/user/.config/retroarch/system - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. Enter Continue Esc Quit ? Help === setup 40x15 RetroBIOS: Where do you play? @@ -759,11 +759,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - + Shortened to fit this window. Enter Back @@ -772,11 +772,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - + Shortened to fit this window. @@ -788,8 +788,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba @@ -926,7 +926,7 @@ The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py + To put them back: python3 install.py \ 1-8 of 16 Enter Exit ^v Scroll === result 40x15 @@ -939,10 +939,10 @@ The files it replaced are kept in /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py - --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - + To put them back: python3 install.py \ + --restore .../2026-10-23_1200 + Shortened to fit this window. + Same choice without questions: 1-11 of 16 Enter Exit ^v Scroll === result 80x24 @@ -953,11 +953,11 @@ failed: scph5500.bin (HTTP 404) The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - To put them back: python3 install.py --restore - /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py \ + --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 Same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index ca3a944af..5b9098ff5 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -9,7 +9,7 @@ BIOS folder: /home/user/.config/retroarch/system - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. A Continue R2 Quit === setup 40x15 RetroBIOS: Where do you play? @@ -784,8 +784,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index d02b5a181..05260a80f 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -9,7 +9,7 @@ BIOS folder: /home/user/.config/retroarch/system - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. Start Continue B Quit === setup 40x15 RetroBIOS: Where do you play? @@ -813,11 +813,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - + Shortened to fit this window. Start Back @@ -826,11 +826,11 @@ The same choice without questions: - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system + python3 install.py \ + --platform retroarch \ + --dest .../retroarch/system \ --system nintendo-gba - + Shortened to fit this window. @@ -842,8 +842,8 @@ The same choice without questions: - python3 install.py --platform retroarch --dest - /home/user/.config/retroarch/system --system nintendo-gba + python3 install.py --platform retroarch \ + --dest /home/user/.config/retroarch/system --system nintendo-gba diff --git a/tests/fixtures/tui/setup-40x12.txt b/tests/fixtures/tui/setup-40x12.txt index 2d87a3413..29dd46547 100644 --- a/tests/fixtures/tui/setup-40x12.txt +++ b/tests/fixtures/tui/setup-40x12.txt @@ -8,5 +8,5 @@ BIOS folder: /home/user/.config/retroarch/system - ES-DE found: its BIOS files go to each + ES-DE: files go to each emulator. A Continue B Quit ? Help diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index fd8742547..2a262004f 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -97,7 +97,7 @@ class InstallerOutputExample(unittest.TestCase): for part in omitted.group(1).split(", "): self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values()) count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups() - self.assertIn(f"0/{count} present", run) + self.assertIn(f"0 in place, {count} missing", run) self.assertSplitAddsUp(run, count, size) menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)[.,]([^`]*)", text) self.assertEqual(menu.groups()[:2], (count, size)) @@ -118,10 +118,14 @@ class InstallerOutputExample(unittest.TestCase): r"files \(([\d.]+) ([KMG]B)\)", text) self.assertIsNotNone(split, "a categorized list says its BIOS files and game data") bios, bios_size, bios_unit, games, game_size, game_unit = split.groups() - self.assertEqual(int(bios) + int(games), int(count)) + # The data files in the one total, the third kind (code_audit_21 P3-8). + data = re.search(r"and (\d+) data files \(([\d.]+) ([KMG]B)\)", text) + data_count, data_size, data_unit = data.groups() if data else ("0", "0", "KB") + self.assertEqual(int(bios) + int(games) + int(data_count), int(count)) units = {"KB": 1, "MB": 1024, "GB": 1024 ** 2} total, unit = size.split() - added = (float(bios_size) * units[bios_unit] + float(game_size) * units[game_unit]) + added = (float(bios_size) * units[bios_unit] + float(game_size) * units[game_unit] + + float(data_size) * units[data_unit]) self.assertLessEqual(abs(added - float(total) * units[unit]), 0.15 * units[unit]) @@ -744,10 +748,10 @@ class FirstInstallWalkthrough(unittest.TestCase): self.assertIn(f'title = "{ending}"', source) self.assertNotRegex(self.text, r"Done\. \d+ files") count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups() - self.assertIn(f"0/{count} present", step3) + self.assertIn(f"0 in place, {count} missing", step3) self.assertIn(f"Downloading {count} files ({size})", step3) self.assertIn(f"Done. {count} files installed", step4) - self.assertIn(f"{count}/{count} present ({count} verified", step5) + self.assertIn(f"{count} in place, 0 missing", step5) manifest = json.loads((ROOT / "install" / "batocera.json").read_text(encoding="utf-8")) omitted = re.search(r"Not installable: (\d+) files \((\d+) required", step3) if manifest.get("omitted_files"): diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index d0d0cf8e4..835376c61 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -625,6 +625,8 @@ printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log" # compositor that keeps the display (FAKE_CHVT_REFUSED) leaves chvt waiting # in VT_WAITACTIVE and the console in front unchanged. FAKE_CHVT = """#!/bin/sh +# A loaded machine starts the process late (FAKE_CHVT_DELAY seconds). +[ -n "$FAKE_CHVT_DELAY" ] && sleep "$FAKE_CHVT_DELAY" printf '%s\\n' "chvt $*" >> "$FAKE_STATE/tools.log" [ -n "$FAKE_CHVT_REFUSED" ] && exec sleep 30 [ -n "$RB_VT_ACTIVE" ] && printf 'tty%s\\n' "$1" > "$RB_VT_ACTIVE" @@ -1509,13 +1511,13 @@ class TheBatoceraPort(unittest.TestCase): self.assertEqual(chvt, [f"chvt {self.number}", f"chvt {self.home}"]) device.assert_console_restored() - def test_a_refused_console_switch_shows_nothing_on_an_unseen_console(self): + def test_a_refused_console_switch_shows_nothing_on_an_unseen_console(self, **slow): """The compositor keeps the display: chvt waits and the console in front stays the same. The launcher switches back and stops, instead of an interface no key reaches.""" device, ports = self.device(FAKE_PGREP="0") proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_CHVT_REFUSED="1", - **self.console_switch(device)) + **self.console_switch(device), **slow) self.assertEqual(proc.returncode, 125) self.assertLess(device.elapsed, 20) self.assertEqual(device.tui_calls(), []) @@ -1526,6 +1528,23 @@ class TheBatoceraPort(unittest.TestCase): self.assertIn(f"console {self.number} did not come to the front", log) self.assertNotIn("\x1bc", device.screen) + def test_the_switch_back_is_waited_for_on_a_slow_machine(self): + """The console to go back to was already in front, so rb_chvt + returned before its chvt had even started, and the launcher ended + with the switch back still pending: under load the log missed + "chvt 2". A chvt that starts 1.2 s late made it fail every time.""" + self.test_a_refused_console_switch_shows_nothing_on_an_unseen_console( + FAKE_CHVT_DELAY="1.2") + + def test_a_slow_console_switch_still_comes_back(self): + device, ports = self.device(FAKE_PGREP="0") + proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_CHVT_DELAY="1.2", + **self.console_switch(device)) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + chvt = [line for line in device.tools() if line.startswith("chvt")] + self.assertEqual(chvt, [f"chvt {self.number}", f"chvt {self.home}"]) + self.assertEqual((device.root / "active").read_text(), f"tty{self.home}\n") + def test_xterm_that_cannot_open_hands_over_to_the_console(self): device, ports = self.device(DISPLAY=":0", FAKE_PGREP="0") device.tool("xterm", FAKE_TERMAPP) diff --git a/tests/test_install.py b/tests/test_install.py index 5488c4fa9..de1ec5fc7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3550,7 +3550,7 @@ class ARefusedStandaloneCopyIsNamed(unittest.TestCase): install._report_outcome([bios], 0, 1, failed, 0, False, failures=failures) text = out.getvalue() self.assertIn(str(target / "scph5501.bin"), text) - self.assertIn(str(target), text.split("Location")[1]) + self.assertIn(str(target), text.split("BIOS folder")[-1]) self.assertIn("cannot write there", text) @@ -4210,8 +4210,11 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun): self.assertEqual(proc.returncode, 0, proc.stderr) lines = [line for line in proc.stdout.splitlines() if line.strip()] self.assertIn("Nothing was written", lines[-1]) - header = next(line for line in lines if line.startswith("Would download")) - self.assertIn("data files", header) + at = next(index for index, line in enumerate(lines) if line.startswith("Would download")) + # One total on the header, the data files among the kinds under it + # (code_audit_21 P3-8). + self.assertNotIn("data files", lines[at]) + self.assertIn("data files", lines[at + 1]) def test_dest_alone_says_whose_list_it_is(self): proc = _install_run(self.env, "--dest", str(self.home / "card"), "--dry-run", @@ -4531,7 +4534,7 @@ class ANetworkFailureNamesTheNetwork(unittest.TestCase): self.assertEqual(raised.exception.code, 2) text = out.getvalue() self.assertIn("HTTPS_PROXY", text) - self.assertIn("Destination: /bios", text) + self.assertIn("BIOS folder: /bios", text) self.assertNotIn("being written to", text) @@ -5222,7 +5225,7 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree): "interrupted": False}, {"event": "end", "code": 1}, ]) - self.assertIn("2/4 present (1 verified, 1 wrong hash)", proc.stderr) + self.assertIn("1 in place, 2 missing, 1 wrong copy", proc.stderr) self.assertFalse((self.bios / "c.bin").exists()) def test_an_install_run(self): diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 8dc06ad8e..de86e7928 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -678,7 +678,7 @@ class AHomeThisUserCannotOpen(unittest.TestCase): with self.subTest(run=name): self.assertNotIn("Traceback", proc.stderr) # A named folder needs no detection and is checked. - self.assertIn("0/5 present", runs["named"].stdout) + self.assertIn("0 in place, 5 missing", runs["named"].stdout) self.assertIn(f"Could not look for setups: this user cannot open {home}", runs["detected"].stderr) @@ -811,7 +811,7 @@ class APartlyClosedHome(unittest.TestCase): 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.assertIn("0 in place, 5 missing", 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] diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index ec313ad02..29d3e2ad7 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -1459,7 +1459,7 @@ class ThePlatformsOwnListAlone(unittest.TestCase): with self.subTest(mode=mode), FakeRepository() as repo: proc = repo.run("--declared-only", mode) self.assertIn("5 to 3 files", proc.stdout, proc.stderr) - self.assertIn("0/3 present", proc.stdout) + self.assertIn("0 in place, 3 missing", proc.stdout) self.assertEqual(repo.installed(), []) def test_it_composes_with_the_other_narrowings(self): diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index e46fb352d..58c61895f 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -771,7 +771,8 @@ class PlanWarnings(unittest.TestCase): self.assertIn("You cannot write to this folder: choose another one.", text) # sudo changes the home folder: the advice names this one. self.assertIn("Or run it as root, this folder named: sudo python3 install.py " - f"--platform retroarch --dest {SYSTEM_DIR}", " ".join(text.split())) + f"--platform retroarch --dest {SYSTEM_DIR}", + " ".join(text.replace(" \\", "").split())) rows = install.tui_rows(state) self.assertEqual([row.action for row in rows[:2]], ["folder", "install"]) self.assertEqual(state.focus.get("plan", 0), 0) @@ -3737,8 +3738,9 @@ class AuditFive(unittest.TestCase): saves = {"files": 1, "folder": str(HOME / ".config" / "retroarch" / "saves" / "")} lines = install._saves_lines(state, saves) # In words: "saves/" read as a folder named . + # The folder at the BIOS folder's indent (tui_audit_14 P3-9). self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to a " - "folder per core under:", "~/.config/retroarch/saves"]) + "folder per core under:", " ~/.config/retroarch/saves"]) def test_the_shared_note_is_one_line(self): state = self.long_list() @@ -3820,7 +3822,9 @@ class LineModeOffline(unittest.TestCase): with mock.patch.object(install, "_ask", return_value=""), \ contextlib.redirect_stdout(io.StringIO()) as out: install._prompt_custom_selection("retroarch", files, [], data_trees=trees) - self.assertIn("RetroArch: 1 file, 1.0 KB, and 2 data files (4.0 KB).", out.getvalue()) + # One total, the kinds under it adding up to it (code_audit_21 P3-8). + self.assertIn("RetroArch: 3 files, 5.0 KB.\n 1 BIOS file (1.0 KB) and 2 data files " + "(4.0 KB).", out.getvalue()) def test_one_save_file_goes(self): files = [{"dest": "keys.txt", "root": "save", "size": 1}] @@ -4773,7 +4777,7 @@ class EndToEnd(unittest.TestCase): # The second folder the run writes to, named before # anything is written, and again on the result. self.assertIn("1 file read from RetroArch's save directory goes to a " - "folder per core under:\n ~/MySaves", screen) + "folder per core under:\n ~/MySaves", screen) term.send("\r") screen = term.wait("6 files installed.") # Spelled in full on the result, as its other folders. @@ -6638,7 +6642,11 @@ class AuditSevenScreens(unittest.TestCase): state.found = [found("gba_bios.bin", str(self.root / "from1"))] with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): text = " ".join(" ".join(install.tui_view(state, 100, 40)).split()) - self.assertIn("They are not in from1", text) + # The rest named by kind as not in it, the remedy after + # (tui_audit_14 P3-7). + self.assertIn("Not in from1: ", text) + self.assertIn("Run the installer again with a pack (--from-pack)", text) + self.assertNotIn("They are not in", text) self.assertNotIn("Copy them from your old card", text) def test_the_card_browser_focuses_the_card_asked_for(self): @@ -7572,7 +7580,7 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertIn("g) Game data: kept (3 files, 120 B): leave it out", text) self.assertIn(" 4 files (123 B)\n 1 BIOS file (3 B) and 3 game data files (120 B).", text) - self.assertIn("0/4 present", text) + self.assertIn("0 in place, 4 missing, 0 wrong copies", text) self.assertIn("4 files (123 B)", text[text.index("Checking existing files"):]) def test_g_counts_the_systems_chosen_and_leaves_out_what_it_said(self): @@ -9197,7 +9205,7 @@ class AuditSixteenAndNineLineMode(unittest.TestCase): folder = install.ask_platform_folder("linux", "batocera") self.assertEqual(folder, share / "bios") text = out.getvalue() - self.assertIn(f"1) SHARE: bios ({share / 'bios'}, created)", text) + self.assertIn(f"1) SHARE: bios ({share / 'bios'}, does not exist yet)", text) self.assertNotIn("MUOS", text) def test_the_folder_question_quits_on_q_and_says_what_to_type(self): @@ -9960,8 +9968,12 @@ class TheAndroidResultReadsWhole(unittest.TestCase): lines = [str(line).strip() for line in self.result(cols)] words = [word for line in lines for word in line.split()] self.assertIn("--restore", words) - # The command keeps it whole, to be copied as one word. - self.assertIn(full, words) + # The command keeps it whole where its line holds it, cut to + # its last folders and said so where not (tui_audit_14 P3-8). + if cols >= 60: + self.assertIn(full, words) + else: + self.assertIn(install.TUI_SHORTENED, lines) # Prose breaks it after a separator only, never inside a name # (tui_audit_13 P3-3): glued again, it is whole. self.assertIn(full, re.sub(r"/ ", "/", " ".join(lines))) @@ -9973,8 +9985,11 @@ class TheAndroidResultReadsWhole(unittest.TestCase): state.stack.append("result") pieces = install._text_lines([command], 38, whole=True) joined = [str(piece).strip() for piece in pieces if piece.style != "cont"] - self.assertIn(install.BOOTSTRAP_URL, joined) - self.assertIn("'/storage/emulated/0/a b/20261027-223524'", joined) + # The address never cut, the option with its value, each line but + # the last ending with the shell's continuation. + self.assertIn(f"{install.BOOTSTRAP_URL} \\", joined) + self.assertTrue(any(line.startswith("--restore '") for line in joined), joined) + self.assertTrue(all(len(str(piece)) <= 38 for piece in pieces), pieces) def test_a_long_line_folds_in_the_terminal(self): terminal = install._TuiTerminal.__new__(install._TuiTerminal) @@ -10024,8 +10039,16 @@ def drawn(state: install.TuiState, cols: int, rows: int) -> list: return lines +def settled(state: install.TuiState) -> None: + """The clock moved on past TUI_TAP_SETTLE since the screen was drawn.""" + if state.hits is not None: + state.now = max(state.now, state.hits.shown + install.TUI_TAP_SETTLE) + + def tap(state: install.TuiState, col: int, row: int, button: int = 0) -> list: - """Press and release on one cell, columns and lines counted from 0.""" + """Press and release on one cell, columns and lines counted from 0, made + once the screen settled (TUI_TAP_SETTLE): a reader's deliberate tap.""" + settled(state) effects = install.tui_update(state, install.TuiMouse(button, col + 1, row + 1)) return effects + install.tui_update(state, install.TuiMouse(button, col + 1, row + 1, True)) @@ -10226,13 +10249,13 @@ class MouseOnEveryScreen(unittest.TestCase): self.assertIn(label, install._BAR_CLICKS) effects = tap(state, first, line) if install._bar_commits(base, target[1]): - # Install, Quit and Continue into Confirm: the - # first tap arms the entry, drawn so, the + # Install on the plan and Put it on the card: + # the first tap arms the entry, drawn so, the # second on the same entry presses it. self.assertEqual(effects, []) self.assertEqual(state.stack, base.stack) again = drawn(state, cols, rows) - self.assertIn("tap again", str(again[-1])) + self.assertIn(install.TUI_ARMED, str(again[-1])) at = next(first for row, first, _end, mark in state.hits.targets if mark == target) effects = tap(state, at, line) @@ -10258,7 +10281,9 @@ class MouseOnEveryScreen(unittest.TestCase): lines = drawn(state, 80, 24) row = next(i for i, line in enumerate(lines) if line.style != "focus" and getattr(line, "mark", None) and line.mark[0] == "row") + settled(state) install.tui_update(state, install.TuiMouse(0, 5, row + 1)) + self.assertIsNotNone(state.pressed) self.assertEqual(state.focus.get("setup", 0), 0) # Released on another row: a swipe, which does nothing. focused = next(i for i, line in enumerate(lines) if line.style == "focus") @@ -10275,6 +10300,7 @@ class MouseOnEveryScreen(unittest.TestCase): row = next(i for i, line in enumerate(lines) if line.style != "focus" and getattr(line, "mark", None) and line.mark[0] == "row") decoder = install.KeyDecoder() + settled(state) for event in decoder.feed(x10(5, row + 1) + x10(5, row + 1, 3)): install.tui_update(state, event) self.assertEqual(state.focus["setup"], lines[row].mark[1]) @@ -10431,6 +10457,9 @@ class MouseOnAPty(unittest.TestCase): return next(index for index, line in enumerate(term.screen.display) if needle in line) def click(self, term: Pty, col: int, row: int) -> None: + # A deliberate tap, after the screen settled: a press sooner is the + # second of a double tap on the screen before. + time.sleep(install.TUI_TAP_SETTLE) term.send(sgr(col + 1, row + 1), sgr(col + 1, row + 1, release=True)) def test_taps_choose_a_row_and_press_the_bar(self): @@ -10454,6 +10483,7 @@ class MouseOnAPty(unittest.TestCase): term.wait("Which setup?") # Legacy reports move the focus the same way. row = self.line_of(term, " > ") + 2 + time.sleep(install.TUI_TAP_SETTLE) term.send(x10(20, row + 1), x10(20, row + 1, 3)) term.settle() self.assertTrue(term.screen.buffer[row][3].reverse, term.text()) @@ -10634,7 +10664,7 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): plan = checked_state() plan.mouse, plan.help = True, True text = " ".join(" ".join(install.tui_view(plan, 80, 24)).split()) - self.assertIn("Tap Choose a row, tap it again to open it", text) + self.assertIn("Tap A row or Install: click or tap twice", text) # The tick entry of the bar is pressed by a tap too. state.help = False lines = drawn(state, 80, 24) @@ -10833,10 +10863,12 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): width = len(path) + 1 lines = install._wrap(f"BIOS folder: {path}", width, whole="text") self.assertEqual(lines, ["BIOS folder:", path]) - command = install._wrap(f" python3 install.py --dest {path}", width + 1, + # A command keeps the option with its value, the path whole where + # the line holds them (tui_audit_14 P3-8). + command = install._wrap(f" python3 install.py --dest {path}", width + 10, whole="command") - self.assertIn(f" {path}", command) - self.assertTrue(all(len(line) <= width + 1 for line in command), command) + self.assertIn(f" --dest {path}", command) + self.assertTrue(all(len(line) <= width + 10 for line in command), command) def test_the_result_spells_each_folder_one_way(self): """tui_audit_12 P3-7: the save folder as ~/ beside full paths.""" @@ -10844,7 +10876,7 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): state.done_saves = {"event": "saves", "files": 2, "folder": "/home/user/.config/retroarch/saves/"} _title, lines = install.tui_outcome(state) - self.assertIn("/home/user/.config/retroarch/saves", lines) + self.assertIn("/home/user/.config/retroarch/saves", [line.strip() for line in lines]) self.assertFalse(any(line.startswith("~") for line in lines)) def test_minui_names_the_folder_of_its_plan(self): @@ -11451,7 +11483,7 @@ class AuditTwentyAndThirteen(unittest.TestCase): state.check = {**state.check, "data_missing": 7, "data_bytes": 2048} text = " ".join(" ".join(install.tui_view(state, 40, 12)).split()) self.assertIn("Game data: what a game reads, of no use to other cores. Data files: " - "what an emulator reads from its own data folder.", text) + "an emulator's own data folder.", text) text = " ".join(" ".join(install.tui_view(state, 40, 15)).split()) self.assertIn("such as Dolphin's Sys and PPSSPP's assets.", text) @@ -11485,7 +11517,11 @@ class AuditTwentyAndThirteen(unittest.TestCase): while pieces[-1].endswith("/") and lines[at + len(pieces)]: pieces.append(lines[at + len(pieces)]) spelled.append(pieces) - self.assertNotIn("...", " ".join(lines)) + # A command's path may be cut to fit, said so: a folder + # in prose never is. + self.assertNotIn("...", " ".join( + line for line in lines if install.tui_command(state)[:10] not in line + and not line.startswith("--"))) self.assertTrue(all(len(piece) <= cols - 2 for piece in pieces), pieces) self.assertEqual(spelled[0], spelled[1]) self.assertEqual("".join(spelled[0]), f"BIOS folder: {dest}") @@ -11495,9 +11531,9 @@ class AuditTwentyAndThirteen(unittest.TestCase): under an indent of 6.""" path = "/home/moi/retroarch_system/tmp/tui_audit13/pack/MinUI_2025-11_BIOS_Pack.zip" self.assertEqual(len(path), 75) - lines = install._wrap(f" python3 install.py --from-pack {path}", 78, + lines = install._wrap(f" python3 install.py --from-pack {path}", 95, whole="command") - self.assertIn(path, lines) + self.assertIn(f" --from-pack {path}", lines) def test_the_definition_is_said_once_and_never_spliced(self): """tui_audit_13 P3-5.""" @@ -11710,7 +11746,9 @@ class TapsSeenBeforeTheyAct(unittest.TestCase): row = next(i for i, line in enumerate(lines) if getattr(line, "mark", None) and line.mark[0] == "row" and line.mark[2][0] == "systems") + settled(state) install.tui_update(state, install.TuiMouse(0, 5, row + 1)) + self.assertIsNotNone(state.pressed) # The screen changes under the finger before it comes up. state.stack.append("options") drawn(state, 80, 24) @@ -11728,7 +11766,7 @@ class TapsSeenBeforeTheyAct(unittest.TestCase): self.assertEqual(tap(state, 3, bar), []) self.assertEqual(state.screen, "plan") armed = drawn(state, 80, 24) - self.assertIn("A/Enter Install: tap again", str(armed[-1])) + self.assertIn(f"A/Enter Install: {install.TUI_ARMED}", str(armed[-1])) self.assertEqual(tap(state, 3, bar), [("run", "install")]) def test_a_tapped_quit_quits_with_the_quit_code(self): @@ -11739,10 +11777,8 @@ class TapsSeenBeforeTheyAct(unittest.TestCase): lines = drawn(state, 80, 24) self.assertFalse(str(lines[-1]).strip().startswith("Ctrl-C")) at = str(lines[-1]).index("Ctrl-C") - tap(state, at, len(lines) - 1) - self.assertIsNone(state.exit_code) - drawn(state, 80, 24) - at = str(install.tui_view(state, 80, 24)[-1]).index("Ctrl-C") + # One tap: quitting writes nothing, and the settle time keeps a + # double tap on the screen before from reaching it. tap(state, at, len(lines) - 1) self.assertEqual(state.exit_code, install.QUIT_CODE) @@ -11769,6 +11805,168 @@ class TapsSeenBeforeTheyAct(unittest.TestCase): self.assertIn("as A", " ".join(install.tui_view(state, 80, 24))) +class DoubleTapsAcrossScreens(unittest.TestCase): + """tui_audit_14 P2-1: the second tap of a double tap acted on the screen + the first one opened, at every gap measured (0, 80, 150 and 250 ms). + A press within TUI_TAP_SETTLE of a new screen is dropped, and a slow + deliberate second tap still acts.""" + + GAPS = (0.0, 0.08, 0.15, 0.25) + + @staticmethod + def bar_cell(lines: list) -> tuple[int, int]: + return len(str(lines[-1])) - len(str(lines[-1]).lstrip()), len(lines) - 1 + + def double_tap(self, state: install.TuiState, gap: float) -> list: + """Two taps gap seconds apart on the bar's first entry, the screen + drawn after each event as the loop draws it.""" + lines = drawn(state, 80, 24) + col, row = self.bar_cell(lines) + state.now = state.hits.shown + 2.0 + effects = [] + for release in (False, True): + effects += install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, release)) + first = state.now + drawn(state, 80, 24) + state.now = first + gap + for release in (False, True): + effects += install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, release)) + drawn(state, 80, 24) + return effects + + def one_tap(self, state: install.TuiState) -> list: + lines = drawn(state, 80, 24) + col, row = self.bar_cell(lines) + return tap(state, col, row) + + def plan_on(self, action: str) -> install.TuiState: + state = checked_state() + state.mouse = True + rows = install.tui_rows(state) + state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == action) + return state + + def both(self, make, gap: float) -> tuple: + once = make() + one = self.one_tap(once) + twice = make() + return one, once, self.double_tap(twice, gap), twice + + def test_continue_on_the_first_screen(self): + def make() -> install.TuiState: + state = fresh_state() + state.mouse = True + return state + for gap in self.GAPS: + with self.subTest(gap=gap): + with mock.patch.object(install.Path, "exists", return_value=True): + one, once, two, twice = self.both(make, gap) + self.assertNotEqual(once.stack, ["setup"]) + self.assertEqual(two, one) + self.assertEqual(twice.stack, once.stack) + + def test_open_on_the_plan_keeps_the_systems_ticked(self): + for gap in self.GAPS: + with self.subTest(gap=gap): + one, once, two, twice = self.both(lambda: self.plan_on("systems"), gap) + self.assertEqual(once.screen, "systems") + self.assertEqual((twice.stack, twice.ticked), (once.stack, once.ticked)) + self.assertEqual(two, one) + + def test_choose_on_copy_from(self): + for gap in self.GAPS: + with self.subTest(gap=gap): + one, once, two, twice = self.both(lambda: self.plan_on("fromcard"), gap) + self.assertEqual(once.screen, "browse") + self.assertEqual((twice.stack, twice.browse_dir), + (once.stack, once.browse_dir)) + self.assertEqual(two, one) + + def test_a_slow_second_tap_acts(self): + once = self.plan_on("systems") + self.double_tap(once, 0.0) + slow = self.plan_on("systems") + self.double_tap(slow, install.TUI_TAP_SETTLE + 0.2) + self.assertEqual(once.screen, "systems") + # On Choose systems the first entry goes on to Confirm: the slow + # tap pressed it. + self.assertEqual(slow.screen, "confirm") + + def test_a_release_whose_press_came_before_the_frame_does_nothing(self): + state = self.plan_on("systems") + lines = drawn(state, 80, 24) + col, row = self.bar_cell(lines) + state.now = state.hits.shown + 2.0 + install.tui_update(state, install.TuiMouse(0, col + 1, row + 1)) + # Another screen drawn between the press and its release. + state.stack.append("options") + drawn(state, 80, 24) + state.now += 1.0 + self.assertEqual(install.tui_update(state, install.TuiMouse(0, col + 1, row + 1, True)), + []) + self.assertEqual(state.screen, "options") + + def test_the_settle_time_is_documented(self): + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + self.assertIn(f"{round(install.TUI_TAP_SETTLE * 1000)} ms", page) + + +class TwoTapsOnlyWhereFilesAreWritten(unittest.TestCase): + """tui_audit_14 P3-2 and P3-4: with the settle time in place, a second + tap is asked only of an entry that writes files with nothing to look at + after it, drawn in the accent and said in the help at every size.""" + + def test_install_on_the_plan_arms_in_the_accent(self): + state = checked_state() + state.mouse = True + lines = drawn(state, 80, 24) + self.assertEqual(tap(state, 2, len(lines) - 1), []) + armed = str(drawn(state, 80, 24)[-1]) + entry = f"A/Enter Install: {install.TUI_ARMED}" + self.assertIn(entry, armed) + painted = install.tui_paint(armed, "hint", True) + self.assertIn(f"{install.COLOUR_ROLES['accent']}m{entry}\x1b[0m", painted) + self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")]) + + def test_quit_takes_two_activations(self): + state = fresh_state() + state.mouse = True + lines = drawn(state, 80, 24) + tap(state, str(lines[-1]).index("B/Esc"), len(lines) - 1) + self.assertEqual(state.screen, "quit") + state.focus["quit"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "quit") + lines = drawn(state, 80, 24) + tap(state, 2, len(lines) - 1) + self.assertEqual(state.exit_code, install.QUIT_CODE) + + def test_install_from_choose_systems_takes_two_activations(self): + state = checked_state() + state.mouse = True + rows = install.tui_rows(state) + state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "systems") + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "systems") + lines = drawn(state, 80, 24) + self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "confirm")]) + self.assertEqual(state.screen, "confirm") + install.tui_receive(state, ("event", state.run_id, CHECK)) + install.tui_receive(state, ("ended", state.run_id, 1, [])) + lines = drawn(state, 80, 24) + self.assertIn("A/Enter Install", str(lines[-1])) + self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")]) + + def test_the_help_says_it_at_every_size(self): + for cols, rows in ((40, 12), (60, 20), (80, 24)): + with self.subTest(size=(cols, rows)): + state = checked_state() + state.mouse = True + state.help = True + text = " ".join(" ".join(str(line).split()) + for line in install.tui_view(state, cols, rows)) + self.assertIn("A row or Install: click or tap twice", text) + + @unittest.skipUnless(pyte and pty and hasattr(pty, "fork"), "screen tests need pyte (pip install pyte==0.8.2) and a pty") class TapsOnAPtySeeFirst(unittest.TestCase): @@ -11791,7 +11989,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase): term.wait("> Install") term.settle() self.click(term, *self.bar_entry(term, "Install")) - self.assertIn("tap again", term.settle().splitlines()[-1]) + self.assertIn(install.TUI_ARMED, term.settle().splitlines()[-1]) self.assertEqual(repo.installed(), []) self.click(term, *self.bar_entry(term, "Install")) term.wait("files installed") @@ -11816,6 +12014,30 @@ class TapsOnAPtySeeFirst(unittest.TestCase): term.send("\r") self.assertIn(term.close(), (install.QUIT_CODE, 0)) + def test_a_double_tap_on_continue_stays_on_what_it_opened(self): + # tui_audit_14 P2-1: at 0, 80, 150 and 250 ms the second tap pressed + # B/Esc Back on the Checking screen, back to the first screen. + for gap in (0.0, 0.08, 0.15, 0.25): + with self.subTest(gap=gap): + repo = self.repo() + term = self.start() + term.wait("Where do you play?") + term.settle() + time.sleep(install.TUI_TAP_SETTLE) + col, line = self.bar_entry(term, "Continue") + press = sgr(col + 1, line + 1) + release = sgr(col + 1, line + 1, release=True) + os.write(term.fd, (press + release).encode()) + time.sleep(gap) + os.write(term.fd, (press + release).encode()) + screen = term.settle(0.8) + self.assertNotIn("Where do you play?", screen) + self.assertNotIn("Quit?", screen) + term.wait("> Install") + self.assertEqual(repo.installed(), []) + term.send("q") + term.close() + def test_continue_then_install_in_one_cell_stops_on_confirm(self): repo = self.repo() term = self.start() @@ -11832,17 +12054,18 @@ class TapsOnAPtySeeFirst(unittest.TestCase): term.settle() col, line = self.bar_entry(term, "Continue") report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) - # A double tap: the first arms Continue, the second opens Confirm. + # A double tap: the first opens Confirm, the second lands on its + # Install inside the settle time and does nothing (tui_audit_14 P2-1). + time.sleep(install.TUI_TAP_SETTLE) term.send(report + report) term.wait("Counted again for your choices") - term.settle() - self.assertEqual(repo.installed(), []) - # Install in the same cell: one tap arms it, nothing is written. - term.send(report) screen = term.settle(0.6) self.assertIn("RetroBIOS: Confirm", screen) - self.assertIn("Install: tap again", screen.splitlines()[-1]) self.assertEqual(repo.installed(), []) + # A tap made later installs: Confirm is the look before the install. + term.send(report) + term.wait("files installed") + self.assertNotEqual(repo.installed(), []) term.send("q") term.close() @@ -11911,5 +12134,428 @@ class TapsOnAPtySeeFirst(unittest.TestCase): term.close() +class AuditTwentyOneAndFourteen(unittest.TestCase): + """code_audit_21 and tui_audit_14: one choice state in both modes, the + cards found first, packs that hold nothing, and words that fit.""" + + setUp = LineModeAndScreenDecideAlike.setUp + line_run = LineModeAndScreenDecideAlike.line_run + listing = LineModeAndScreenDecideAlike.listing + entry = staticmethod(LineModeAndScreenDecideAlike.entry) + receive = staticmethod(AuditTwentyAndThirteen.receive) + game_manifest = AuditTwentyAndThirteen.game_manifest + game_events = AuditTwentyAndThirteen.game_events + game_plan = AuditTwentyAndThirteen.game_plan + + @staticmethod + def screen(state: install.TuiState, cols: int = 80, rows: int = 24) -> str: + return " ".join(" ".join(str(line).split()) + for line in install.tui_view(state, cols, rows)) + + def toggle_game_data(self, state: install.TuiState) -> None: + rows = install.tui_rows(state) + state.focus["plan"] = next(index for index, row in enumerate(rows) + if row.action == "gamedata") + self.assertEqual(install.tui_update(state, Key("enter")), [("run", "check")]) + self.receive(state, self.game_events(self.game_manifest(), + left_out=state.no_game_data)) + + def test_the_game_data_toggle_never_unticks_a_system_for_good(self): + """tui_audit_14 P2-2: DS and Dreamcast ticked, game data left out + then kept: Dreamcast was gone, and so was the row to turn it back.""" + state = self.game_plan() + state.ticked = {"nintendo-ds", "sega-dreamcast"} + self.toggle_game_data(state) + self.assertEqual(state.ticked, {"nintendo-ds"}) + self.assertIn("for 1 of 2 systems", self.screen(state)) + row = next(row for row in install.tui_rows(state) if row.action == "gamedata") + self.assertEqual((row.label, row.right), ("Game data: left out", "2 files, 2.0 KB")) + self.toggle_game_data(state) + self.assertEqual(state.ticked, {"nintendo-ds", "sega-dreamcast"}) + self.assertIn("for 2 of 3 systems", self.screen(state)) + # Line mode, the same choice: c, the two systems, g twice. + manifest = self.game_manifest() + listing = self.listing("retroarch", [ + self.entry(entry["dest"], b"x" * entry["size"], entry["systems"], + **({"category": entry["category"]} if "category" in entry else {})) + for entry in manifest["files"]], categorized=True) + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True), + ["c", "1,2", "g", "g"]) + self.assertIn("for 1 of 2 systems", text) + self.assertIn("for 2 of 3 systems", text.rsplit("for 1 of 2 systems", 1)[1]) + + def game_listing(self) -> Path: + manifest = self.game_manifest() + return self.listing("retroarch", [ + self.entry(entry["dest"], b"x" * entry["size"], entry["systems"], + **({"category": entry["category"]} if "category" in entry else {})) + for entry in manifest["files"]], categorized=True) + + def test_line_mode_offers_no_enter_for_an_emptied_choice(self): + """code_audit_21 P3-6: Dreamcast alone, game data left out: line mode + said "0 files, 0 B, for 0 of 2 systems", offered Enter and a command + that installs nothing, then refused the Enter.""" + text, asked = self.line_run(_args(manifest=self.game_listing(), dest=self.here, + declared_only=False), + ["c", "2", "g", ""]) + menu = text.rsplit("Counted again for your choices", 1)[1] + self.assertIn("RetroArch: sega-dreamcast holds only game data: nothing is left to " + "install.", menu) + self.assertNotIn("0 files, 0 B", menu) + self.assertNotIn("Enter)", menu) + self.assertNotIn("Same choice without questions", menu) + # Enter is not an answer here: asked again, never refused after. + self.assertIn("Type c, f, g or q.", menu) + self.assertEqual(asked[-1], "> ") + + def test_a_kept_choice_in_place_offers_no_install(self): + """code_audit_21 P3-4: one system ticked, its files in place, the + plan said "About 0 files" over Install, and Confirm "Everything is + in place" over Install too.""" + state = checked_state() + state.ticked = {"nintendo-famicom-disk"} + text = self.screen(state) + self.assertIn("(ファミコン) is in place: nothing is left to install.", text) + self.assertNotIn("About 0 files", text) + actions = [row.action for row in install.tui_rows(state)] + self.assertNotIn("install", actions) + self.assertEqual(actions[0], "systems") + # Confirm counted nothing to install: Close, never Install. + state.stack.append("confirm") + state.dry = {**CHECK, "present": 1, "missing": 0, "wrong_hash": 0, + "bytes_needed": 0, "files": 1} + state.dry_files = [] + self.assertEqual([row.action for row in install.tui_rows(state)], ["close", "back"]) + install.tui_update(state, Key("enter")) + self.assertEqual(state.exit_code, 0) + + def test_offline_the_plan_counts_the_systems_ticked(self): + """code_audit_21 P3-5: offline, one system ticked, the plan counted + every file of the list beside "For your 1 of 3 systems".""" + state = checked_state() + folder = str(self.root / "from1") + state.from_folders = [self.root / "from1"] + state.found = [found("gba_bios.bin", folder)] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + state.ticked = {"nintendo-gba"} + text = self.screen(state, 100, 40) + self.assertIn("About 1 file, 16.0 KB, for 1 of 3 systems ", text) + self.assertNotIn("found in from1", text) + self.assertNotIn("Run the installer again", text) + state.ticked = {"sony-playstation"} + text = self.screen(state, 100, 40) + self.assertIn("About 0 files, 0 B, for 1 of 3 systems, 3 need the internet", text) + + def easyroms(self, name: str = "EASYROMS") -> Path: + """An ArkOS games part as a computer mounts it: bios/ and console + folders, no file naming the system.""" + card = self.root / "media" / name + for folder in ("bios", "nes", "gba", "psx"): + (card / folder).mkdir(parents=True) + return card + + def test_an_arkos_card_is_found_and_comes_first(self): + """tui_audit_14 P2-3: an EASYROMS partition mounted, "Nothing was + found here", and two presses of Enter filled this computer's + ~/.config/retroarch/system for a RetroArch that was not there.""" + for name in ("EASYROMS", "GAMES"): + with self.subTest(card=name): + card = self.easyroms(name) + home = self.root / "home" + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install.Path, "home", return_value=home): + state, _effects = install.tui_start(_args(), "linux") + self.assertEqual(state.unnamed, [card]) + text = self.screen(state) + self.assertIn(f"A card is in the reader ({name}): choose its system", + text) + self.assertNotIn("Nothing was found here", text) + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "folder") + rows = install.tui_rows(state) + self.assertEqual(rows[0].value, card / "bios") + install.tui_update(state, Key("enter")) + self.assertEqual(state.dest, card / "bios") + shutil.rmtree(card) + + def lacking_pack(self, held: list[tuple[str, bytes]]) -> Path: + pack_dir = self.root / "downloads" + pack_dir.mkdir(exist_ok=True) + pack = pack_dir / "RetroArch_BIOS_Pack.zip" + with zipfile.ZipFile(pack, "w") as archive: + archive.writestr("manifest.json", json.dumps({"files": []})) + for dest, payload in held: + archive.writestr(dest, payload) + return pack + + def test_line_mode_names_what_the_pack_lacks_before_its_plan(self): + """code_audit_21 P3-3: --dry-run promised 23 files, --check said to + fetch them, the install counted 23 beside the size of 20 and advised + retrying files that always fail.""" + files = [self.entry(f"bios{n}.bin", b"x" * (n + 1), ["nintendo-gba"]) + for n in range(4)] + listing = self.listing("retroarch", files) + pack = self.lacking_pack([("bios0.bin", b"x")]) + dest = self.root / "card" / "system" + common = dict(manifest=listing, from_pack=[pack], dest=dest, declared_only=False, + no_input=True) + text, _asked = self.line_run(_args(**common, dry_run=True), []) + lacks = "3 of these 4 files are not in RetroArch_BIOS_Pack.zip" + self.assertIn(lacks, text) + self.assertLess(text.index(lacks), text.index("Would copy")) + self.assertIn("Would copy 1 file (1 B) from the pack", text) + text, _asked = self.line_run(_args(**common, check=True), []) + self.assertIn(lacks, text) + self.assertNotIn("to fetch them", text) + self.assertIn("to copy 1 of them from RetroArch_BIOS_Pack.zip", text) + options = ("--platform", "retroarch", "--manifest", str(listing), "--dest", str(dest), + "--yes") + proc = install_run(*options, "--from-pack", str(pack)) + text = proc.stdout + proc.stderr + self.assertIn("Copying 1 file (1 B) from RetroArch_BIOS_Pack.zip...", text) + self.assertNotIn("retry only those", text) + self.assertIn("RetroArch_BIOS_Pack.zip does not hold them: a newer pack", text) + # A pack holding none: no copy, no folder. + shutil.rmtree(dest.parent, ignore_errors=True) + proc = install_run(*options, "--from-pack", str(self.lacking_pack([]))) + text = proc.stdout + proc.stderr + self.assertNotIn("Copying 0 files", text) + self.assertIn("RetroArch_BIOS_Pack.zip does not hold them", text) + self.assertFalse(dest.exists()) + + def test_a_pack_holding_none_offers_no_install_with_data_files(self): + """tui_audit_14 P3-1: data files left, the pack holding none of the + files nor the trees: Install was offered and failed every file, and + the result never named the data files.""" + state = checked_state() + state.check = {**state.check, "data_missing": 7, "data_bytes": 2048} + state.manifest = {**MANIFEST, "data_directories": [ + {"ref": "dolphin", "dest": "dolphin-emu/Sys", "files": {}}]} + + class Empty: + name = "RetroArch_BIOS_Pack.zip" + + @staticmethod + def find(entry: dict) -> None: + return None + + @staticmethod + def tree(prefix: str): + return iter(()) + + with mock.patch.object(install.LOCAL, "pack", Empty()): + actions = [row.action for row in install.tui_rows(state)] + self.assertNotIn("install", actions) + self.assertEqual(actions[0], "fromcard") + text = self.screen(state) + self.assertIn("RetroArch_BIOS_Pack.zip holds none of these 11 files", text) + # The result of an install run anyway names them. + state.stack = ["result"] + state.summary = {"installed": 0, "up_to_date": 0, "pending": 4, "kept": [], + "failed": [{"dest": event["dest"], "reason": "not in the pack"} + for event in state.files]} + state.code = 2 + _title, lines = install.tui_outcome(state) + self.assertIn("7 data files not installed: not in RetroArch_BIOS_Pack.zip.", lines) + + def test_a_command_at_40_columns_breaks_between_shell_words(self): + """tui_audit_14 P3-8: the terminal folded commands inside a word, + "--platform" ended a line with its value on the next.""" + words = ["--platform", "retroarch", "--dest", "/a/b/c/system", "--manifest", + "/home/moi/x.json", "--system", "nintendo-gba"] + text = "printf '%s\\n' " + " ".join(words) + with mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}): + lines = install._wrap(text, 38, whole="command") + self.assertGreater(len(lines), 1) + self.assertTrue(all(install.cell_width(line) <= 38 for line in lines), lines) + self.assertTrue(all(line.endswith(" \\") for line in lines[:-1]), lines) + for line in lines: + # An option never ends a line without its value. + self.assertFalse((line[:-2] if line.endswith(" \\") else line).rstrip().endswith( + ("--platform", "--dest", "--manifest", "--system")), lines) + # The shell reads the lines as the command: the same words. + proc = subprocess.run(["sh", "-c", "\n".join(lines)], capture_output=True, text=True, + check=True) + self.assertEqual(proc.stdout.split(), words) + # A path wider than a line keeps its last folders, said so, and the + # address of the one-liner is never cut. + state = _installed_state(Path("/home/moi/retroarch_system/tmp/tui_audit14/sb/a/b/bios")) + state.summary["kept"] = [{"folder": "/home/moi/retroarch_system/tmp/tui_audit14/sb/" + "retrobios-backup/2026", "files": 1, "bytes": 1}] + with mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}): + screen = [str(line) for line in install.tui_view(state, 40, 40)] + self.assertTrue(all(install.cell_width(line) <= 40 for line in screen), screen) + self.assertIn(install.TUI_SHORTENED, " ".join(screen)) + self.assertIn(install.BOOTSTRAP_URL, " ".join(screen)) + state.exit_code = 0 + farewell = "\n".join(install.tui_farewell(state)) + self.assertIn("/home/moi/retroarch_system/tmp/tui_audit14/sb/a/b/bios", farewell) + + def test_a_folder_on_its_own_line_has_one_indent(self): + """tui_audit_14 P3-9: the save folder at the label's indent, the + BIOS folder under it, a path's last piece without its indent.""" + state = checked_state() + state.dest = Path("/home/moi/retroarch_system/tmp/tui_audit14/sb/esmiyoo/home/" + ".config/retroarch/system") + state.saves = {"files": 1, "folder": "/home/moi/retroarch_system/tmp/tui_audit14/" + "sb/and2/storage/RetroArch/saves/"} + for cols in (40, 80): + with self.subTest(cols=cols): + lines = [str(line) for line in install.tui_view(state, cols, 40)] + folder = next(i for i, line in enumerate(lines) if "BIOS folder:" in line) + under = next(i for i, line in enumerate(lines) if "under:" in line) + for at in (folder, under): + pieces = [] + for line in lines[at + 1:]: + if not line.strip() or "/" not in line or " " in line.strip(): + break + pieces.append(line) + indents = {len(piece) - len(piece.lstrip()) for piece in pieces} + self.assertLessEqual(len(indents), 1, pieces) + if pieces: + self.assertEqual(indents, {3}, pieces) + + def test_no_shift_where_there_is_no_shift_key(self): + """tui_audit_14 P3-3: "Shift and a drag" on Android.""" + for os_type, said in (("linux", True), ("android", False)): + with self.subTest(os_type=os_type): + state = _installed_state(SYSTEM_DIR) + state.os_type, state.mouse = os_type, True + _title, lines = install.tui_outcome(state) + self.assertEqual(any("Shift" in line for line in lines), said) + self.assertIn(install.TUI_RESULT_AGAIN, lines[-1]) + state.help = True + text = " ".join(install.tui_view(state, 80, 24)) + self.assertEqual("Shift+drag" in text, said) + + def test_the_40x12_setup_never_ends_mid_sentence(self): + """tui_audit_14 P3-5.""" + state = fresh_state() + state.notes = [install.TUI_ESDE_NOTE] + text = [str(line).strip() for line in install.tui_view(state, 40, 12)] + self.assertIn(install.TUI_ESDE_SHORT, text) + self.assertFalse(any(line.startswith("ES-DE found") for line in text), text) + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn(install.TUI_ESDE_NOTE, text) + + def test_the_40x12_plan_keeps_its_folder_and_version_note(self): + """tui_audit_14 P3-6: the plan named ~/.var/app/ as the folder, and + the version note was absent.""" + state = checked_state() + state.dest = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/system" + state.version = "This list was built for RetroBat 8.3.1, this machine runs 8.2.0.1." + lines = [str(line).strip() for line in install.tui_view(state, 40, 12)] + joined = "".join(line for line in lines if line.endswith("/") or "system" in line) + self.assertIn("~/.var/app/org.libretro.RetroArch/config/retroarch/system", joined) + self.assertIn(state.version, " ".join(" ".join(lines).split())) + self.assertTrue(any(re.fullmatch(r"1-\d+ of \d+", line) for line in lines), lines) + self.assertIn("> Install", lines) + + def test_one_count_said_once_in_both_modes(self): + """tui_audit_14 P3-10: "22 files installed." over "22 files copied + from packx.", and line mode's Location, wrong hash and zero count.""" + state = _installed_state(SYSTEM_DIR) + state.summary["installed"] = 5 + state.finished = [(f"f{n}.bin", True, "") for n in range(5)] + state.local_dests = {f"f{n}.bin": ("/media/packx", "") for n in range(5)} + _title, lines = install.tui_outcome(state) + self.assertEqual(lines[0], "5 files copied from packx.") + self.assertNotIn("5 files installed.", lines) + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"])] + listing = self.listing("retroarch", files) + proc = install_run("--platform", "retroarch", "--manifest", str(listing), + "--dest", str(self.here), "--check", "--offline") + self.assertIn("0 in place, 1 missing, 0 wrong copies", proc.stdout) + self.assertNotIn("present (", proc.stdout) + source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + self.assertNotIn('say(f"Location', source) + self.assertNotIn("already up to date.\")\n", source.split("\nDone.", 1)[-1][:300]) + + def test_quit_names_stay_once(self): + """tui_audit_14 P3-11: "A/Enter Stay B/Esc Stay".""" + state = fresh_state() + state.stack.append("quit") + state.focus["quit"] = 1 + self.assertEqual(str(install.tui_view(state, 80, 24)[-1]).count("Stay"), 1) + + def test_the_yes_stop_example_keeps_yes(self): + """tui_audit_14 P3-12: a script copying the example stopped at a + question.""" + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"])] + listing = self.listing("retroarch", files) + proc = install_run("--yes", "--manifest", str(listing), "--offline", + env={"HOME": str(self.root / "home"), "RETROBIOS_OS": "linux"}) + example = next(line for line in proc.stderr.splitlines() + if "--platform retroarch --dest" in line) + self.assertIn(" --yes", example) + + def test_the_systems_question_fits_the_terminal(self): + """code_audit_21 P3-9: 53 of 66 rows wider than 80 columns.""" + rows = [(f"system-{n}", [{"dest": f"{n}.bin", "size": 10 ** n}]) for n in range(6)] + labels = {"system-0": "Sega Dreamcast-based arcade boards, each " + "them sega-dreamcast-arcade", + **{f"system-{n}": f"Short {n}" for n in range(1, 6)}} + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((80, 24))), \ + mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._pick("Systems:", rows, labels=labels) + printed = out.getvalue().splitlines() + self.assertTrue(all(install.cell_width(line) <= 80 for line in printed), printed) + # The long label on its own line, its size under it in the column. + at = next(i for i, line in enumerate(printed) if "sega-dreamcast-arcade" in line) + self.assertNotIn("B", printed[at].split("arcade", 1)[1]) + sizes = {line.index("1 file") for line in printed[at + 1:] if "1 file" in line} + self.assertEqual(len(sizes), 1, printed) + + def test_the_save_dir_sentence_is_written_once(self): + """code_audit_21 P3-10.""" + source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + self.assertEqual(source.count('"Name it with --save-dir next time."'), 1) + self.assertEqual(source.count("args.declared_only or len(files) + game_data_cut"), 1) + + def test_a_missing_folder_does_not_exist_yet_on_every_row(self): + """code_audit_21 P3-7: line mode said "(created)" of folders nobody + created, and the screen said "does not exist yet" of the Windows row + alone.""" + drives = [self.root / "media" / "D", self.root / "media" / "E"] + for drive in drives: + drive.mkdir(parents=True) + (drives[1] / "bios").mkdir() + with mock.patch.object(install, "_mounted_cards", return_value=drives), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "_ask", return_value="q"), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.suppress(SystemExit): + install.ask_platform_folder("linux", "retroarch") + text = out.getvalue() + self.assertNotIn(", created)", text) + self.assertNotIn("(created)", text) + self.assertIn(f"D: bios (card or drive) ({drives[0] / 'bios'}, does not exist yet)", text) + self.assertIn(f"E: bios (card or drive) ({drives[1] / 'bios'})\n", text) + state = fresh_state() + state.platform = "retroarch" + with mock.patch.object(install, "_mounted_cards", return_value=drives): + install._open_folder_screen(state, SYSTEM_DIR) + labels = [row.label for row in install.tui_rows(state) if row.action == "drive"] + self.assertEqual(labels, ["D: bios (card or drive) (does not exist yet)", + "E: bios (card or drive)"]) + + def test_line_mode_puts_the_arkos_card_first(self): + card = self.easyroms() + text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, + declared_only=False), ["1", ""], mounts=[card]) + self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) + self.assertNotIn("Nothing was found here", text) + self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.", + text) + self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text) + self.assertIn("Enter for the card's bios folder", asked[-2]) + self.assertIn(f"BIOS folder: {card / 'bios'}", text) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_site_bootstrap.py b/tests/test_site_bootstrap.py index 1ebe30d07..4d37f8700 100644 --- a/tests/test_site_bootstrap.py +++ b/tests/test_site_bootstrap.py @@ -57,8 +57,12 @@ class ServedCopies(unittest.TestCase): served = sitebootstrap.served_text(name, REPO_ROOT, self.commit).splitlines() self.assertEqual(len(original), len(served)) changed = [(a, b) for a, b in zip(original, served) if a != b] - self.assertEqual(len(changed), 1, changed) - before, after = changed[0] + # The address, and the advice a refusal gives (code_audit_21 + # P3-1). + self.assertEqual(len(changed), 2, changed) + advice = [pair for pair in changed if sitebootstrap.SERVED_ADVICE in pair[1]] + self.assertEqual(len(advice), 1, changed) + before, after = next(pair for pair in changed if pair not in advice) self.assertIn("https://raw.githubusercontent.com/Abdess/retrobios/main/install.py", before) self.assertEqual(after, before.replace( @@ -94,6 +98,51 @@ class ServedCopies(unittest.TestCase): self.assertEqual(path.read_text(encoding="utf-8"), sitebootstrap.served_text(path.name, REPO_ROOT, self.commit)) + def test_the_served_copy_never_blames_a_cut_download_or_caches(self): + """code_audit_21 P3-1: the commit-pinned copy said "cut short, try + again" and "waiting five minutes settles that", neither of which can + happen to it.""" + for name in sitebootstrap.SOURCES: + with self.subTest(name=name): + text = sitebootstrap.served_text(name, REPO_ROOT, self.commit) + self.assertNotIn("cut short", text) + self.assertNotIn("five minutes", text) + self.assertIn(sitebootstrap.SERVED_ADVICE, text) + # The raw form keeps its advice: a branch's copy can be stale. + self.assertIn("cut short", (REPO_ROOT / "install.sh").read_text(encoding="utf-8")) + page = (REPO_ROOT / "wiki" / "first-install.md").read_text(encoding="utf-8") + answer = page.split("does not match its expected fingerprint.", 1)[1].split("###", 1)[0] + self.assertNotIn("cut short", answer) + self.assertIn("something changed the file", answer) + + def test_a_stale_pin_stops_the_build(self): + """code_audit_21 P3-2: a pin that is not the installer's was served, + and refused every install until the next good deploy.""" + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() + else None) as scratch: + root = Path(scratch) + for source in ("install.sh", "install.ps1"): + (root / source).write_text((REPO_ROOT / source).read_text(encoding="utf-8"), + encoding="utf-8") + (root / "install.py").write_text('print("not the pinned installer")\n', + encoding="utf-8") + for name in sitebootstrap.SOURCES: + with self.subTest(name=name), self.assertRaises(ValueError): + sitebootstrap.served_text(name, root, self.commit) + + def test_deploy_site_checks_the_copies_before_it_publishes(self): + import yaml # noqa: PLC0415 + + steps = yaml.safe_load((REPO_ROOT / ".github/workflows/deploy-site.yml").read_text( + encoding="utf-8"))["jobs"]["build"]["steps"] + runs = [str(step.get("run", "")) for step in steps] + uses = [str(step.get("uses", "")) for step in steps] + check = next(i for i, run in enumerate(runs) if "tests.test_site_bootstrap" in run) + build = next(i for i, run in enumerate(runs) if "scripts/generate_site.py" in run) + upload = next(i for i, use in enumerate(uses) if "upload-pages-artifact" in use) + self.assertLess(build, check) + self.assertLess(check, upload) + def test_a_bootstrap_without_its_address_line_stops_the_build(self): with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None) as scratch: diff --git a/wiki/first-install.md b/wiki/first-install.md index eb08de448..6d3c6eb99 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -126,7 +126,7 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --check All good: ``` - 109/109 present (109 verified, 0 wrong hash) + 109 in place, 0 missing, 0 wrong copies ``` ## Undo @@ -159,7 +159,7 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform retropie - ### Error: the downloaded installer does not match its expected fingerprint. -The download was cut short. Run the command again. +Nothing ran: something changed the file. Try another network. ### Cannot create ... diff --git a/wiki/installer.md b/wiki/installer.md index 1bbe78f88..6ef13fd26 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -218,9 +218,15 @@ installer with a signal (SIGTERM), which stops it the same way. Either way the result says how many files were installed, and it stays printed in the terminal once the screen closes, with the command that repeats the run. A run that failed keeps its log and names it, and every other run leaves nothing behind. A path -or a command on the result is never split inside a word: one longer than the -window takes a line of its own, which the terminal folds, and a command breaks -only between its arguments. Every path a run names is spelled the way its BIOS +on the result is never split inside a word: one longer than the window takes +a line of its own, which the terminal folds. A command breaks only between its +arguments, an option kept with its value, each line but the last ending with +the continuation its shell reads (a backslash in sh, a backtick in PowerShell), so +the lines copied run as one command. Where an option and its path do not fit +on one line, the path keeps its last folders after `.../`, the line under it +says it was shortened, and the terminal shows it whole after the screen +closes. On Android and on a handheld route the result does not mention Shift, +which neither has. Every path a run names is spelled the way its BIOS folder was named, the backup folder and the save directory included (`/sdcard/RetroArch` on Android, not the `/storage/emulated/0` it links to). @@ -265,15 +271,21 @@ does. On any other row it moves the cursor there, and a second one on that row does what Enter does: one row of a terminal is far smaller than a finger, and the row a screen opens on (**Install** on Confirm) is never chosen by one tap. The entries of the bottom line press their key, **Exit** on the result -too. One that installs, quits or goes on to Confirm takes two taps as a row -does: the first adds `tap again` to it, the second presses it. A row of +too. One that writes files with no screen after it to look first, **Install** +on the plan and **Put it on the card**, takes two taps: the first adds +`click or tap again` to it in the accent colour, the second presses it. +**Install** on Confirm, which is that look, Quit, Continue and Close take one, +so installing or quitting never takes more than two. A row of Preferred regions stays where it is when it is ticked, its number giving the order, which the list shows sorted the next time it opens. The line that says which rows are in view shows the next page, and the wheel scrolls a list or a text without moving the cursor unless it would leave the screen. Nothing happens until the button or the finger comes up, and only where it went down, -on the screen drawn when it went down: the second tap of a double tap that -changes the screen does nothing on the new one. The right and middle buttons +on the screen drawn when it went down. A new screen takes no press for its +first 400 ms: a double click is two presses at most 400 ms (GNOME) to 500 ms +(Windows, macOS) apart and the new screen is drawn on the first release, so +the second tap of a double tap that changes the screen does nothing on the +new one, and a tap made later acts. The right and middle buttons do nothing. The help and the bottom line of a list of boxes say how a tap acts. Clicks are off in the Windows console the PowerShell command runs in, which @@ -379,12 +391,12 @@ A RetroArch dry run into an empty folder printed this on 2026-10-27: ``` Fetching file index for retroarch... 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves - 8358 files (6.6 GB) - 3269 BIOS files (4.1 GB) and 5089 game data files (2.5 GB). + 10994 files (6.6 GB) + 3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB). Not installable: 37 files (1 required, not found: 37). Checking existing files... - 0/8358 present (0 verified, 0 wrong hash) + 0 in place, 10994 missing, 0 wrong copies ``` The second line divides the first, and every later count of the run (the @@ -439,13 +451,13 @@ Fetching file index for batocera... Not installable: 399 files (79 required, not found: 399). Checking existing files... - 0/14481 present (0 verified, 0 wrong hash) + 0 in place, 14481 missing, 0 wrong copies ``` `Not installable` counts the files of the list that the collection does not hold. They are named at the end of the run. -On a first install the present count is zero. On a later run it counts what +On a first install nothing is in place. On a later run the line counts what is already there, and only the rest is downloaded. A download of 1 GB or more is confirmed first (`Download 9.1 GB to /userdata/bios? [Y/n]`). @@ -464,8 +476,8 @@ command again picks up where it stopped. ### The end of the run ``` -Done. 14481 files installed, 0 files already up to date. -Location: /userdata/bios +Done. 14481 files installed. +BIOS folder: /userdata/bios 399 files were not installed: the collection does not hold them, or its copy fails their check. Needed to start an emulator (79 files): dark-forces (theforceengine): DARK.GOB, SOUNDS.GOB, SPRITES.GOB, TEXTURES.GOB @@ -496,11 +508,11 @@ When some files came from this machine rather than the network, a line under the wrong place in the BIOS folder, 4 downloaded.`). See [Copy from an old card](#copy-from-an-old-card). -The word `Done` and a location are the confirmation. Any other ending +The word `Done` and the BIOS folder are the confirmation. Any other ending (`Stopped`, `Finished with errors`, `Nothing was installed`) means something was left undone, and the run says which case it was. -The lines between `Location:` and the `--check` hint name the files of the +The lines between `BIOS folder:` and the `--check` hint name the files of the list that the collection does not hold. `Needed to start an emulator` gives, for each system, the emulators that read it and the files they cannot start without. On the run above, all 79 belong to games (Dark Forces, Diablo, Doom 3, @@ -514,14 +526,14 @@ The same command with `--check` reads the folder and writes nothing: ``` Checking existing files... - 14481/14481 present (14481 verified, 0 wrong hash) + 14481 in place, 0 missing, 0 wrong copies All files up to date. ``` The files the collection does not hold are listed after it again, as long as -they are absent from the folder. A number before `wrong hash` means a file is -present with contents the platform does not accept. Running the install again +they are absent from the folder. A number before `wrong copies` means a file is +in place with contents the platform does not accept. Running the install again replaces it and keeps the old one, see [Files you already had](#files-you-already-had). @@ -665,7 +677,7 @@ downloaded as usual, or taken from the pack with `--from-pack`. Several file came from: ``` -Done. 44 files installed, 0 files already up to date. +Done. 44 files installed. 38 files copied from E:\, 4 downloaded, 2 still missing. ``` @@ -691,7 +703,7 @@ same choice comes as questions after detection. A RetroArch run with `--plain` printed this on 2026-10-28, `g` then pressed: ``` -RetroArch: 8358 files, 6.6 GB, and 2636 data files (38.5 MB). +RetroArch: 10994 files, 6.6 GB. 3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB). Game data: files a game or a game engine reads, of no use to the other cores. Data files: the files an emulator reads from a data folder of its own, such as Dolphin's Sys and PPSSPP's assets. BIOS folder: /home/me/.config/retroarch/system @@ -704,7 +716,7 @@ RetroArch: 8358 files, 6.6 GB, and 2636 data files (38.5 MB). > Counted again for your choices. Nothing is written before Enter. -RetroArch: 3269 files, 4.1 GB, and 2636 data files (38.5 MB). +RetroArch: 5905 files, 4.1 GB. 3269 BIOS files (4.1 GB) and 2636 data files (38.5 MB). 5089 game data files left out. ``` @@ -1169,7 +1181,13 @@ muOS, ArkOS and the others) opens the setup list instead, filtered to the setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At that question a drive is numbered by the folder its system reads (`ONION: BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never -by its root, which no setup reads. Another system's card or SHARE drive, and a +by its root, which no setup reads. A drive no file names that holds a `bios` +folder and console folders (`nes`, `gba`, `psx`) is a handheld's card whose +own files sit on a part a computer does not open, ArkOS's `EASYROMS` and +AmberELEC's `GAMES`: with nothing found here the first screen and line mode +say `A card is in the reader (EASYROMS): choose its system`, and its `bios` +folder comes first at the folder question, under Enter, before this +computer's usual folder. Another system's card or SHARE drive, and a drive two systems fit, are left out, and a line names them: each is reached through its own setup. A folder typed is read from the current one when it is not a full path, and named in full after. `q` quits there too @@ -1489,10 +1507,16 @@ pinned raw URL. A URL with any other scheme is refused outright. They cap the download at 2 MB and require the SHA-256 to equal the 64 hex characters embedded in the wrapper, `RETROBIOS_INSTALL_SHA256` when set. A mismatch aborts with "the downloaded installer does not match its expected -fingerprint" and nothing runs. Right after an update of this project the -wrapper and `install.py` can come from caches a few minutes apart, which reads -the same way: waiting five minutes settles it. Any change to `install.py` -therefore means recomputing that pin in both wrappers. +fingerprint" and nothing runs. The copies the site serves name the commit's +`install.py`, which never changes, and both downloaders stop on a cut transfer +before the hash is read: there a mismatch is a file changed on its way, by a +proxy or an antivirus, and that is what they say. The raw form reads `main`, +whose two files can come from caches a few minutes apart right after an +update: it says to try again, and that waiting five minutes settles it. The +site build refuses to publish a copy whose pin is not the SHA-256 of the +`install.py` it names, and Deploy Site runs the bootstrap tests before it +deploys, so a failed check keeps the last good pair served. Any change to +`install.py` therefore means recomputing that pin in both wrappers. They locate a Python 3.8 or newer interpreter (`python3` then `python` on POSIX, `py -3` then `python3` then `python` on Windows). Without one, diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index 9071ec742..96309059f 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -28,13 +28,13 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --check Read the count line: ``` - 1/109 present (0 verified, 1 wrong hash) + 0 in place, 108 missing, 1 wrong copy ``` -- **`wrong hash`**: another region or model, or damaged. -- **Fewer present than the total**: files are missing. +- **`wrong copy`**: another region or model, or damaged. +- **A number before `missing`**: files are missing. - **A line under a file**, like `still zipped`, says the fix. -- **`0 wrong hash`, all present**: go to step 3. +- **`0 missing, 0 wrong copies`**: go to step 3. ## 2. Fix the files