From 9911321227fca5a5b5a5c70129fb9cdc4d33a921 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:28:01 +0100 Subject: [PATCH] fix: a card is told by what its system writes A drive is a handheld's games part only when it holds the folders ArkOS, AmberELEC or EmuELEC write there, and never a disk Windows or Linux calls fixed. A data drive or a stick with a bios folder is listed with a note, never taken under Enter. Two cards are named in the plural, and the settle of a new screen runs from when it is drawn. --- install.ps1 | 2 +- install.py | 172 +++++++++++++++++++---- install.sh | 2 +- install/launchers/index.json | 16 +-- tests/test_install_tui.py | 255 ++++++++++++++++++++++++++++++++++- wiki/installer.md | 19 ++- 6 files changed, 416 insertions(+), 50 deletions(-) diff --git a/install.ps1 b/install.ps1 index eaf69702f..69c62f8da 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # 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" + $defaultInstallSha256 = "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 47ddc9a84..19c12cc04 100644 --- a/install.py +++ b/install.py @@ -7249,37 +7249,137 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: 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"}) +# What a handheld system writes on the games part a computer mounts, beside +# its bios folder: a drive holding these is that card, and a data drive or +# a stick with an emulation library holds none of them (code_audit_22 P2-1). +# Each set is (system, folders and files that must all be there). +_GAMES_PART_MARKERS = ( + # ArkOS and dArkOS mount EASYROMS as /roms. dArkOS daecf488 creates every + # folder of game_systems.txt on it (finishing_touches.sh:420-430), + # themes (game_systems.txt:108) and launchimages (game_systems.txt:54), + # and copies loading.jpg there (finishing_touches.sh:471-472). ArkOS + # dedf4278 updates both in place (Update-RG351MP.sh:1003,1443). + ("ArkOS", ("themes", "launchimages")), + # AmberELEC 5a3882cb fills GAMES at every boot: homebrew/ from its ports + # with gamelist.xml (packages/amberelec/sources/autostart.sh:93-94, + # 110-113) and gamedata/ for RetroArch, PPSSPP, DOSBox and Hatari + # (autostart.sh:123-145). + ("AmberELEC", ("gamedata", "homebrew")), + # EmuELEC d4c7b2f1 copies its default bezels to EEROMS at every boot + # (packages/sx05re/emuelec/bin/emuelec_autostart.sh:78-81). + ("EmuELEC", ("bezels/default.cfg",)), +) + +# GetDriveTypeW: DRIVE_FIXED, DRIVE_REMOTE, DRIVE_CDROM and DRIVE_RAMDISK, +# none of them a card in a reader. +_NOT_REMOVABLE_TYPES = (3, 4, 5, 6) + + +def _drive_not_removable(mount: Path) -> bool: + """Whether the system says mount is a drive no card sits in: a fixed + disk, a network drive or a disc on Windows (GetDriveTypeW), a whole + partition of a disk the kernel calls fixed elsewhere (_fixed_partition). + False where it says nothing.""" + if os.name == "nt": + try: + import ctypes # noqa: PLC0415 + kind = ctypes.windll.kernel32.GetDriveTypeW(str(mount)) # type: ignore[attr-defined] + except (AttributeError, OSError): + return False + return kind in _NOT_REMOVABLE_TYPES + try: + text = Path("/proc/self/mountinfo").read_text(encoding="utf-8", errors="replace") + except OSError: + return False + return _fixed_partition(mount, text, Path("/sys")) + + +def _fixed_partition(mount: Path, mountinfo: str, sys_root: Path) -> bool: + """Whether mountinfo mounts a whole partition at mount whose disk sysfs + calls fixed: removable at 0, neither on USB nor an MMC card. False for + a folder bound in place, which is no whole drive, and for WSL's + /mnt/d, a Windows drive Linux cannot classify.""" + for line in mountinfo.splitlines(): + fields = line.split(" ") + if len(fields) < 5: + continue + point = re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), fields[4]) + if point != str(mount): + continue + # Field 4 is the folder of the filesystem mounted there: "/" for a + # whole partition, a folder for a bind mount (proc(5)). + device = sys_root / "dev" / "block" / fields[2] + if fields[3] != "/" or not _exists(device): + return False + try: + real = device.resolve() + disk = real.parent if _exists(real / "partition") else real + if "/usb" in real.as_posix() or disk.name.startswith("mmcblk"): + return False + return (disk / "removable").read_text(encoding="ascii").strip() == "0" + except OSError: + return False + return False + + +def _games_part_system(mount: Path) -> str: + """The handheld system whose games part mount is, as the files it + writes there say, "" for none (_GAMES_PART_MARKERS).""" + if not _is_dir(mount / "bios"): + return "" + return next((name for name, markers in _GAMES_PART_MARKERS + if all(_exists(mount / marker) for marker in markers)), "") + + +def _unnamed_candidates() -> list[Path]: + """The mounted drives no file names, no SHARE either, holding a bios + folder: a handheld's card, or any drive someone keeps an emulation + library on.""" + return [mount for mount in _mounted_cards() + if card_system(mount) is None and not share_doubt(mount)[0] + and _is_dir(mount / "bios")] 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 + """The mounted drives no file names that are a handheld's games part: + ArkOS's EASYROMS, AmberELEC's GAMES, EmuELEC's EEROMS, each told by the + folders its system writes there (_GAMES_PART_MARKERS), on a drive the + system does not call fixed. Which of the systems that read RetroArch's + list runs them cannot be told, 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). A bios folder and a console folder were the rule, and a data + drive or WSL's D: disk with bios/, snes/ and Music/ took the list + (code_audit_22 P2-1).""" + return [mount for mount in _unnamed_candidates() + if _games_part_system(mount) and not _drive_not_removable(mount)] + + +def unproven_drives() -> list[Path]: + """The drives with a bios folder nothing shows to be a handheld's card: + offered at the folder question, never its default.""" + cards = unnamed_cards() + return [mount for mount in _unnamed_candidates() if mount not in cards] + + +def unproven_words() -> str: + """The plain note on those drives, "" for none.""" + drives = unproven_drives() + if not drives: + return "" + names = ", ".join(_mount_name(mount) for mount in drives) + has, it = ("has", "it") if len(drives) == 1 else ("have", "them") + return (f"{names} {has} a bios folder, and nothing on {it} shows a handheld's card: " + "listed, not chosen.") def unnamed_card_words(cards: list[Path], tail: str = ".") -> str: - """What the first screen and line mode say of those cards.""" + """What the first screen and line mode say of those cards: one card, + its system, two cards, their systems (code_audit_22 P3-2).""" 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}" + if len(cards) == 1: + return f"A card is in the reader ({names}): choose its system{tail}" + return f"Cards are in the reader ({names}): choose their systems{tail}" def say_unnamed_cards() -> None: @@ -7351,8 +7451,10 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, 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)}.") + # Two cards: the one Enter takes is named, "The card" was one of + # two (code_audit_22 P3-2). + which = "The card in the reader" if len(cards) == 1 else _mount_name(cards[0]) + print(f" {which} keeps them in its bios folder: {shown_path(default)}.") elif found is not None: print(f" {name} reads its BIOS files from: {shown_path(found)}.") elif default is not None: @@ -7381,6 +7483,9 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, left_out = "" if shared else drives_left_out(plat) if left_out: print(f" {left_out}") + unproven = "" if shared else unproven_words() + if unproven: + print(f" {unproven}") numbers = "" if not targets else (" or 1" if len(targets) == 1 else f" or 1 to {len(targets)}") why = ("" if default is not None or shared @@ -18012,7 +18117,11 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: 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. " + cards = [mount for mount in state.unnamed + if mount / "bios" == tui_rows(state)[0].value] + which = ("The card in the reader" if len(state.unnamed) == 1 + else _mount_name(cards[0])) + before = [f"{which} keeps them in its bios folder, first below. " f"{lead}", str(state.folder_default)] elif state.folder_kind == "closed": title = "Which folder?" @@ -18023,6 +18132,8 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: if not bound and state.folder_kind != "share": left_out = drives_left_out(state.platform) before += [left_out] if left_out else [] + unproven = unproven_words() + before += [unproven] if unproven else [] return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state), pinned=state.refused), ("A/Enter Choose", "B/Esc Back", "? Help")) @@ -20155,10 +20266,17 @@ def run_tui(args: argparse.Namespace) -> int: state.now = time.monotonic() lines = tui_view(state, cols, rows) # A click is read against the frame it was made on. + before = state.hits state.hits = tui_hits(state, lines) # Before the frame: a frame on screen takes clicks as it shows. terminal.set_mouse(tui_wants_mouse(state, lines)) terminal.draw(lines, cols) + if before is None or state.hits.shown != before.shown: + # A new screen's settle runs from when it is on the screen, + # after its layout and its write: the second click of a + # double click waited out a slow layout in the input and + # acted on the screen it had never seen (code_audit_22 P3-1). + state.hits = state.hits._replace(shown=time.monotonic()) for key in terminal.keys(TUI_FRAME): if key.name == "ctrl-z": # ISIG is off, so Ctrl-Z comes as a key: it stops what diff --git a/install.sh b/install.sh index cb52eff70..57918a976 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # 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" +DEFAULT_INSTALL_SHA256="478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 4f0d7be40..742231b0e 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": 939291, - "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", + "size": 944874, + "sha256": "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 992468, - "sha256": "06761ebb41ef22fa2c4b64c5e27fab5ed5508dbcd63c5a634527d03a7cdaa7d1" + "size": 998051, + "sha256": "d39926472bdc1ef05c1c16b058b135cbd988b336e4a5d222b5f918e6663d9d32" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 939291, - "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", + "size": 944874, + "sha256": "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 939291, - "sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7", + "size": 944874, + "sha256": "478537cc1ede1d3aa9e7b579a178bd4552de2b40c512fe8c6c7b3d3b277799aa", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 58c61895f..20c9cd182 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -6756,6 +6756,21 @@ class AuditSevenScreens(unittest.TestCase): self.assertIn("1 wrong copy: backed up, replaced.", view) +def games_part(card: Path) -> Path: + """An ArkOS EASYROMS (dArkOS game_systems.txt: themes, launchimages + with loading.jpg) or an AmberELEC GAMES (autostart.sh: gamedata, + homebrew with gamelist.xml), console folders beside them.""" + marks = (("gamedata/retroarch", "homebrew") if card.name.startswith("GAMES") + else ("themes/es-theme-nes-box", "launchimages")) + for folder in ("bios", "nes", "gba", "psx", *marks): + (card / folder).mkdir(parents=True) + if card.name.startswith("GAMES"): + (card / "homebrew" / "gamelist.xml").write_text("\n") + else: + (card / "launchimages" / "loading.jpg").write_bytes(b"\xff\xd8") + return card + + class _Decided(Exception): """Raised where line mode has chosen its platforms and folders.""" @@ -12248,12 +12263,10 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): 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 + """A handheld's games part as a computer mounts it, with the folders + its system writes there (install._GAMES_PART_MARKERS): ArkOS's + EASYROMS, AmberELEC's GAMES.""" + return games_part(self.root / "media" / name) def test_an_arkos_card_is_found_and_comes_first(self): """tui_audit_14 P2-3: an EASYROMS partition mounted, "Nothing was @@ -12557,5 +12570,235 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): self.assertIn(f"BIOS folder: {card / 'bios'}", text) + +class AuditTwentyTwoAndFifteen(unittest.TestCase): + """code_audit_22 and tui_audit_15: a drive is a handheld's card only by + the files its system writes, one settle from the frame shown, one + vocabulary and one count per run, and the focus after an action.""" + + 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 + game_listing = AuditTwentyOneAndFourteen.game_listing + screen = staticmethod(AuditTwentyOneAndFourteen.screen) + + def drive(self, name: str, folders: tuple) -> Path: + root = self.root / "media" / name + for folder in folders: + (root / folder).mkdir(parents=True) + return root + + def first_screen(self, mounts: list) -> install.TuiState: + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install.Path, "home", return_value=self.root / "home"): + state, _effects = install.tui_start(_args(), "linux") + return state + + def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]: + """RetroArch chosen on the first screen: (state, Which folder? at + 80x40, its rows).""" + state = self.first_screen(mounts) + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "folder") + return state, self.screen(state, 80, 40), install.tui_rows(state) + + def test_a_data_drive_or_a_stick_is_no_card(self): + """code_audit_22 P2-1: a data drive holding bios/, snes/ and Music/ + was "a card in the reader", and three presses of Enter put the list + on it. A stick with bios/ alone is no card either: both are listed, + never under Enter, and a plain line says why.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + stick = self.drive("STICK", ("bios",)) + mounts = [data, stick] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + self.assertEqual(install.unnamed_cards(), []) + state = self.first_screen(mounts) + self.assertEqual(state.unnamed, []) + self.assertIn("Nothing was found here", self.screen(state)) + state, text, rows = self.folder_screen(mounts) + self.assertNotIn(rows[0].value, (data / "bios", stick / "bios")) + self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a " + "handheld's card: listed, not chosen.", text) + self.assertIn(data / "bios", [row.value for row in rows]) + text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, + declared_only=False), ["1", ""], mounts=mounts) + self.assertNotIn("in the reader", text) + self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a " + "handheld's card: listed, not chosen.", text) + self.assertIn("Enter for the default above", asked[-2]) + self.assertNotIn(f"BIOS folder: {data / 'bios'}", text) + + def test_the_layouts_handhelds_write_are_cards(self): + """ArkOS's EASYROMS, AmberELEC's GAMES and EmuELEC's EEROMS, each + with the folders its own image writes there.""" + arkos = games_part(self.root / "media" / "EASYROMS") + amber = games_part(self.root / "media" / "GAMES") + emuelec = self.drive("EEROMS", ("bios", "nes", "bezels")) + (emuelec / "bezels" / "default.cfg").write_text("") + mounts = [arkos, amber, emuelec] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + self.assertEqual(install.unnamed_cards(), mounts) + self.assertEqual([install._games_part_system(mount) for mount in mounts], + ["ArkOS", "AmberELEC", "EmuELEC"]) + # One of these the system calls fixed is a disk, not a card. + with mock.patch.object(install, "_drive_not_removable", return_value=True): + self.assertEqual(install.unnamed_cards(), []) + + def test_a_card_beside_a_data_drive_comes_first(self): + """code_audit_22 P2-1: the ArkOS card and the data drive mounted, + the data drive sorted first and took Enter. The card comes first in + both modes, the drive listed after it with its note.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + card = games_part(self.root / "media" / "EASYROMS") + mounts = [data, card] + state = self.first_screen(mounts) + self.assertEqual(state.unnamed, [card]) + self.assertIn("A card is in the reader (EASYROMS): choose its system", self.screen(state)) + state, text, rows = self.folder_screen(mounts) + self.assertEqual(rows[0].value, card / "bios") + self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text) + text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, + declared_only=False), ["1", ""], mounts=mounts) + self.assertIn("A card is in the reader (EASYROMS): choose its system.", 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("DATA has a bios folder, and nothing on it shows a handheld's card", text) + self.assertIn(f"BIOS folder: {card / 'bios'}", text) + + def test_two_cards_are_spoken_of_in_the_plural(self): + """code_audit_22 P3-2: "Cards are in the reader (DATA, EASYROMS): + choose its system", then "The card in the reader" of one of two.""" + arkos = games_part(self.root / "media" / "EASYROMS") + amber = games_part(self.root / "media" / "GAMES") + mounts = [arkos, amber] + state = self.first_screen(mounts) + self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems", + self.screen(state)) + state, text, rows = self.folder_screen(mounts) + self.assertIn("EASYROMS keeps them in its bios folder, first below.", text) + text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, + declared_only=False), ["1", ""], mounts=mounts) + self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems.", text) + self.assertIn(f"EASYROMS keeps them in its bios folder: {arkos / 'bios'}.", text) + self.assertNotIn("The card in the reader", text) + + def test_a_fixed_partition_is_told_from_a_card(self): + """code_audit_22 P2-1: every mount under /media and /mnt, and on + Windows every drive letter, was a card. A whole partition of a disk + sysfs calls fixed is not, a USB or MMC one is, a folder bound in + place says nothing, and Windows answers by GetDriveTypeW.""" + sys_root = self.root / "sys" + devices = sys_root / "devices" + for disk, part, removable in (("pci0/nvme0n1", "nvme0n1p2", "0"), + ("pci0/usb2/2-1/sdb", "sdb1", "0"), + ("mmc0/mmcblk0", "mmcblk0p1", "0"), + ("pci0/sdc", "sdc1", "1")): + (devices / disk / part).mkdir(parents=True) + (devices / disk / part / "partition").write_text("1\n") + (devices / disk / "removable").write_text(f"{removable}\n") + block = sys_root / "dev" / "block" + block.mkdir(parents=True) + for number, target in (("259:2", "pci0/nvme0n1/nvme0n1p2"), + ("8:17", "pci0/usb2/2-1/sdb/sdb1"), + ("179:1", "mmc0/mmcblk0/mmcblk0p1"), + ("8:33", "pci0/sdc/sdc1")): + (block / number).symlink_to(devices / target) + mountinfo = "\n".join([ + "40 1 259:2 / /media/me/DATA rw - ext4 /dev/nvme0n1p2 rw", + "41 1 8:17 / /media/me/EASYROMS rw - vfat /dev/sdb1 rw", + "42 1 179:1 / /media/me/GAMES rw - exfat /dev/mmcblk0p1 rw", + "43 1 8:33 / /media/me/STICK rw - vfat /dev/sdc1 rw", + "44 1 259:2 /home/me/cards/X /media/me/BOUND rw - ext4 /dev/nvme0n1p2 rw", + "45 1 0:74 / /mnt/d rw - 9p D:\\134 rw"]) + told = {name: install._fixed_partition(Path(point), mountinfo, sys_root) + for name, point in (("DATA", "/media/me/DATA"), ("USB", "/media/me/EASYROMS"), + ("MMC", "/media/me/GAMES"), ("STICK", "/media/me/STICK"), + ("BOUND", "/media/me/BOUND"), ("WSL", "/mnt/d"), + ("unmounted", "/media/me/NONE"))} + self.assertEqual(told, {"DATA": True, "USB": False, "MMC": False, "STICK": False, + "BOUND": False, "WSL": False, "unmounted": False}) + for kind, fixed in ((2, False), (3, True), (4, True)): + kernel = types.SimpleNamespace(GetDriveTypeW=lambda root, kind=kind: kind) + fake = types.SimpleNamespace(windll=types.SimpleNamespace(kernel32=kernel)) + with mock.patch.object(install.os, "name", "nt"), \ + mock.patch.dict(sys.modules, {"ctypes": fake}): + self.assertEqual(install._drive_not_removable(PureWindowsPath("D:\\")), fixed) + + +# install.py whose tui_view takes SLOW seconds on the first frame of each +# screen, as a slower device takes to lay one out (code_audit_22 P3-1). +SLOW_VIEW = """ +import os, sys, time +sys.argv[0] = {install!r} +sys.path.insert(0, {repo!r}) +import install +slow = float(os.environ["SLOW"]) +real, seen = install.tui_view, [None] +def view(state, cols, rows): + key = (state.screen, len(state.stack)) + if key != seen[0]: + seen[0] = key + time.sleep(slow) + return real(state, cols, rows) +install.tui_view = view +sys.exit(install.main()) +""" + + +@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"), + "screen tests need pyte (pip install pyte==0.8.2) and a pty") +class TheSettleRunsFromTheFrameShown(unittest.TestCase): + """code_audit_22 P3-1: the settle was stamped before the layout, so on + a slow one the second click of a double click on Options' Back reached + the plan's Quit.""" + + repo = EndToEnd.repo + bar_entry = TapsOnAPtySeeFirst.bar_entry + + def test_a_double_click_on_back_stays_on_the_plan_after_a_slow_layout(self): + repo = self.repo() + wrapper = repo.root / "slowview.py" + wrapper.write_text(SLOW_VIEW.format(install=str(REPO_ROOT / "install.py"), + repo=str(REPO_ROOT))) + env = {**self.env, "SLOW": "0.5"} + term = Pty([], env, 80, 24, program=[sys.executable, str(wrapper)]) + self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9)) + term.wait("Where do you play?") + term.send("\r") + term.wait("> Install") + term.settle() + term.send("o") + term.wait("RetroBIOS: Options") + term.settle() + time.sleep(1.0) + col, line = self.bar_entry(term, "Back") + report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) + os.write(term.fd, report.encode()) + time.sleep(0.2) + os.write(term.fd, report.encode()) + time.sleep(1.0) + screen = term.settle(0.6) + self.assertNotIn("Quit?", screen) + self.assertIn("RetroBIOS: RetroArch", screen.splitlines()[0]) + self.assertEqual(repo.installed(), []) + term.send("q") + if "Quit?" in term.settle(): + term.send("\r") + term.close() + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 6ef13fd26..7ea65deff 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1181,13 +1181,18 @@ 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. 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 +by its root, which no setup reads. A handheld's card whose own files sit on a +part a computer does not open is told by the folders its system writes beside +`bios` on the games part: `themes` and `launchimages` on ArkOS's `EASYROMS`, +`gamedata` and `homebrew` on AmberELEC's `GAMES`, `bezels/default.cfg` on +EmuELEC's `EEROMS`. A disk Windows or Linux calls fixed is never one. With +nothing found here the first screen and line mode say `A card is in the reader +(EASYROMS): choose its system` (two cards: `choose their systems`), and its +`bios` folder comes first at the folder question, under Enter, before this +computer's usual folder. A drive with a `bios` folder and none of those +folders, a data drive or a stick, is numbered with a note (`DATA has a bios +folder, and nothing on it shows a handheld's card: listed, not chosen.`) and +never taken under Enter. 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