diff --git a/install.ps1 b/install.ps1 index 9dc2a88e4..bfb5a2142 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "89451086ee0a434877b56d78734aed95be74e62ec5c1a5ab4e54769902ed8f44" + $defaultInstallSha256 = "ad35e44d7d16467636197f7e40b34c53670eadf75d24681c32488c9bc21637f7" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 1d3d17210..23eb056ac 100644 --- a/install.py +++ b/install.py @@ -341,7 +341,9 @@ def _enable_ansi() -> bool: class Terminal: """What the output stream can show: a live line, colour, or neither. - A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb are honoured. + A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb or unset are + honoured, as is a terminal whose description moves no cursor the ANSI way + (vt52). As no-color.org reads it, an empty NO_COLOR is not set: `NO_COLOR= cmd` turned colour off. FORCE_COLOR or CLICOLOR_FORCE paints a pipe, for a log viewer that shows colour, and never makes the line live. @@ -351,7 +353,12 @@ class Terminal: self.stream = stream or sys.stdout self.quiet = quiet isatty = getattr(self.stream, "isatty", None) - self.live = bool(isatty and isatty()) and os.environ.get("TERM") != "dumb" + # An unset TERM is a dumb one, as the full screen reads it. A Windows + # console sets none and draws the sequences once _enable_ansi is on. + term = os.environ.get("TERM", "") + self.live = (bool(isatty and isatty()) and term != "dumb" + and (bool(term) or os.name == "nt") + and not terminal_without_ansi(os.environ)) forced = any(os.environ.get(name, "0") not in ("", "0") for name in ("FORCE_COLOR", "CLICOLOR_FORCE")) self.colour = ( @@ -9120,9 +9127,9 @@ def _help_epilog() -> str: return "\n".join(lines) -def main() -> None: - """Entry point.""" - _INVOCATION.os_type = detect_os() +def build_parser() -> argparse.ArgumentParser: + """The options, in the order --help and wiki/installer.md list them + (scripts/generate_installer_doc.py writes its table from this parser).""" parser = argparse.ArgumentParser( allow_abbrev=False, description="Download missing BIOS files for retrogaming emulators. " @@ -9133,10 +9140,12 @@ def main() -> None: ) parser.add_argument( "--platform", + metavar="NAME", help="target platform (retroarch, batocera, emudeck, ...)", ) parser.add_argument( "--dest", + metavar="PATH", type=Path, help="BIOS folder to install into. Without --platform, the file list is " "the platform detected here, asked at a terminal, RetroArch's when " @@ -9144,6 +9153,7 @@ def main() -> None: ) parser.add_argument( "--target", + metavar="NAME", help="hardware target for core filtering (switch, rpi4, ...); " "on Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system", ) @@ -9152,57 +9162,19 @@ def main() -> None: action="store_true", help="install the files of every board, not only the detected one", ) - parser.add_argument( - "--check", - action="store_true", - help="check existing files without downloading", - ) - parser.add_argument( - "--list-platforms", - action="store_true", - help="list detected platforms and exit", - ) - parser.add_argument( - "--list-targets", - action="store_true", - help="list available targets for a platform and exit", - ) - parser.add_argument( - "--jobs", "-j", - type=int, - default=8, - help="parallel download threads (default: 8)", - ) - parser.add_argument( - "--verbose", "-v", - action="store_true", - help="verbose output", - ) - parser.add_argument( - "--standalone-copies", - action="store_true", - help="opt in to copies into detected standalone-emulator directories", - ) - parser.add_argument( - "--add-app", - action="store_true", - help="after the install, put the RetroBIOS app on the handheld card the BIOS " - "folder is on (muOS, KNULLI, ROCKNIX), to check and update the BIOS " - "files from the console later", - ) - parser.add_argument( - "--emulator", - action="append", - metavar="NAME", - help="install what these emulators read on the platform, as a pack " - "built for them carries (names: --list-emulators)", - ) parser.add_argument( "--installed-cores", action="store_true", help="install only what the cores already installed read " "(RetroArch, Lakka, RetroPie)", ) + parser.add_argument( + "--cores-dir", + type=Path, + metavar="PATH", + help="the folder RetroArch loads its cores from, when it is not found " + "(implies --installed-cores)", + ) parser.add_argument( "--save-dir", type=Path, @@ -9211,26 +9183,26 @@ def main() -> None: "for the files some cores read there (Azahar's keys and system " "data), when it is not found", ) - parser.add_argument( - "--cores-dir", - type=Path, - metavar="PATH", - help="the folder RetroArch loads its cores from, when it is not found " - "(implies --installed-cores)", - ) parser.add_argument( "--system", action="append", - metavar="NAME", + metavar="NAMES", help="install only these systems (repeat or comma-separate, see " "--list-systems)", ) parser.add_argument( "--core", action="append", - metavar="NAME", + metavar="NAMES", help="keep only the extra files these cores load (see --list-cores)", ) + parser.add_argument( + "--emulator", + action="append", + metavar="NAMES", + help="install what these emulators read on the platform, as a pack " + "built for them carries (names: --list-emulators)", + ) parser.add_argument( "--region", action="append", @@ -9260,6 +9232,11 @@ def main() -> None: action="store_true", help="list the cores whose extra files a platform installs and exit", ) + parser.add_argument( + "--list-emulators", + action="store_true", + help="list every name --emulator accepts for a platform and exit", + ) parser.add_argument( "--romm-firmware", metavar="TEMPLATE", @@ -9267,16 +9244,16 @@ def main() -> None: "filesystem.structure.firmware in its config.yml (default: read " "from config.yml, else bios/{platform})", ) - parser.add_argument( - "--list-emulators", - action="store_true", - help="list every name --emulator accepts for a platform and exit", - ) parser.add_argument( "--no-input", action="store_true", help="never ask a question and fail where an answer is needed", ) + parser.add_argument( + "--check", + action="store_true", + help="check existing files without downloading", + ) parser.add_argument( "--dry-run", action="store_true", @@ -9299,11 +9276,13 @@ def main() -> None: "usual text on stderr (implies --no-input)", ) parser.add_argument( - "--restore", + "--from", + action="append", type=Path, metavar="FOLDER", - help=f"put back the files an earlier run replaced and kept in FOLDER " - f"(a dated folder inside {BACKUP_DIR})", + help="copy the BIOS files found in FOLDER (an old SD card, a backup, a " + "ZIP) instead of downloading them: every file recognised by its " + "hash, never moved. Repeat for several folders", ) parser.add_argument( "--from-pack", @@ -9321,15 +9300,6 @@ def main() -> None: "work without one, and a file the run would download fails at once " f"({OFFLINE_VARIABLE}=1 does the same)", ) - parser.add_argument( - "--from", - action="append", - type=Path, - metavar="FOLDER", - help="copy the BIOS files found in FOLDER (an old SD card, a backup, a " - "ZIP) instead of downloading them: every file recognised by its " - "hash, never moved. Repeat for several folders", - ) parser.add_argument( "--manifest", type=Path, @@ -9337,6 +9307,13 @@ def main() -> None: help="read the file list from FILE (install/.json) instead " "of downloading it", ) + parser.add_argument( + "--restore", + type=Path, + metavar="FOLDER", + help=f"put back the files an earlier run replaced and kept in FOLDER " + f"(a dated folder inside {BACKUP_DIR})", + ) parser.add_argument( "--tui", action="store_true", @@ -9357,7 +9334,47 @@ def main() -> None: version=f"retrobios installer {installer_id()} (ref {RELEASE_REF})", help="print the installer version and exit", ) + parser.add_argument( + "--list-platforms", + action="store_true", + help="list detected platforms and exit", + ) + parser.add_argument( + "--list-targets", + action="store_true", + help="list available targets for a platform and exit", + ) + parser.add_argument( + "--jobs", "-j", + metavar="N", + type=int, + default=8, + help="parallel download threads (default: 8)", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="verbose output", + ) + parser.add_argument( + "--standalone-copies", + action="store_true", + help="opt in to copies into detected standalone-emulator directories", + ) + parser.add_argument( + "--add-app", + action="store_true", + help="after the install, put the RetroBIOS app on the handheld card the BIOS " + "folder is on (muOS, KNULLI, ROCKNIX), to check and update the BIOS " + "files from the console later", + ) + return parser + +def main() -> None: + """Entry point.""" + _INVOCATION.os_type = detect_os() + parser = build_parser() args = parser.parse_args() if args.verbose: _RAW_ERRORS.set() @@ -11305,7 +11322,7 @@ def _plain_key(char: str) -> TuiKey | None: """The key one character stands for, None for a control nobody bound.""" named = {"\r": "enter", "\n": "enter", "\x7f": "backspace", "\x08": "backspace", "\t": "tab", "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d", - "\x15": "ctrl-u"}.get(char) + "\x15": "ctrl-u", "\x1a": "ctrl-z"}.get(char) if named: return TuiKey(named) if char < " " or unicodedata.category(char) in ("Cc", "Cs"): @@ -11728,7 +11745,92 @@ def plain_requested(args: argparse.Namespace, env: Mapping[str, str] | None = No # (ports/shared/tui.txt exports RB_PAD as this), so the bar names those # buttons and no others. TUI_PAD_ENV = "RETROBIOS_PAD" -TUI_PAD_ROUTES = ("gptokeyb", "gptokeyb_muxterm", "muxterm", "evmapy", "miyoo") + + +class TuiRoute(NamedTuple): + """How one input route names the keys, read by the screen and by + scripts/generate_installer_doc.py for the key table of the page. + + bar maps a key of the bottom line, written in the "both" form ("A/Enter + Install"), to the route's own name, None for a key the route lacks: a PC + user read "A/Enter", a muOS player was told a Start+Select that muxterm + does not have. help names each action of the help screen, the buttons + first, then the keys a keyboard plugged in also has, None for an action + the route lacks. stop is what stops an install the second time, then the + first time. + """ + + bar: Mapping[str, str | None] + help: Mapping[str, str | None] + stop: tuple[str, str] + + +# retrobios.gptk: A Enter, B Esc, X Space, Y Tab, L1 and R1 a page, Start +# Enter, Select Esc, Start+Select the kill. +_GPTOKEYB = TuiRoute( + {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None, "/": None, + "Ctrl-C": "Start+Select", "Enter": "A", "Esc": "B", "Ctrl-U": None}, + {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter", + "tick": "X, Space, Right/Left", "options": "Y, Tab, o", "back": "B, Select, Esc", + "stop": "B, Esc, Ctrl-C", "kill": "Select, then Start", "enter": "A, Enter", + "esc": "B, Esc"}, + ("B", "B or Start+Select")) +# keys is a keyboard's, and both names the button and the key where nothing +# says which one is held. The others are the values of RETROBIOS_PAD. +TUI_ROUTES: dict[str, TuiRoute] = { + "keys": TuiRoute( + {"A/Enter": "Enter", "B/Esc": "Esc", "X/Space": "Space", "Y/o": "o"}, + {"move": "Up/Down, k/j", "page": "PgUp/PgDn", "ok": "Enter", + "tick": "Space, Right/Left", "options": "o, Tab", "back": "Esc, Backspace", + "stop": "Esc, Ctrl-C", "kill": None, "enter": "Enter", "esc": "Esc"}, + ("Esc or Ctrl-C", "Esc or Ctrl-C")), + "both": TuiRoute( + {}, + {"move": "Up/Down, k/j", "page": "PgUp/PgDn, L1/R1", "ok": "A/Enter, Start", + "tick": "X, Right/Left", "options": "Y, o, Tab", "back": "B/Esc, Backspace", + "stop": "B/Esc, Ctrl-C", "kill": "Start+Select", "enter": "Enter", "esc": "Esc"}, + ("B or Ctrl-C", "B, Ctrl-C or Start+Select")), + "gptokeyb": _GPTOKEYB, + # gptokeyb2 with retrobios.gptk inside muxterm, which no longer reads the + # pad: the gptokeyb buttons. muxterm keeps PgUp and PgDn for its + # scrollback, and the launcher sends nothing on L1 and R1 there: Left and + # Right page. + "gptokeyb_muxterm": _GPTOKEYB._replace(help={**_GPTOKEYB.help, + "page": "D-pad Left/Right", + "tick": "X, Space"}), + # muxterm's own keys: Start Enter, B Backspace, Y Space, Menu closes the + # terminal. A, X, L1 and R1 send nothing, Select shows its keyboard, and + # the D-pad's Left and Right page a list. + "muxterm": TuiRoute( + {"A/Enter": "Start", "B/Esc": "B", "X/Space": "Y", "Y/o": None, "?": None, + "/": None, "Ctrl-C": "Menu", "Enter": "Start", "Esc": None, "Ctrl-U": None}, + {"move": "D-pad, Up/Down", "page": "D-pad Left/Right", "ok": "Start, Enter", + "tick": "Y, Space", "options": "o, Tab", "back": "B, Esc, Backspace", + "stop": "B, Esc, Ctrl-C", "kill": None, "enter": "Start, Enter", "esc": "Esc"}, + ("B", "B")), + # RetroBIOS.sh.keys: the gptokeyb buttons, and no combo that ends the run. + "evmapy": TuiRoute( + {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None, "/": None, + "Ctrl-C": None, "Enter": "A", "Esc": "B", "Ctrl-U": None}, + {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter", + "tick": "X, Space, Right/Left", "options": "Y, Tab, o", + "back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None, + "enter": "A, Enter", "esc": "B, Esc"}, + ("B", "B")), + # Buttons that are keys already: A Space, Start Enter, L2 Tab, R2 + # Backspace. B is Left Ctrl, a modifier: alone it sends no byte to a + # terminal (the console's keyboard driver only records its state), so + # the interface never sees it, nor X, Y and Select (Shift, Alt, Ctrl). + "miyoo": TuiRoute( + {"A/Enter": "A", "B/Esc": "R2", "X/Space": "A", "Y/o": "L2", "?": None, "/": None, + "Ctrl-C": None, "Enter": "Start", "Esc": None, "Ctrl-U": None}, + {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter", + "tick": "A, Space, Right/Left", "options": "L2, Tab, o", + "back": "R2, Esc, Backspace", "stop": "R2, Esc, Ctrl-C", "kill": None, + "enter": "Start, Enter", "esc": "Esc"}, + ("R2", "R2")), +} +TUI_PAD_ROUTES = tuple(name for name in TUI_ROUTES if name not in ("keys", "both")) # Set by Steam for what it starts, and by gamescope: a controller may be # all there is, mapped by Steam Input to keys nobody here can name. _STEAM_MARKERS = ("SteamGamepadUI", "SteamDeck", "SteamGameId", "GAMESCOPE_WAYLAND_DISPLAY") @@ -11780,6 +11882,81 @@ def _terminal_size(stream: TextIO) -> tuple[int, int]: return size.columns, size.lines +# Where ncurses finds a terminal's compiled description after $TERMINFO, +# ~/.terminfo and $TERMINFO_DIRS. +_TERMINFO_DIRS = ("/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", + "/usr/lib/terminfo", "/usr/share/lib/terminfo") +# cursor_address, string capability 10 of term(5). +_TERMINFO_CUP = 10 + + +def _terminfo_folders(env: Mapping[str, str]) -> list[Path]: + folders = [Path(env["TERMINFO"])] if env.get("TERMINFO") else [] + if env.get("HOME"): + folders.append(Path(env["HOME"]) / ".terminfo") + # An empty entry stands for the system's folders, which come last anyway. + folders += [Path(part) for part in env.get("TERMINFO_DIRS", "").split(":") if part] + return folders + [Path(folder) for folder in _TERMINFO_DIRS] + + +def terminfo_string(data: bytes, index: int) -> str | None: + """String capability index of a compiled term(5) entry, "" when the + entry lacks it, None when data is no compiled entry.""" + def short(at: int) -> int: + return int.from_bytes(data[at:at + 2], "little", signed=True) + + if len(data) < 12: + return None + # The legacy format stores numbers in 2 bytes, the extended one in 4. + width = {0o432: 2, 0o1036: 4}.get(short(0)) + if width is None: + return None + names, bools, numbers, strings = short(2), short(4), short(6), short(8) + offsets = 12 + names + bools + offsets += offsets % 2 + numbers * width + if index >= strings: + return "" + at = short(offsets + 2 * index) + if at < 0: + return "" + start = offsets + 2 * strings + at + end = data.find(b"\0", start) + if end < 0: + return None + return data[start:end].decode("latin-1") + + +def terminal_cursor_address(term: str, env: Mapping[str, str]) -> str | None: + """How TERM's description here moves the cursor, "" when it cannot, + None when no description is found: Windows, Android and many handheld + images carry none, and the screen is not held back for it.""" + if not term or "/" in term or term.startswith("."): + return None + for folder in _terminfo_folders(env): + # Linux files xterm under x/, macOS under 78/. + for sub in (term[0], f"{ord(term[0]):02x}"): + try: + data = (folder / sub / term).read_bytes() + except (OSError, ValueError): + continue + return terminfo_string(data, _TERMINFO_CUP) + return None + + +def terminal_without_ansi(env: Mapping[str, str]) -> str: + """Why TERM's description here says the ANSI sequences the screen and + the progress line write would print as text, "" when it does not.""" + if os.name == "nt": + return "" + term = env.get("TERM", "") + cup = terminal_cursor_address(term, env) + if cup == "": + return f"TERM={term} cannot move the cursor" + if cup is not None and not cup.startswith(("\x1b[", "\x9b")): + return f"TERM={term} does not take the escape sequences the screen is drawn with" + return "" + + def tui_blocker( args: argparse.Namespace, stdin: TextIO | None = None, stdout: TextIO | None = None, env: Mapping[str, str] | None = None, @@ -11804,6 +11981,9 @@ def tui_blocker( return f"the {name} is not a terminal" if os.name != "nt" and env.get("TERM", "") in ("", "dumb"): return "TERM is dumb or not set" + drawing = terminal_without_ansi(env) + if drawing: + return drawing # A pseudo-terminal nobody sized reports 0x0: script(1) fed from a pipe # is one, and the line questions run there as they always have. cols, rows = _terminal_size(stdout) @@ -13728,42 +13908,12 @@ _SHORT_KEYS = (("A/Enter ", "A "), ("B/Esc ", "B "), ("X/Space ", "X "), ("Y/o " ("Left/Right ", "<> "), ("Up/Down ", "^v ")) -# The key of a hint as each route names it, None for a key it lacks. The -# bar is written in the "both" form ("A/Enter Install"), the route's own -# names replace it: a PC user read "A/Enter", a muOS player was told a -# Start+Select that muxterm does not have. -_ROUTE_KEYS: dict[str, dict[str, str | None]] = { - "keys": {"A/Enter": "Enter", "B/Esc": "Esc", "X/Space": "Space", "Y/o": "o"}, - # retrobios.gptk: A Enter, B Esc, X Space, Y Tab, L1 and R1 a page, - # Start Enter, Select Esc, Start+Select the kill. - "gptokeyb": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None, - "/": None, "Ctrl-C": "Start+Select", "Enter": "A", "Esc": "B", - "Ctrl-U": None}, - # RetroBIOS.sh.keys: the same buttons, and no combo that ends the run. - "evmapy": {"A/Enter": "A", "B/Esc": "B", "X/Space": "X", "Y/o": "Y", "?": None, - "/": None, "Ctrl-C": None, "Enter": "A", "Esc": "B", "Ctrl-U": None}, - # muxterm's own keys: Start Enter, B Backspace, Y Space, Menu closes the - # terminal. A, X, L1 and R1 send nothing, Select shows its keyboard. - "muxterm": {"A/Enter": "Start", "B/Esc": "B", "X/Space": "Y", "Y/o": None, "?": None, - "/": None, "Ctrl-C": "Menu", "Enter": "Start", "Esc": None, - "Ctrl-U": None}, - # Buttons that are keys already: A Space, Start Enter, L2 Tab, R2 - # Backspace. B is Left Ctrl, a modifier: alone it sends no byte to a - # terminal (the console's keyboard driver only records its state), so - # the interface never sees it, nor X, Y and Select (Shift, Alt, Ctrl). - "miyoo": {"A/Enter": "A", "B/Esc": "R2", "X/Space": "A", "Y/o": "L2", "?": None, - "/": None, "Ctrl-C": None, "Enter": "Start", "Esc": None, "Ctrl-U": None}, -} -# gptokeyb2 with retrobios.gptk inside muxterm, which no longer reads the pad: -# the gptokeyb buttons. -_ROUTE_KEYS["gptokeyb_muxterm"] = _ROUTE_KEYS["gptokeyb"] - - def _route_hint(state: TuiState, parts: tuple) -> tuple: """The bar's entries named as the run's input names them.""" - names = _ROUTE_KEYS.get(state.pad) - if names is None or state.help: + route = TUI_ROUTES.get(state.pad) + if route is None or state.help: return parts + names = route.bar named = [] for part in parts: key, sep, what = part.partition(" ") @@ -14606,18 +14756,6 @@ def _bar(share: float, width: int) -> str: return f"[{'#' * filled}{'-' * (room - filled)}] {int(share * 100):3d}%" -# What stops an install a second time, and the first time, per route. -_STOP_KEYS = { - "both": ("B or Ctrl-C", "B, Ctrl-C or Start+Select"), - "keys": ("Esc or Ctrl-C", "Esc or Ctrl-C"), - "gptokeyb": ("B", "B or Start+Select"), - "gptokeyb_muxterm": ("B", "B or Start+Select"), - "evmapy": ("B", "B"), - "muxterm": ("B", "B"), - "miyoo": ("R2", "R2"), -} - - def _view_installing(state: TuiState, width: int, height: int) -> tuple: lines: list = [] if state.download is None: @@ -14640,7 +14778,7 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: lines.append(f"{files_done} of {_plural(state.download['files'], 'file')}") if files_done >= state.download["files"] and state.summary is None: lines.append("Finishing...") - stops = _STOP_KEYS.get(state.pad, _STOP_KEYS["both"]) + stops = TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).stop lines.append(_styled(f"Stopping, finishing the files in flight. Press {stops[0]} " "again to stop now." if state.stopping else f"{stops[1]} stops. What is done stays done.", "dim")) @@ -14932,41 +15070,6 @@ _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = { } -# The help's names for each action, per route: the buttons first, then the -# keys a keyboard plugged in also has. None for an action the route lacks. -_HELP_KEYS: dict[str, dict[str, str | None]] = { - "both": {"move": "Up/Down, k/j", "page": "PgUp/PgDn, L1/R1", "ok": "A/Enter, Start", - "tick": "X, Right/Left", "options": "Y, o, Tab", "back": "B/Esc, Backspace", - "stop": "B/Esc, Ctrl-C", "kill": "Start+Select", "enter": "Enter", - "esc": "Esc"}, - "keys": {"move": "Up/Down, k/j", "page": "PgUp/PgDn", "ok": "Enter", - "tick": "Space, Right/Left", "options": "o, Tab", "back": "Esc, Backspace", - "stop": "Esc, Ctrl-C", "kill": None, "enter": "Enter", "esc": "Esc"}, - "gptokeyb": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", - "ok": "A, Start, Enter", "tick": "X, Space, Right/Left", - "options": "Y, Tab, o", "back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", - "kill": "Select, then Start", "enter": "A, Enter", "esc": "B, Esc"}, - "evmapy": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter", - "tick": "X, Space, Right/Left", "options": "Y, Tab, o", - "back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None, - "enter": "A, Enter", "esc": "B, Esc"}, - # muxterm keeps PgUp and PgDn for its own scrollback, and its L1 and R1 - # send nothing: the D-pad's Left and Right page a list there. - "muxterm": {"move": "D-pad, Up/Down", "page": "D-pad Left/Right", "ok": "Start, Enter", - "tick": "Y, Space", "options": "o, Tab", - "back": "B, Esc, Backspace", "stop": "B, Esc, Ctrl-C", "kill": None, - "enter": "Start, Enter", "esc": "Esc"}, - "miyoo": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter", - "tick": "A, Space, Right/Left", "options": "L2, Tab, o", - "back": "R2, Esc, Backspace", "stop": "R2, Esc, Ctrl-C", "kill": None, - "enter": "Start, Enter", "esc": "Esc"}, -} -# muxterm keeps PgUp and PgDn for its scrollback even when gptokeyb2 owns the -# pad, and the launcher sends nothing on L1 and R1 there: Left and Right page. -_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": "D-pad Left/Right", - "tick": "X, Space"} - - def _route_extras(state: TuiState) -> list[tuple[str, str]]: """What a route's own buttons do that no key of the table says.""" if state.pad == "muxterm": @@ -14978,7 +15081,7 @@ def _route_extras(state: TuiState) -> list[tuple[str, str]]: def _help_lines(state: TuiState) -> list[tuple[str, str]]: screen = state.screen - names = _HELP_KEYS.get(state.pad, _HELP_KEYS["both"]) + names = TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).help # gptokeyb's kill: Start pressed first also chooses the row in focus. kill = ([(names["kill"], "Quit at once")] if state.pad in ("gptokeyb", "gptokeyb_muxterm") else []) @@ -15072,10 +15175,15 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: """The screen as rows lines of at most cols cells, from the state alone.""" if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS: + # How large, and how large it is now: "larger" alone left the + # reader resizing by guess. lines = [_styled("")] * rows - text = fit("Make the window larger", cols) - if rows: - lines[rows // 2] = _styled(" " * max(0, (cols - cell_width(text)) // 2) + text) + said = [fit(text, cols) for text in ( + "Make the window larger:", + f"{TUI_MIN_COLS}x{TUI_MIN_ROWS} or more, now {cols}x{rows}")][:rows] + top = max(0, (rows - len(said)) // 2) + for index, text in enumerate(said): + lines[top + index] = _styled(" " * max(0, (cols - cell_width(text)) // 2) + text) return lines width = min(cols, TUI_MAX_WIDTH) - 2 height = rows - 3 @@ -15788,6 +15896,13 @@ def run_tui(args: argparse.Namespace) -> int: state.now = time.monotonic() terminal.draw(tui_view(state, cols, rows), cols) for key in terminal.keys(TUI_FRAME): + if key.name == "ctrl-z": + # ISIG is off, so Ctrl-Z comes as a key: it stops the run + # as kill -TSTP does, where the system has job control. + if hasattr(signal, "SIGSTOP"): + flags["suspend"] = True + break + continue pressed = True state.now = time.monotonic() perform(tui_update(state, key)) diff --git a/install.sh b/install.sh index 87c3f6fea..80a2924ab 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="89451086ee0a434877b56d78734aed95be74e62ec5c1a5ab4e54769902ed8f44" +DEFAULT_INSTALL_SHA256="ad35e44d7d16467636197f7e40b34c53670eadf75d24681c32488c9bc21637f7" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 608ebeb3f..502632168 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": 715303, - "sha256": "89451086ee0a434877b56d78734aed95be74e62ec5c1a5ab4e54769902ed8f44", + "size": 719855, + "sha256": "ad35e44d7d16467636197f7e40b34c53670eadf75d24681c32488c9bc21637f7", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 767852, - "sha256": "07caac3bd999de7eea34292d8ecc806100f6e950a51805d7db3df37af141bc94" + "size": 772404, + "sha256": "e3aa2c3b790916d103731c47dc3fbf7dd30cd4416ed5120936796b545251e9cd" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 715303, - "sha256": "89451086ee0a434877b56d78734aed95be74e62ec5c1a5ab4e54769902ed8f44", + "size": 719855, + "sha256": "ad35e44d7d16467636197f7e40b34c53670eadf75d24681c32488c9bc21637f7", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 715303, - "sha256": "89451086ee0a434877b56d78734aed95be74e62ec5c1a5ab4e54769902ed8f44", + "size": 719855, + "sha256": "ad35e44d7d16467636197f7e40b34c53670eadf75d24681c32488c9bc21637f7", "executable": false }, { diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py new file mode 100644 index 000000000..ea89da6a6 --- /dev/null +++ b/scripts/generate_installer_doc.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Rewrite the marked tables of wiki/installer.md from install.py. + + python scripts/generate_installer_doc.py rewrite the tables + python scripts/generate_installer_doc.py --check exit 1 when one is stale + +Each table sits between `` and +``. What install.py knows is written from it: the +options and their arguments from its parser, the event kinds and their keys +from EVENT_KEYS, the exit codes from its sys.exit calls, the RETROBIOS_PAD +values and each route's keys from TUI_ROUTES. The prose cells (what an option +does, when an event comes) are kept as written, matched by their row. A row +install.py gains has no prose yet: the generator names it and writes nothing, +and the row is added by hand inside the markers, its first cell holding the +option, kind, code or value alone. +""" + +from __future__ import annotations + +import argparse +import ast +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PAGE = ROOT / "wiki" / "installer.md" +INSTALLER = ROOT / "install.py" + +_spec = importlib.util.spec_from_file_location("install", INSTALLER) +_install = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_install) + +BLOCK = re.compile( + r"(?P\n)(?P.*?)" + r"(?P)", re.S) +_CELL = re.compile(r"(? list[str]: + return [cell.strip() for cell in _CELL.split(line.strip().strip("|"))] + + +def _rows(body: str) -> list[list[str]]: + lines = [line for line in body.splitlines() if line.startswith("|")] + return [_cells(line) for line in lines[2:]] + + +def _prose(body: str, key: re.Pattern[str], width: int) -> dict[str, list[str]]: + """The prose cells of each row, by the key its first cell holds.""" + kept = {} + for cells in _rows(body): + found = key.search(cells[0]) + if found: + kept[found.group(1)] = (cells[1:] + [""] * width)[:width] + return kept + + +def _table(heading: list[str], rows: list[list[str]]) -> str: + lines = ["| " + " | ".join(heading) + " |", + "|" + "|".join("-" * (len(cell) + 2) for cell in heading) + "|"] + lines += ["| " + " | ".join(row) + " |" for row in rows] + return "\n".join(lines) + "\n" + + +def _with_prose(name: str, facts: list[tuple[str, str]], body: str, key: str, + width: int) -> list[list[str]]: + """Each row's first cell from install.py, the rest from the page, whose + last cell must be written.""" + prose = _prose(body, re.compile(key), width) + missing = [fact for fact, _cell in facts if not prose.get(fact, [""])[-1]] + if missing: + raise MissingProse(f"{name}: no text for {', '.join(missing)}") + return [[cell, *prose[fact]] for fact, cell in facts] + + +def option_rows(body: str) -> list[list[str]]: + facts = [] + # argparse's own --help last, after the options of the run. + for action in sorted(_install.build_parser()._actions, + key=lambda action: isinstance(action, argparse._HelpAction)): + flags = sorted(action.option_strings, key=lambda flag: not flag.startswith("--")) + metavar = "" if action.nargs == 0 else (action.metavar or action.dest.upper()) + shown = ", ".join(f"`{flag} {metavar}`" if metavar else f"`{flag}`" for flag in flags) + facts.append((flags[0], f'{shown}')) + return _with_prose("options", facts, body, r"`(--[a-z-]+)", 1) + + +def exit_codes() -> list[int]: + """The codes install.py exits with: its sys.exit calls, 0 for a run + that returns, 2 for an option argparse refuses.""" + codes = {0, 2} + for node in ast.walk(ast.parse(INSTALLER.read_text(encoding="utf-8"))): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr == "exit" and getattr(node.func.value, "id", "") == "sys" + and node.args): + continue + given = node.args[0] + for value in ((given.body, given.orelse) if isinstance(given, ast.IfExp) else (given,)): + if isinstance(value, ast.Constant) and isinstance(value.value, int): + codes.add(value.value) + return sorted(codes) + + +def exit_rows(body: str) -> list[list[str]]: + facts = [(str(code), f"`{code}`") for code in exit_codes()] + return _with_prose("exit-codes", facts, body, r"`(\d+)`", 1) + + +def event_rows(body: str) -> list[list[str]]: + facts = [(kind, f"`{kind}`") for kind in _install.EVENT_KEYS] + rows = _with_prose("events", facts, body, r"`([a-z_]+)`", 2) + for row, keys in zip(rows, _install.EVENT_KEYS.values()): + row[1] = ", ".join(f"`{key}`" for key in keys) + return rows + + +def pad_rows(body: str) -> list[list[str]]: + facts = [(route, f"`{route}`") for route in _install.TUI_PAD_ROUTES] + return _with_prose("pad-routes", facts, body, r"`([a-z_]+)`", 2) + + +def route_keys() -> str: + routes = _install.TUI_ROUTES + actions = list(next(iter(routes.values())).help) + unnamed = [action for action in actions if action not in ACTIONS] + if unnamed or any(list(route.help) != actions for route in routes.values()): + raise MissingProse(f"route-keys: TUI_ROUTES actions {unnamed or 'differ by route'} " + "need a name in ACTIONS") + heading = ["Action", *(ROUTE_HEADINGS.get(name, f"`{name}`") for name in routes)] + rows = [[ACTIONS[action], *(route.help[action] or "none" for route in routes.values())] + for action in actions] + return _table(heading, rows) + + +def render(page: str) -> str: + """The page with each marked table written from install.py.""" + writers = { + "options": lambda body: _table(["Option", "Effect"], option_rows(body)), + "exit-codes": lambda body: _table(["Code", "Meaning"], exit_rows(body)), + "events": lambda body: _table(["Kind", "Keys", "When"], event_rows(body)), + "pad-routes": lambda body: _table(["Value", "Input", "Set by"], pad_rows(body)), + "route-keys": lambda _body: route_keys(), + } + seen = [] + + def write(match: re.Match[str]) -> str: + name = match.group("name") + if name not in writers: + raise MissingProse(f"no table is written for the marker {name!r}") + seen.append(name) + return f"{match.group('open')}\n{writers[name](match.group('body'))}\n{match.group('close')}" + + text = BLOCK.sub(write, page) + absent = sorted(set(writers) - set(seen)) + if absent: + raise MissingProse(f"markers missing from the page: {', '.join(absent)}") + return text + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="store_true", + help="exit 1 when a table differs from install.py, writing nothing") + parser.add_argument("--page", type=Path, default=PAGE, help=argparse.SUPPRESS) + args = parser.parse_args(argv) + current = args.page.read_text(encoding="utf-8") + try: + text = render(current) + except MissingProse as exc: + print(f"{args.page}: {exc}. Add the row inside its markers, then run " + "python scripts/generate_installer_doc.py", file=sys.stderr) + return 1 + if text == current: + return 0 + if args.check: + print(f"{args.page} differs from install.py: run " + "python scripts/generate_installer_doc.py", file=sys.stderr) + return 1 + args.page.write_text(text, encoding="utf-8") + print(f"Wrote {args.page}") + return 0 + + +if __name__ == "__main__": + from common import run_cli + + run_cli(main) diff --git a/scripts/sitediagrams.py b/scripts/sitediagrams.py index 139d5519e..2cba29ada 100644 --- a/scripts/sitediagrams.py +++ b/scripts/sitediagrams.py @@ -820,9 +820,10 @@ def installer_options() -> tuple[tuple[str, str], ...]: if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) } - main = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "main") + builder = next(node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "build_parser") options = [] - for node in ast.walk(main): + for node in ast.walk(builder): if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "add_argument"): continue @@ -957,9 +958,15 @@ def hide_hashes(markdown: str) -> str: return "".join(pieces) +# The markers scripts/generate_installer_doc.py writes its tables between: +# for the source only, the page shows the table alone. +GENERATED_MARKER = re.compile(r"(?m)^\n\n?") + + def prepare_page(markdown: str, page: Path) -> str: """Everything this module does to a page before it is decorated.""" markdown = expand_glance(expand_options(expand_commands(resolve_images(localize_images(markdown, page))))) + markdown = GENERATED_MARKER.sub("", markdown) if page.parts[:1] == ("wiki",): markdown = hide_hashes(markdown) return markdown diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 440071742..da7dfc7d6 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -688,9 +688,9 @@ class FirstInstallWalkthrough(unittest.TestCase): import install section = _section(self.text, "4. Wait") - computer = install._STOP_KEYS["keys"][0].replace("Ctrl-C", "Ctrl+C") + computer = install.TUI_ROUTES["keys"].stop[0].replace("Ctrl-C", "Ctrl+C") self.assertIn(f"{computer} stops", section) - self.assertIn(f"{install._STOP_KEYS['gptokeyb'][0]} on a handheld", section) + self.assertIn(f"{install.TUI_ROUTES['gptokeyb'].stop[0]} on a handheld", section) def test_no_hand_kept_size_before_the_run(self): import install diff --git a/tests/test_install.py b/tests/test_install.py index 42e210792..15bb1a118 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5349,6 +5349,97 @@ class TheEventSchemaFileIsEventKeys(unittest.TestCase): self.assertEqual(generator.main(["--check", "--output", str(scratch)]), 0) +class TheInstallerPageIsWrittenFromTheCode(unittest.TestCase): + """The options, events, exit codes and keys of wiki/installer.md were + written by hand beside the code that defines them, and every audit + found one of them behind it.""" + + PAGE = REPO_ROOT / "wiki" / "installer.md" + + def setUp(self): + sys.path.insert(0, str(REPO_ROOT / "scripts")) + import generate_installer_doc # noqa: PLC0415 + + self.generator = generate_installer_doc + self.scratch = Path(_tempdir(self)) / "installer.md" + self.scratch.write_text(self.PAGE.read_text(encoding="utf-8"), encoding="utf-8") + + def run_generator(self, *argv: str) -> tuple[int, str]: + err = io.StringIO() + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err): + code = self.generator.main([*argv, "--page", str(self.scratch)]) + return code, err.getvalue() + + def test_the_committed_page_is_the_generated_one(self): + with contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(self.generator.main(["--check"]), 0, + err.getvalue() + "run python scripts/generate_installer_doc.py") + + def test_a_hand_edit_of_a_fact_fails_the_check(self): + page = self.scratch.read_text(encoding="utf-8") + for old, new in (("`--jobs N`", "`--jobs JOBS`"), + ("| `end` | `code` |", "| `end` | `code`, `reason` |"), + ("| Quit at once | none |", "| Quit at once | q |"), + # A code install.py never exits with. + ("| `141` |", "| `142` | stopped twice |\n| `141` |")): + with self.subTest(edit=new): + self.assertIn(old, page) + self.scratch.write_text(page.replace(old, new, 1), encoding="utf-8") + self.assertEqual(self.run_generator("--check")[0], 1) + self.assertEqual(self.run_generator()[0], 0) + self.assertEqual(self.scratch.read_text(encoding="utf-8"), page) + + def test_the_prose_is_kept(self): + page = self.scratch.read_text(encoding="utf-8") + old = "| Parallel downloads, 1 to 32, default 8 |" + self.assertIn(old, page) + edited = page.replace(old, "| Parallel downloads, from 1 to 32 |") + self.scratch.write_text(edited, encoding="utf-8") + self.assertEqual(self.run_generator("--check")[0], 0) + + def test_what_the_code_gains_stops_it_until_the_page_says_it(self): + real = self.generator._install.build_parser + + def gained() -> argparse.ArgumentParser: + parser = real() + parser.add_argument("--brand-new", action="store_true", help="new") + return parser + + page = self.scratch.read_text(encoding="utf-8") + events = {**self.generator._install.EVENT_KEYS, "brand_new": ("thing",)} + for name, patch in ( + ("--brand-new", unittest.mock.patch.object( + self.generator._install, "build_parser", gained)), + ("brand_new", unittest.mock.patch.object( + self.generator._install, "EVENT_KEYS", events))): + with self.subTest(gained=name), patch: + code, said = self.run_generator() + self.assertEqual(code, 1) + self.assertIn(name, said) + self.assertEqual(self.scratch.read_text(encoding="utf-8"), page) + self.assertEqual(self.run_generator("--check")[0], 1) + # The row added by hand, its first cell alone: the generator fills it. + close = "\n\n" + self.scratch.write_text( + page.replace(close, "\n| `--brand-new` | Something new |" + close, 1), + encoding="utf-8") + with unittest.mock.patch.object(self.generator._install, "build_parser", gained): + self.assertEqual(self.run_generator()[0], 0) + self.assertIn('| `--brand-new` | Something new |', + self.scratch.read_text(encoding="utf-8")) + + def test_the_exit_codes_are_the_ones_install_py_exits_with(self): + self.assertEqual(self.generator.exit_codes(), [0, 1, 2, 130, 141]) + + def test_a_missing_marker_stops_it(self): + page = self.scratch.read_text(encoding="utf-8") + self.scratch.write_text(page.replace("\n", ""), + encoding="utf-8") + code, said = self.run_generator("--check") + self.assertEqual(code, 1) + self.assertIn("route-keys", said) + + class NetworkFailuresInPlainWords(unittest.TestCase): """Offline, the run printed "[Errno -2] Name or service not known". A beginner needs "no internet connection", and the raw text only under diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 9b858c365..b74cfb7d9 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -878,7 +878,8 @@ class ColourFollowsNoColorOrg(unittest.TestCase): def _colour(self, **env: str) -> bool: clean = {k: v for k, v in os.environ.items() if k not in ("NO_COLOR", "FORCE_COLOR", "CLICOLOR_FORCE", "TERM")} - with mock.patch.dict(os.environ, {**clean, **env}, clear=True), \ + # A terminal that is named: an unset TERM is a dumb one. + with mock.patch.dict(os.environ, {**clean, "TERM": "xterm", **env}, clear=True), \ mock.patch.object(install, "_enable_ansi", return_value=True): return install.Terminal(_Tty()).colour diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index c145f6347..77bd03119 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -153,7 +153,9 @@ class KeyDecoding(unittest.TestCase): [Key("paste", "/a/b"), Key("enter")]) def test_controls_nobody_bound_are_ignored(self): - self.assertEqual(self.feed("\x00\x1a\x1cx"), keys("char:x")) + self.assertEqual(self.feed("\x00\x1cx"), keys("char:x")) + # Ctrl-Z is a key once ISIG is off: the run stops on it, as on SIGTSTP. + self.assertEqual(self.feed("\x1a"), [Key("ctrl-z")]) def test_windows_scan_codes(self): for prefix in ("\x00", "\xe0"): @@ -738,7 +740,9 @@ class Flow(unittest.TestCase): self.assertEqual(state.screen, "setup") lines = install.tui_view(state, 30, 10) self.assertEqual([line.strip() for line in lines if line.strip()], - ["Make the window larger"]) + ["Make the window larger:", "40x12 or more, now 30x10"]) + self.assertEqual(len(install.tui_view(state, 39, 1)), 1) + self.assertEqual(install.tui_view(state, 30, 0), []) class PlanWarnings(unittest.TestCase): @@ -4218,6 +4222,9 @@ class Pty: if pid: while self._pump(0.05) and select.select([self.fd], [], [], 0)[0]: pass + # The modes the run left the terminal in: the master reads + # the slave's. + self.modes = termios.tcgetattr(self.fd) os.close(self.fd) self.status = os.WEXITSTATUS(status) return self.status @@ -4859,7 +4866,7 @@ class EndToEnd(unittest.TestCase): screen = term.wait("> Another setup or folder") self.assertIn("B/Esc Quit", screen.splitlines()[29]) term.resize(30, 10) - term.wait("Make the window larger") + term.wait("40x12 or more, now 30x10") term.resize(40, 12) term.wait("Where do you play?") term.send("q") @@ -4879,13 +4886,272 @@ class EndToEnd(unittest.TestCase): self.env.pop(name) def test_dumb_terminal_writes_no_escape(self): + """TERM unset printed the banner bold and dim, and redrew the + progress with CSI, where the screen itself read it as dumb.""" self.repo() - self.env["TERM"] = "dumb" + for value in ("dumb", None): + with self.subTest(term=value): + if value is None: + self.env.pop("TERM", None) + else: + self.env["TERM"] = value + term = self.start() + term.wait("Enter) install everything") + term.send("\r") + term.wait("installed") + term.close() + self.assertNotIn(b"\x1b", term.raw) + + def test_a_terminal_that_cannot_draw_the_screen_gets_the_questions(self): + """TERM=vt52 moves the cursor with ESC Y: the screen opened and drew + CSI sequences it printed as text.""" + self.repo() + self.env["TERMINFO"] = str(_terminfo_folder(self)) + for name in ("rbtest-glass", "rbtest-vt52"): + with self.subTest(term=name): + self.env["TERM"] = name + term = self.start() + term.wait("Enter) install everything") + term.send("q\r") + term.close() + self.assertNotIn(b"\x1b[?1049h", term.raw) + term = self.start("--tui") + self.assertEqual(term.close(), 2) + said = term.raw.decode() + self.assertIn(f"--tui cannot open the full-screen interface here: TERM={name}", + said) + self.assertNotIn("\x1b", said) + + def test_no_color_draws_no_colour(self): + """The screen marks the row in focus with > as well as reverse video, + and draws no colour at all: NO_COLOR has nothing left to remove.""" + self.repo() + self.env["NO_COLOR"] = "1" term = self.start() - term.wait("Enter) install everything") - term.send("q\r") - term.close() - self.assertNotIn(b"\x1b", term.raw) + screen = term.wait("Where do you play?") + self.assertIn("> RetroArch", screen) + term.send("q") + self.assertEqual(term.close(), 130) + for params in re.findall(rb"\x1b\[([0-9;]*)m", term.raw): + codes = {int(code) for code in params.split(b";") if code} + self.assertFalse(codes & {*range(30, 50), *range(90, 108)}, params) + + +def _terminfo_entry(name: str, cup: str | None, extended: bool = False) -> bytes: + """A compiled term(5) entry holding the strings up to cursor_address, + only that one set.""" + def short(value: int) -> bytes: + return value.to_bytes(2, "little", signed=True) + + names = name.encode() + b"|test terminal\0" + table = (cup.encode("latin-1") + b"\0") if cup is not None else b"" + offsets = [-1] * (install._TERMINFO_CUP + 1) + if cup is not None: + offsets[install._TERMINFO_CUP] = 0 + body = names + (b"\0" if len(names) % 2 else b"") + # One number, columns, in the width of the format. + body += (80).to_bytes(4 if extended else 2, "little") + body += b"".join(short(offset) for offset in offsets) + table + header = b"".join(short(value) for value in ( + 0o1036 if extended else 0o432, len(names), 0, 1, len(offsets), len(table))) + return header + body + + +def _terminfo_folder(test: unittest.TestCase) -> Path: + """A $TERMINFO with a glass terminal, a VT52 and two ANSI terminals, + one in the extended format.""" + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + folder = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + test.addCleanup(shutil.rmtree, folder, True) + for name, cup, extended in ( + ("rbtest-glass", None, False), + ("rbtest-vt52", "\x1bY%p1%' '%+%c%p2%' '%+%c", False), + ("rbtest-ansi", "\x1b[%i%p1%d;%p2%dH", False), + ("rbtest-ansi2", "\x1b[%i%p1%d;%p2%dH", True)): + (folder / "r").mkdir(exist_ok=True) + (folder / "r" / name).write_bytes(_terminfo_entry(name, cup, extended)) + return folder + + +class TerminalsTheScreenCannotDraw(unittest.TestCase): + """The screen writes CSI sequences: a terminal whose description moves + the cursor otherwise, or not at all, gets the line questions.""" + + def setUp(self): + self.master, self.slave = os.openpty() + self.addCleanup(os.close, self.master) + self.addCleanup(os.close, self.slave) + fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + self.tty = StartConditions._Tty(self.slave) + self.env = {"TERMINFO": str(_terminfo_folder(self))} + + def blocker(self, term: str) -> str: + return install.tui_blocker(_args(), self.tty, self.tty, {**self.env, "TERM": term}) + + def test_the_cursor_address_is_read(self): + self.assertEqual(install.terminal_cursor_address("rbtest-ansi", self.env), + "\x1b[%i%p1%d;%p2%dH") + self.assertEqual(install.terminal_cursor_address("rbtest-ansi2", self.env), + "\x1b[%i%p1%d;%p2%dH") + self.assertEqual(install.terminal_cursor_address("rbtest-glass", self.env), "") + self.assertIsNone(install.terminal_cursor_address("rbtest-none", self.env)) + + def test_ansi_opens_and_the_others_say_why(self): + self.assertEqual(self.blocker("rbtest-ansi"), "") + self.assertEqual(self.blocker("rbtest-ansi2"), "") + self.assertEqual(self.blocker("rbtest-glass"), + "TERM=rbtest-glass cannot move the cursor") + self.assertIn("escape sequences", self.blocker("rbtest-vt52")) + + def test_no_description_is_no_refusal(self): + """Android and most handheld images carry no terminfo: the screen + draws there as before.""" + self.assertEqual(self.blocker("rbtest-none"), "") + self.assertEqual(self.blocker("../r/rbtest-glass"), "") + + def test_the_folders_are_searched_in_order(self): + home = Path(self.env["TERMINFO"]) + (home / ".terminfo" / "r").mkdir(parents=True) + (home / ".terminfo" / "r" / "rbtest-ansi").write_bytes( + _terminfo_entry("rbtest-ansi", None)) + # $TERMINFO first, then ~/.terminfo, then $TERMINFO_DIRS. + self.assertTrue(install.terminal_cursor_address( + "rbtest-ansi", {**self.env, "HOME": str(home)})) + self.assertEqual(install.terminal_cursor_address( + "rbtest-ansi", {"HOME": str(home), "TERMINFO_DIRS": self.env["TERMINFO"]}), "") + # macOS files a terminal under the hex of its first letter. + (home / "72").mkdir() + (home / "72" / "rbtest-hex").write_bytes(_terminfo_entry("rbtest-hex", None)) + self.assertEqual(install.terminal_cursor_address( + "rbtest-hex", {"TERMINFO_DIRS": str(home)}), "") + + def test_the_line_questions_draw_no_escape_there_either(self): + """The progress line redraws with CSI and paints with SGR: on a VT52 + both print as text, as on TERM=dumb.""" + for term, live in (("rbtest-ansi", True), ("rbtest-vt52", False), + ("rbtest-glass", False)): + with self.subTest(term=term), mock.patch.dict( + os.environ, {**self.env, "TERM": term}), \ + mock.patch.object(install, "_enable_ansi", return_value=True): + os.environ.pop("NO_COLOR", None) + shown = install.Terminal(self.tty) + self.assertEqual((shown.live, shown.colour), (live, live)) + self.assertEqual(shown.ok("ok") == "ok", not live) + + def test_an_unset_term_is_a_dumb_one(self): + with mock.patch.dict(os.environ, {"FORCE_COLOR": ""}), \ + mock.patch.object(install, "_enable_ansi", return_value=True): + for name in ("TERM", "NO_COLOR"): + os.environ.pop(name, None) + shown = install.Terminal(self.tty) + self.assertEqual((shown.live, shown.colour), (False, False)) + with mock.patch.object(install.os, "name", "nt"): + self.assertTrue(install.Terminal(self.tty).live) + + def test_a_damaged_description_is_no_description(self): + for data in (b"", b"\x1a\x01", b"\x00" * 12, _terminfo_entry("x", "\x1b[H")[:-1]): + with self.subTest(data=data): + self.assertIsNone(install.terminfo_string(data, install._TERMINFO_CUP)) + + +@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"), + "screen tests need pyte (pip install pyte==0.8.2) and a pty") +class TheTerminalComesBack(unittest.TestCase): + """However the screen ends or pauses, the terminal is handed back as it + was lent: the cursor shown, the main screen, echo, lines and signals.""" + + repo = EndToEnd.repo + start = EndToEnd.start + + # What a new pseudo-terminal has, which the child started on. + @staticmethod + def fresh_modes() -> list: + master, slave = os.openpty() + try: + return termios.tcgetattr(slave) + finally: + os.close(master) + os.close(slave) + + def assertGivenBack(self, term: Pty, since: int = 0) -> None: + raw = term.raw[since:] + self.assertGreater(raw.rfind(b"\x1b[?1049l"), raw.rfind(b"\x1b[?1049h"), raw[-200:]) + self.assertGreater(raw.rfind(b"\x1b[?25h"), raw.rfind(b"\x1b[?25l"), raw[-200:]) + + def assertModesBack(self, modes: list) -> None: + fresh = self.fresh_modes() + for index, flag in ((3, termios.ECHO), (3, termios.ICANON), (3, termios.ISIG), + (3, termios.IEXTEN), (0, termios.ICRNL), (0, termios.IXON)): + self.assertEqual(modes[index] & flag, fresh[index] & flag, (index, flag)) + self.assertEqual(modes, fresh) + + def ended(self, how) -> Pty: + self.repo() + term = self.start() + term.wait("Where do you play?") + how(term) + return term + + def test_ctrl_c(self): + term = self.ended(lambda term: term.send("\x03")) + self.assertEqual(term.close(), 130) + self.assertGivenBack(term) + self.assertModesBack(term.modes) + + def test_eof_on_stdin(self): + """Ctrl-D, the terminal's end of input, typed at the screen.""" + term = self.ended(lambda term: term.send("\x04")) + self.assertEqual(term.close(), 130) + self.assertGivenBack(term) + self.assertModesBack(term.modes) + + def test_sigterm(self): + import signal + + term = self.ended(lambda term: os.kill(term.pid, signal.SIGTERM)) + self.assertEqual(term.close(), 128 + signal.SIGTERM) + self.assertGivenBack(term) + self.assertModesBack(term.modes) + + def test_sighup(self): + """The hangup without the window gone (kill -HUP, an ssh session + dropped while its pty lingers).""" + import signal + + term = self.ended(lambda term: os.kill(term.pid, signal.SIGHUP)) + self.assertEqual(term.close(), 128 + signal.SIGHUP) + self.assertGivenBack(term) + self.assertModesBack(term.modes) + + def test_ctrl_z_then_fg(self): + """ISIG is off on the screen, so Ctrl-Z reached it as a byte and did + nothing: the shell never got its terminal back.""" + import signal + + term = self.ended(lambda term: None) + mark = len(term.raw) + term.send("\x1a") + end, stopped = time.monotonic() + 10, False + while time.monotonic() < end and not stopped: + term._pump(0.05) + pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) + stopped = bool(pid) and os.WIFSTOPPED(status) + self.assertTrue(stopped, term.text()) + self.assertGivenBack(term, mark) + self.assertModesBack(termios.tcgetattr(term.fd)) + # fg: the shell's SIGCONT, and the screen is back, raw again. + mark = len(term.raw) + os.kill(term.pid, signal.SIGCONT) + end = time.monotonic() + 5 + while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end: + term._pump(0.05) + self.assertIn(b"\x1b[?1049h", term.raw[mark:]) + term.wait("Where do you play?") + self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO) + term.send("q") + self.assertEqual(term.close(), 130) + self.assertGivenBack(term) + self.assertModesBack(term.modes) class TheFolderScreenSaysWhatItOffers(unittest.TestCase): @@ -6547,5 +6813,64 @@ class CardBoundaries(unittest.TestCase): self.assertIn("/", points) +class EdgesTheMutantsFound(unittest.TestCase): + """Changes to the code that every test let through: the survivors of a + mutmut run over the device filter, the card bounds and the WSL paths.""" + + def test_a_folder_below_a_drive_is_named_with_backslashes(self): + """Only the drive itself was ever shown: D:\\Games/bios passed.""" + for root in ("/mnt", "/mnt/", "/windows", "/drvX/"): + with self.subTest(root=root), mock.patch.dict(install._WSL, + {"active": True, "root": root}): + base = root.rstrip("/") + self.assertEqual(install.shown_path(Path(f"{base}/d/Games/bios")), + "D:\\Games\\bios") + self.assertEqual(install.shown_path(Path(f"{base}/e")), "E:\\") + + def test_every_path_option_is_read_through_drvfs(self): + """--cores-dir G:\\ under WSL stayed a relative path named G:.""" + with mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}): + args = _args(dest=Path("D:\\bios"), save_dir=Path("F:\\saves"), + cores_dir=Path("G:\\RetroArch\\cores")) + install._wsl_dest(args) + self.assertEqual((args.dest, args.save_dir, args.cores_dir), + (Path("/mnt/d/bios"), Path("/mnt/f/saves"), + Path("/mnt/g/RetroArch/cores"))) + + def card(self, system: str) -> Path: + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + make_card(root / "card", system) + return root / "card" + + def test_a_card_is_looked_for_three_folders_up_and_no_further(self): + card = self.card("KNULLI") + setup = install.card_setup("KNULLI", "knulli") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + self.assertEqual(install._card_above(setup, card / "a" / "b" / "c"), + ("own", "KNULLI", card)) + self.assertIsNone(install._card_above(setup, card / "a" / "b" / "c" / "d")) + + def test_a_minui_root_is_looked_for_two_folders_up_and_no_further(self): + card = self.card("MinUI") + self.assertEqual(install.card_root_above(card / "Bios" / "GBA"), ("minui", card)) + self.assertIsNone(install.card_root_above(card / "Bios" / "GBA" / "x")) + self.assertIsNone(install.card_root_above(card)) + + def test_the_filter_compares_the_letters_alone(self): + self.assertEqual(install._folded("Pokémon Évolution"), "pokemon evolution") + self.assertFalse(install._matches("Odroid", "x")) + + def test_a_letter_no_name_holds_finds_nothing(self): + with mock.patch.dict(install._SETUP_DEVICES, {"batocera": ("Odroid",), + "Onion": ("Miyoo Mini",)}): + self.assertIsNone(install._setup_matches("batocera", ["Onion"], "x")) + self.assertEqual(install._setup_matches("batocera", ["Onion"], "mini"), + ["Onion"]) + self.assertEqual(install._setup_matches("batocera", ["Onion"], "odroid"), + ["Odroid"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_site_r2_wiki.py b/tests/test_site_r2_wiki.py index c442a9a17..3ceac2a95 100644 --- a/tests/test_site_r2_wiki.py +++ b/tests/test_site_r2_wiki.py @@ -376,6 +376,8 @@ class InstallerOptionsAtAGlance(unittest.TestCase): with self.subTest(option=flag): self.assertIn(f"[`{flag}`](#option-{flag.lstrip('-')})", listed) self.assertNotIn(" + | Option | Effect | |--------|--------| | `--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. Unknown names are refused with the closest ones | @@ -740,6 +757,8 @@ what the options name, everything by default. | `--add-app` | After the install, put the RetroBIOS app on the handheld card the BIOS folder is on (muOS, KNULLI, ROCKNIX), to check and update the BIOS files from the console later. Refused (code 2) for a folder on no such card, and with `--check`, `--dry-run`, a listing, `--from-pack` and `--offline`. See [On a handheld](#on-a-handheld-the-retrobios-port) | | `--help`, `-h` | Print the option list, a few examples in the form the run was started with, and the address of this page | + + `--check` reads the same file list, hashes the destination and exits, with code 1 when a file is missing or does not match and 0 when every one does. Files with a wrong hash are listed first and in full, then the missing ones whose @@ -1152,6 +1171,8 @@ same way. Any launcher can set it to another number of seconds. Each launcher tells the interface which buttons reach it, in `RETROBIOS_PAD`, so the hints can name them: + + | Value | Input | Set by | |-------|-------|--------| | `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port and the Tools entry | @@ -1160,8 +1181,31 @@ Each launcher tells the interface which buttons reach it, in | `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port | | `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet | + + A launcher with no mapper at all (a keyboard) leaves it unset. +The help screen (`?`) names the keys of the route in use. A computer gets +the keyboard column, and SteamOS game mode or a console no launcher named +gets the column that names both: + + + +| Action | Computer (`keys`) | Unknown (`both`) | `gptokeyb` | `gptokeyb_muxterm` | `muxterm` | `evmapy` | `miyoo` | +|--------|-------------------|------------------|------------|--------------------|-----------|----------|---------| +| Move | Up/Down, k/j | Up/Down, k/j | D-pad, Up/Down | D-pad, Up/Down | D-pad, Up/Down | D-pad, Up/Down | D-pad, Up/Down | +| Page | PgUp/PgDn | PgUp/PgDn, L1/R1 | L1/R1, PgUp/PgDn | D-pad Left/Right | D-pad Left/Right | L1/R1, PgUp/PgDn | L1/R1, PgUp/PgDn | +| Continue, open, choose | Enter | A/Enter, Start | A, Start, Enter | A, Start, Enter | Start, Enter | A, Start, Enter | A, Start, Enter | +| Tick, untick | Space, Right/Left | X, Right/Left | X, Space, Right/Left | X, Space | Y, Space | X, Space, Right/Left | A, Space, Right/Left | +| Options | o, Tab | Y, o, Tab | Y, Tab, o | Y, Tab, o | o, Tab | Y, Tab, o | L2, Tab, o | +| Back, and quit on the first screen | Esc, Backspace | B/Esc, Backspace | B, Select, Esc | B, Select, Esc | B, Esc, Backspace | B, Select, Esc | R2, Esc, Backspace | +| Stop an install | Esc, Ctrl-C | B/Esc, Ctrl-C | B, Esc, Ctrl-C | B, Esc, Ctrl-C | B, Esc, Ctrl-C | B, Esc, Ctrl-C | R2, Esc, Ctrl-C | +| Quit at once | none | Start+Select | Select, then Start | Select, then Start | none | none | none | +| Use the folder typed | Enter | Enter | A, Enter | A, Enter | Start, Enter | A, Enter | Start, Enter | +| Clear a filter, leave the folder typed | Esc | Esc | B, Esc | B, Esc | Esc | B, Esc | Esc | + + + On a Miyoo, B, X, Y and Select are Left Ctrl, Shift, Alt and Right Ctrl: pressed alone, a modifier sends nothing to a terminal, so the interface goes back with R2 instead, and the bottom line says so. @@ -1326,6 +1370,14 @@ The build scripts need 3.9 and are not bound by this. Every change to `install.py` recomputes the SHA-256 pinned in `install.sh` and `install.ps1` in the same commit. The tests compare the pins with the file. +The tables of options, events, exit codes and keys on this page sit between +`generated` markers and are written from `install.py` by +`scripts/generate_installer_doc.py`: the options from its parser, the events +from `EVENT_KEYS`, the codes from its `sys.exit` calls, the keys from +`TUI_ROUTES`. The prose cells stay as written. A row the code gains stops the +script until its text is added inside the markers, and `--check`, which a +test runs, fails on a table behind the code. + The [handheld](#on-a-handheld-the-retrobios-port) archives each carry `install.py`, the one the one-line command of the same commit downloads: `scripts/build_port.py` refuses any other (it compares it with the SHA-256 @@ -1340,6 +1392,8 @@ fields, the installer refuses to narrow rather than install everything. ## Exit status + + | Code | Meaning | |------|---------| | `0` | everything asked for is installed or already up to date | @@ -1348,6 +1402,8 @@ fields, the installer refuses to narrow rather than install everything. | `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. Run the same command again to carry on | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | + + The full screen ended by SIGTERM (Start+Select on a handheld) before any install exits `143`. During an install it stops the install first, and exits `130`. Its window or terminal closed (SIGHUP) exits `129`, during an install @@ -1376,11 +1432,13 @@ the text a person reads goes to standard error. A program driving the installer (the [handheld port](#on-a-handheld-the-retrobios-port) is one) reads the objects and never parses the text. Every object has an `event` key naming its kind, and each kind has exactly the keys below, in this order. The schema is -`EVENT_KEYS` in `install.py`, and a test holds this table to it. The same +`EVENT_KEYS` in `install.py`, and this table is written from it. The same keys, with their types, are in `schemas/install-events.schema.json`, written from `EVENT_KEYS` by `scripts/generate_event_schema.py` and published with the other schemas under `api/v1/schemas/`. + + | Kind | Keys | When | |------|------|------| | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | @@ -1388,9 +1446,9 @@ other schemas under `api/v1/schemas/`. | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | | `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | -| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place | | `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | | `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it | +| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place | | `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply | | `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` | | `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place | @@ -1405,6 +1463,8 @@ other schemas under `api/v1/schemas/`. | `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) | | `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) | + + Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees), never a key of an existing kind: a reader skips a kind it does not know. An option refused before the run starts (`install.py: error:`, code 2) writes @@ -1435,7 +1495,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only | | `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters. When either of these two is set and the fingerprint does not match, the error names the variable instead of suggesting a retry | | `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, so that its hints give back the one-line command rather than `python3 install.py` | -| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb` also turns the live progress line off | +| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb`, or no `TERM` outside Windows, also turns the live progress line and the colour off, as does a terminal whose description moves no cursor the ANSI way (`TERM=vt52`). The full screen draws no colour at all: the row in focus is marked with `>` as well as reverse video, and every state is written in words | | `FORCE_COLOR`, `CLICOLOR_FORCE` | Colour even when the output is a pipe or a file, set to any value but `0` or an empty one. `NO_COLOR` wins over both, and the progress line stays one line per file | | `SSL_CERT_FILE`, `SSL_CERT_DIR` | The trusted certificates to check servers with, for a Python that finds none of its own | | `RETROBIOS_PLAIN`, `ACCESSIBLE` | Set to anything but `0`: the questions come line by line, as with `--plain`, instead of the full-screen interface | diff --git a/wiki/tools.md b/wiki/tools.md index abff8b34c..9a3678f37 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -796,6 +796,9 @@ python scripts/sync_measurements.py # write the site's measure python scripts/sync_measurements.py --clone DIR --pin FILE # another clone or pin file ``` +`--clone` and `--pin` name another clone and pin file than +`.cache/measurements/` and `measurements-pin.json`. + `--fetch` is the only step on the network. It refuses a remote whose history changed or removed a line the pin holds, or rewrote a data file: a correction is a new line naming the record it `supersedes`. Every line must hash to its