fix: settle the interface audit findings

A stop by SIGTERM (Start+Select under gptokeyb) reads the summary like
Ctrl-C and prints the count on disk. The systems filter keeps the ticks
and the focus. The bar names what A does on the focused row. Options
check the folder again, a missing network fails in 15 s with plain words,
an unwritable folder offers another one, a folder inside a card offers
its root, and only a failure keeps its log.

New event kind: checking, with files_done and files_total, while the
files in place are hashed. The exact event lists of the check and
install runs now carry two of them, for the two files in place.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 16:10:09 +02:00
1 parent 48086b9966
commit 588a8d3350
68 files changed
+1012 -271

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "d0a92cac2d09da791fe3dbacdad37ec7e40b5a3685a39e3cc02f0dcbd5a556e6"
$defaultInstallSha256 = "84ba56c9498a8bf89db4c4af8bd3413871f467771b36aea36158458aa4644ef8"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+479 -137
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="d0a92cac2d09da791fe3dbacdad37ec7e40b5a3685a39e3cc02f0dcbd5a556e6"
DEFAULT_INSTALL_SHA256="84ba56c9498a8bf89db4c4af8bd3413871f467771b36aea36158458aa4644ef8"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Open B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
system/
A/Enter Open B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Open B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Open B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Done B/Esc Back X/Space Tick ? Help
A/Enter Filter B/Esc Back X/Space Tick ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Done B/Esc Back X/Space Tick
A Filter B Back X Tick ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Done B/Esc Back X/Space Tick
A Filter B Back X Tick ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Done B/Esc Back X/Space Tick ? Help
A/Enter Filter B/Esc Back X/Space Tick ? Help
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Back
A/Enter Create B/Esc Back
+3 -3
View File
@@ -4,8 +4,7 @@
known
Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/retroarch.json
> Try again
Choose another setup or folder
> Choose another setup or folder
Quit
@@ -37,4 +36,5 @@
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+6 -6
View File
@@ -3,10 +3,10 @@
Could not fetch the file list for
retroarch after 3 attempts: [Errno
-2] Name or service not known
> Try again
Choose another setup or folder
Address:
https://raw.githubusercontent.com/
Abdess/retrobios/main/install/
retroarch.json
> Choose another setup or folder
Quit
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+3 -3
View File
@@ -8,8 +8,8 @@
Abdess/retrobios/main/install/
retroarch.json
> Try again
Choose another setup or folder
> Choose another setup or folder
Quit
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+3 -3
View File
@@ -5,8 +5,7 @@
Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/
retroarch.json
> Try again
Choose another setup or folder
> Choose another setup or folder
Quit
@@ -21,4 +20,5 @@
A/Enter Choose B/Esc Back
A/Enter Back B/Esc Back
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Back ? Help
A Use this folder B Back ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Use this folder B/Esc Back ? Help
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Back ? Help
A/Enter Browse B/Esc Back ? Help
+2 -2
View File
@@ -1,8 +1,6 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
@@ -34,6 +32,8 @@
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
q, Ctrl-C Quit
?, F1 This help
BIOS folder:
/home/user/.config/retroarch/system
Any key closes this help
+2 -2
View File
@@ -1,8 +1,6 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
@@ -12,4 +10,6 @@
BIOS folder:
/home/user/.config/retroarch/system
Any key closes this help
+2 -2
View File
@@ -1,8 +1,6 @@
RetroBIOS: Keys
Up/Down, k/j Move
PgUp/PgDn, L1/R1 Page
Home/End, g/G First, last
A/Enter, Start Install
Y, o, Tab Options
B/Esc, Backspace Back
@@ -21,4 +19,6 @@
Any key closes this help
+2 -2
View File
@@ -4,7 +4,7 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
@@ -37,4 +37,4 @@
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+3 -3
View File
@@ -4,9 +4,9 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What
is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+3 -3
View File
@@ -4,12 +4,12 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What
is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+2 -2
View File
@@ -4,7 +4,7 @@
528.0 KB of 1.0 MB
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
B, Ctrl-C or Start+Select stops. What is done stays done.
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
@@ -21,4 +21,4 @@
Ctrl-C / Start+Select stops
B/Esc Stop ? Help
+2 -2
View File
@@ -2,7 +2,7 @@
Each row is an option of the command line, named on the right.
> Preferred regions: north-america, japan --region
> Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data
Installed cores only: no (1 found) --installed-cores
Cores: all --core
@@ -37,4 +37,4 @@
A/Enter Change B/Esc Back Left/Right Set ? Help
A/Enter Open B/Esc Back Left/Right Set ? Help
+3 -3
View File
@@ -1,12 +1,12 @@
RetroBIOS: Options
> Preferred regions: north-america, ja
> Preferred regions: North America, Ja
Leave out game data: no
Installed cores only: no (1 found)
Cores: all
Parallel downloads: 8
BIOS folder: ~/.config/retroarch/sys
BIOS folder: ~/.config...ch/system
Preview the file list
Show the command line
A/Enter Change B/Esc Back
A Open B Back <> Set ? Help
+3 -3
View File
@@ -1,15 +1,15 @@
RetroBIOS: Options
> Preferred regions: north-america, ja
> Preferred regions: North America, Ja
Leave out game data: no
Installed cores only: no (1 found)
Cores: all
Parallel downloads: 8
BIOS folder: ~/.config/retroarch/sys
BIOS folder: ~/.config...ch/system
Preview the file list
Show the command line
Sets --region
A/Enter Change B/Esc Back
A Open B Back <> Set ? Help
+2 -2
View File
@@ -2,7 +2,7 @@
Each row is an option of the command line, named on the right.
> Preferred regions: north-america, japan --region
> Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data
Installed cores only: no (1 found) --installed-cores
Cores: all --core
@@ -21,4 +21,4 @@
A/Enter Change B/Esc Back Left/Right Set ? Help
A/Enter Open B/Esc Back Left/Right Set ? Help
+2 -2
View File
@@ -3,7 +3,7 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables are expanded.
Ctrl-U clears. Quotes are removed, ~ and variables are expanded.
@@ -37,4 +37,4 @@
Enter Use this folder Esc Back
Enter Use this folder Esc Back Ctrl-U Clear
+2 -2
View File
@@ -3,8 +3,8 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables
are expanded.
Ctrl-U clears. Quotes are removed, ~
and variables are expanded.
+2 -2
View File
@@ -3,8 +3,8 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables
are expanded.
Ctrl-U clears. Quotes are removed, ~
and variables are expanded.
+2 -2
View File
@@ -3,7 +3,7 @@
The folder for RetroArch:
> ~/bios_
Quotes are removed, ~ and variables are expanded.
Ctrl-U clears. Quotes are removed, ~ and variables are expanded.
@@ -21,4 +21,4 @@
Enter Use this folder Esc Back
Enter Use this folder Esc Back Ctrl-U Clear
+1 -1
View File
@@ -9,4 +9,4 @@
Choose systems
Options
Quit
A/Enter Install B/Esc Back
A Install B Back Y Options ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
Choose systems
Options
Quit
A/Enter Install B/Esc Back
A Install B Back Y Options ? Help
+1 -1
View File
@@ -9,4 +9,4 @@
> Close
Options
A/Enter Close B/Esc Back Y/o Options
A Close B Back Y Options ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Close B/Esc Back Y/o Options
A Close B Back Y Options ? Help
+2 -2
View File
@@ -3,7 +3,7 @@
Choose the setup that will use the BIOS files.
Filter (/ to type)
> RetroArch
> RetroArch muOS, JELOS, KOLOS, Uno...Anbernic stock firmware
Batocera
Recalbox
RetroBat
@@ -15,7 +15,7 @@
BizHawk
MiSTer FPGA
RetroPie
AmberELEC
AmberELEC PAN4ELEC
EmuELEC
KNULLI
MinUI
+3 -3
View File
@@ -1,12 +1,12 @@
RetroBIOS: Which setup?
Filter (/ to type)
> RetroArch
> RetroArch muOS, JE...firmware
Batocera
Recalbox
RetroBat
EmuDeck
Lakka
RetroDECK
1-8 of 19
A/Enter Choose B/Esc Back / Filter
1-7 of 18
A Choose B Back / Filter ? Help
+3 -3
View File
@@ -1,7 +1,7 @@
RetroBIOS: Which setup?
Filter (/ to type)
> RetroArch
> RetroArch muOS, JE...firmware
Batocera
Recalbox
RetroBat
@@ -11,5 +11,5 @@
ROCKNIX
RomM
BizHawk
1-11 of 19
A/Enter Choose B/Esc Back / Filter
1-10 of 18
A Choose B Back / Filter ? Help
+2 -2
View File
@@ -3,7 +3,7 @@
Choose the setup that will use the BIOS files.
Filter (/ to type)
> RetroArch
> RetroArch muOS, JELOS, KOLOS...nic stock firmware
Batocera
Recalbox
RetroBat
@@ -15,7 +15,7 @@
BizHawk
MiSTer FPGA
RetroPie
AmberELEC
AmberELEC PAN4ELEC
EmuELEC
KNULLI
MinUI
+1 -1
View File
@@ -37,4 +37,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+1 -1
View File
@@ -9,4 +9,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+1 -1
View File
@@ -12,4 +12,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+1 -1
View File
@@ -21,4 +21,4 @@
A/Enter Choose B/Esc Stay
A/Enter Quit B/Esc Stay
+2 -2
View File
@@ -3,8 +3,8 @@
Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found
is installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
+3 -3
View File
@@ -5,8 +5,8 @@
region, the best one found is
installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
A/Enter Tick B/Esc Done
A Tick B Done <> Order ? Help
+3 -3
View File
@@ -5,11 +5,11 @@
region, the best one found is
installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
A/Enter Tick B/Esc Done
A Tick B Done <> Order ? Help
+2 -2
View File
@@ -3,8 +3,8 @@
Tick the regions to keep, best first. Where a system has one BIOS per region,
the best one found is installed.
> [1] north-america
[ ] japan
> [1] North America
[ ] Japan
+1 -1
View File
@@ -8,5 +8,5 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-8 of 20
1-4 of 9
A/Enter Exit Up/Down Scroll
+1 -1
View File
@@ -11,5 +11,5 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-11 of 20
1-5 of 9
A/Enter Exit Up/Down Scroll
+2 -2
View File
@@ -2,8 +2,8 @@
Found on this machine:
> RetroArch ~/.config/retroarch/system
KNULLI (Batocera files) /userdata/bios
> RetroArch
KNULLI (Batocera files)
Another setup or folder
BIOS folder: /home/user/.config/retroarch/system
+2 -2
View File
@@ -2,8 +2,8 @@
Found on this machine:
> RetroArch ~/.config/retroarch/system
KNULLI (Batocera files) /userdata/bios
> RetroArch
KNULLI (Batocera files)
Another setup or folder
BIOS folder: /home/user/.config/retroarch/system
+1 -1
View File
@@ -9,4 +9,4 @@
2 of 3 systems: 4 files, 1.0 MB
A/Enter Continue B/Esc Back
A Continue B Back X Tick ? Help
+1 -1
View File
@@ -12,4 +12,4 @@
2 of 3 systems: 4 files, 1.0 MB
A/Enter Continue B/Esc Back
A Continue B Back X Tick ? Help
+3
View File
@@ -5049,6 +5049,9 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
{"event": "detect", "os": "linux", "os_name": install._os_name(),
"platforms": [{"platform": "retroarch", "name": "RetroArch",
"bios_dir": str(self.bios)}]},
# a.bin and b.bin are in place: the first read and the last.
{"event": "checking", "files_done": 1, "files_total": 2},
{"event": "checking", "files_done": 2, "files_total": 2},
{"event": "file", "platform": "retroarch", "dest": "a.bin",
"systems": ["sys-a"], "size": 100, "state": "present"},
{"event": "file", "platform": "retroarch", "dest": "b.bin",
+405 -17
View File
@@ -484,7 +484,8 @@ class Flow(unittest.TestCase):
self.assertEqual(state.screen, "cores")
self.assertEqual([row.value for row in install.tui_rows(state)][1:],
["mesen", "pcsx_rearmed"])
press(state, "down", " ")
self.assertEqual(install.tui_rows(state)[state.focus["cores"]].value, "mesen")
press(state, " ")
self.assertEqual(state.cores, ["mesen"])
press(state, "left")
self.assertEqual(state.cores, [])
@@ -644,13 +645,14 @@ class Flow(unittest.TestCase):
state = fresh_state()
with mock.patch.object(install.Path, "exists", return_value=True):
press(state, "enter")
install.tui_receive(state, ("manifest_failed", "retroarch",
install.tui_receive(state, ("manifest_failed", "retroarch", "No connection",
[" Could not fetch the file list for retroarch", ""], 1))
self.assertIn("RetroBIOS: No connection", install.tui_view(state, 80, 24)[0])
self.assertEqual(state.screen, "error")
self.assertEqual([row.action for row in install.tui_rows(state)],
["retry", "back", "quit"])
self.assertEqual(press(state, "enter"), [("fetch", "retroarch")])
install.tui_receive(state, ("manifest_failed", "retroarch", ["no"], 1))
install.tui_receive(state, ("manifest_failed", "retroarch", "No connection", ["no"], 1))
press(state, "down", "down", "enter")
self.assertEqual(state.exit_code, 1)
@@ -697,7 +699,13 @@ class PlanWarnings(unittest.TestCase):
state = checked_state({"free": 1024, "writable": False, "cores": []})
text = " ".join(install.tui_view(state, 120, 40))
self.assertIn("Not enough free space for this download", text)
self.assertIn("This folder cannot be written", text)
self.assertIn("You cannot write to this folder: choose another one, or run the "
"installer with sudo.", text)
rows = install.tui_rows(state)
self.assertEqual([row.action for row in rows[:2]], ["folder", "install"])
self.assertEqual(state.focus.get("plan", 0), 0)
self.assertEqual(press(state, "enter"), [])
self.assertEqual(state.screen, "folder")
self.assertNotIn("--installed-cores", [row.right for row in install._option_rows(state)])
@@ -716,7 +724,8 @@ class Widgets(unittest.TestCase):
press(state, "end")
lines = install.tui_view(state, 40, 15)
self.assertIn("> [x] system-111", "\n".join(lines))
self.assertTrue(any(re.fullmatch(r" \d+-115 of 115", line) for line in lines), lines)
# The All, None and Filter rows are not counted among the systems.
self.assertTrue(any(re.fullmatch(r" \d+-112 of 112", line) for line in lines), lines)
press(state, "pgup")
self.assertLess(state.focus["systems"], 114)
press(state, "home")
@@ -755,7 +764,10 @@ class Widgets(unittest.TestCase):
press(state, "a")
self.assertEqual(len(state.ticked), 5)
state.focus["systems"] = 1
# X on None does nothing: only A, an explicit choice, unticks all.
press(state, " ")
self.assertEqual(len(state.ticked), 5)
press(state, "enter")
self.assertEqual(state.ticked, set())
def test_running_total_counts_each_file_once(self):
@@ -909,8 +921,8 @@ class Views(unittest.TestCase):
for name, state in golden_states().items():
with self.subTest(screen=name):
hint = install.tui_view(state, 40, 15)[-1].strip()
self.assertTrue(hint.startswith(("A/Enter", "Enter", "Ctrl-C", "Any key")),
hint)
self.assertTrue(hint.startswith(("A/Enter", "A ", "Enter", "Ctrl-C", "Any key",
"B/Esc Stop")), hint)
def test_focus_is_marked_without_colour(self):
lines = install.tui_view(fresh_state(), 80, 24)
@@ -1092,6 +1104,19 @@ class SmallFixes(unittest.TestCase):
self.assertTrue(state.installed_only)
self.assertNotIn("--installed-cores", [f for f, _v in install.tui_flags(state)])
def test_the_end_of_a_wrapped_pager_is_reachable(self):
state = checked_state()
state.stack.append("result")
state.code, state.summary = 0, {"installed": 3}
state.dest = Path("/media/" + "long-folder-name/" * 8)
state.cols, state.rows = 40, 12
press(state, "end")
lines = install.tui_view(state, 40, 12)
self.assertIn("python3 install.py", "\n".join(lines))
top = state.focus["result"]
press(state, "up")
self.assertEqual(state.focus["result"], top - 1)
def test_end_then_up_in_a_pager(self):
state = checked_state()
state.stack += ["options", "preview"]
@@ -1127,7 +1152,7 @@ class SmallFixes(unittest.TestCase):
"event": "downloaded", "dest": "a.bin", "ok": True, "reason": "", "size": 1,
"files_done": 1, "files_total": 4, "bytes_done": 1, "bytes_total": 100}))
press(state, "ctrl-c")
self.assertIn("Press Ctrl-C again to quit now.",
self.assertIn("Press B or Ctrl-C again to stop now.",
" ".join(install.tui_view(state, 120, 40)))
self.assertEqual(press(state, "ctrl-c"), [("kill",)])
install.tui_receive(state, ("ended", state.run_id, -9, []))
@@ -1206,6 +1231,346 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
self.assertEqual(signal.getsignal(number), handler)
class AuditRoundTwo(unittest.TestCase):
"""tui_audit_1.md, each finding held where it was found."""
def systems(self) -> install.TuiState:
state = checked_state()
press(state, "down", "enter")
self.assertEqual(state.screen, "systems")
return state
def focused(self, state: install.TuiState) -> install.TuiRow:
rows = install.tui_rows(state)
return rows[state.focus[state.screen]]
def test_p1_3_the_filter_never_changes_the_ticks(self):
state = self.systems()
press(state, "n")
state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.value == "nintendo-gba")
press(state, "right")
self.assertEqual(state.ticked, {"nintendo-gba"})
press(state, "/", "p", "l", "a", "y")
self.assertEqual(self.focused(state).action, "filter")
press(state, "enter")
# Enter lands on the first match, never on All or None.
self.assertEqual(self.focused(state).value, "sony-playstation")
press(state, " ")
self.assertEqual(state.ticked, {"nintendo-gba", "sony-playstation"})
press(state, " ")
press(state, "esc")
# The filter is cleared, the row in focus stays in focus, and A there
# continues instead of ticking everything.
self.assertEqual(state.filters["systems"], "")
self.assertEqual(self.focused(state).value, "sony-playstation")
self.assertEqual(state.ticked, {"nintendo-gba"})
self.assertIn("A/Enter Continue", install.tui_view(state, 80, 24)[-1])
state.focus["systems"] = 2
press(state, " ")
self.assertEqual(state.editing, "")
self.assertEqual(state.ticked, {"nintendo-gba"})
def test_p2_2_the_bar_names_what_a_does_on_the_focused_row(self):
state = checked_state()
self.assertTrue(install.tui_view(state, 80, 24)[-1].strip().startswith("A/Enter Install"))
press(state, "down")
self.assertTrue(install.tui_view(state, 80, 24)[-1].strip().startswith("A/Enter Open"))
state = self.systems()
state.focus["systems"] = 0
self.assertIn("A/Enter Tick all", install.tui_view(state, 80, 24)[-1])
def test_p2_2_b_esc_and_q_stop_an_install(self):
for key in ("esc", "backspace", "q"):
with self.subTest(key=key):
state = checked_state()
press(state, "enter")
self.assertEqual(press(state, key), [("stop",)])
self.assertTrue(state.stopping)
self.assertEqual(press(state, key), [("kill",)])
def test_p2_3_options_read_the_plan_again(self):
state = self.systems()
press(state, "n")
state.focus["systems"] = 3
press(state, "right")
ticked = set(state.ticked)
press(state, "esc", "o")
self.assertEqual(state.screen, "options")
state.focus["options"] = 1
press(state, "enter")
self.assertTrue(state.no_game_data)
effects = press(state, "esc")
self.assertEqual(effects, [("run", "check")])
self.assertIn("--no-game-data", [f for f, _v in state.checked_flags])
self.assertNotIn("--system", [f for f, _v in state.checked_flags])
for event in [*FILES, *SYSTEMS, {**CHECK, "missing": 2, "bytes_needed": 600}]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
self.assertEqual(state.screen, "plan")
self.assertEqual(state.ticked, ticked)
for cols, rows in ((80, 24), (40, 15)):
text = "\n".join(install.tui_view(state, cols, rows))
self.assertIn("3 files to download, 600 B", text)
self.assertIn("For your 1 of 3 systems", text)
def test_p2_4_no_network_is_said_at_once(self):
import socket
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
inbox = __import__("queue").Queue()
started = time.monotonic()
with mock.patch.object(install, "MANIFEST_URL",
f"http://127.0.0.1:{port}/install/{{platform}}.json"):
install._tui_fetch("retroarch", inbox, io.StringIO())
self.assertLess(time.monotonic() - started, 5)
kind, plat, title, lines, code = inbox.get_nowait()
self.assertEqual((kind, title, code), ("manifest_failed", "No connection", 1))
self.assertIn("could not be downloaded: the server refused the connection", lines[0])
self.assertNotIn("--", " ".join(lines))
state = fresh_state()
with mock.patch.object(install.Path, "exists", return_value=True):
press(state, "enter")
install.tui_receive(state, (kind, plat, title, lines, code))
self.assertEqual([row.action for row in install.tui_rows(state)],
["retry", "back", "quit"])
def test_p2_4_the_clock_says_so(self):
import ssl
inbox = __import__("queue").Queue()
error = urllib_error(ssl.SSLCertVerificationError("certificate is not yet valid"))
words = install.network_words(error, today=install.datetime.date(1980, 1, 1))
with mock.patch.object(install, "_fetch_json", side_effect=error), \
mock.patch.object(install, "network_words", return_value=words):
install._tui_fetch("retroarch", inbox, io.StringIO())
_kind, _plat, title, lines, _code = inbox.get_nowait()
self.assertEqual(title, "The clock is wrong")
self.assertIn("The clock reads 1980-01-01", lines[0])
def test_p2_5_one_line_per_cause(self):
state = checked_state()
state.stack.append("result")
state.code = 2
state.summary = {"installed": 0, "up_to_date": 0, "failed": [
{"dest": f"f{n}.bin", "reason": "folder not writable"} for n in range(50)]}
_title, lines = install.tui_outcome(state)
self.assertEqual([line for line in lines if "failed" in line],
["Nothing was installed: 50 files failed.",
" 50 files failed (folder not writable), the first f0.bin"])
def test_p2_6_a_folder_inside_a_card_offers_its_root(self):
card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, card, True)
(card / ".system").mkdir()
(card / ".system" / "version.txt").write_text("MinUI-20250101\n")
(card / "Bios").mkdir()
state = fresh_state()
state.stack = ["folder"]
self.assertEqual(install._choose(state, "minui", card / "Bios"), [])
self.assertEqual(state.screen, "cardroot")
text = " ".join(line.strip() for line in install.tui_view(state, 120, 40))
self.assertIn(f"under the card's root: {card}", text)
self.assertEqual(press(state, "enter"), [("fetch", "minui")])
self.assertEqual(state.dest, card)
def test_p2_6_the_banner_is_not_an_error_line(self):
state = fresh_state()
state.platform, state.dest = "retroarch", SYSTEM_DIR
state.manifest, state.manifest_for = MANIFEST, "retroarch"
install._start_check(state)
install.tui_receive(state, ("ended", state.run_id, 1,
["RetroBIOS 532f0b8d96e0", "Error: refused."]))
self.assertEqual(state.error, ["Error: refused."])
self.assertNotIn("retry", [row.action for row in install.tui_rows(state)])
def test_p2_7_a_typed_path_replaces_the_one_offered(self):
state = fresh_state()
state.platform, state.stack = "retroarch", ["folder"]
state.folder_default = SYSTEM_DIR
state.focus["folder"] = 2
press(state, "enter")
self.assertEqual((state.screen, state.path_selected), ("path", True))
install.tui_update(state, Key("paste", '"/media/sd card/bios"'))
self.assertEqual(state.path_text, '"/media/sd card/bios"')
install.tui_update(state, Key("ctrl-u"))
self.assertEqual(state.path_text, "")
install.tui_update(state, Key("paste", '/media/x"'))
self.assertEqual(install.typed_path(state.path_text), Path("/media/x"))
def test_p2_7_a_path_from_the_root_is_not_created_on_a(self):
state = fresh_state()
state.stack = ["folder"]
install._choose(state, "retroarch", Path("/no/such/deep/folder"))
self.assertEqual(state.screen, "create")
self.assertEqual(self.focused(state).action, "other")
self.assertIn("Every folder of this path would be made",
" ".join(install.tui_view(state, 120, 40)))
near = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, near, True)
install._choose(state, "retroarch", near / "bios")
self.assertEqual(self.focused(state).action, "create")
def test_p2_9_two_setups_of_one_name(self):
flatpak = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/system"
setups = install.tell_apart([("retroarch", flatpak, "RetroArch"),
("retroarch", SYSTEM_DIR, "RetroArch"),
("emudeck", HOME / "Emulation/bios", "EmuDeck")])
self.assertEqual([label for _p, _f, label in setups],
["RetroArch (Flatpak)", "RetroArch (retroarch/system)", "EmuDeck"])
state = fresh_state()
state.setups = setups
self.assertIn("RetroArch (Flatpak)", "\n".join(install.tui_view(state, 40, 15)))
# A Flatpak whose config names a system folder outside its sandbox.
elsewhere = HOME / "bios"
config = HOME / ".var/app/org.libretro.RetroArch/config/retroarch/retroarch.cfg"
with mock.patch.dict(install._RETROARCH_CONFIGS, {elsewhere: (config, config.parent)}):
setups = install.tell_apart([("retroarch", elsewhere, "RetroArch"),
("retroarch", SYSTEM_DIR, "RetroArch")])
self.assertEqual(setups[0][2], "RetroArch (Flatpak)")
def test_p2_10_the_check_counts_while_it_reads(self):
state = fresh_state()
state.platform, state.dest = "retroarch", SYSTEM_DIR
state.manifest, state.manifest_for = MANIFEST, "retroarch"
install._start_check(state)
install.tui_receive(state, ("event", state.run_id,
{"event": "checking", "files_done": 120, "files_total": 400}))
self.assertIn("Reading the files already in the folder: 120 of 400",
"\n".join(install.tui_view(state, 80, 24)))
def test_p2_11_a_narrow_bar_keeps_the_keys_to_finish(self):
state = self.systems()
self.assertEqual(install.tui_view(state, 40, 15)[-1].strip(),
"A Continue B Back X Tick ? Help")
def test_p2_12_a_device_name_finds_its_setup(self):
state = fresh_state()
state.stack = ["platforms"]
press(state, "/", "m", "u", "o", "s")
self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["retroarch"])
def test_p2_13_a_long_preview_wraps_only_what_is_in_view(self):
state = checked_state()
state.stack += ["options", "preview"]
state.dry = dict(CHECK)
state.dry_files = [{**FILES[0], "dest": f"file{n:05d}.bin"} for n in range(8000)]
state.cols, state.rows = 40, 15
real = install._text_lines
calls = []
def counted(items, width):
calls.append(len(items))
return real(items, width)
with mock.patch.object(install, "_text_lines", counted):
press(state, "end")
lines = install.tui_view(state, 40, 15)
self.assertLess(sum(calls), 40)
self.assertIn("file07999.bin", "\n".join(lines))
def test_p2_1_titles_and_regions(self):
state = checked_state()
state.manifest = {**MANIFEST, "system_titles": {"nintendo-gba": "Game Boy Advance"}}
state.stack.append("systems")
text = "\n".join(install.tui_view(state, 40, 15))
self.assertIn("[x] Game Boy Advance", text)
self.assertEqual(install.region_title("asia-ntsc"), "Asia (NTSC)")
self.assertEqual(install.region_title("north-america"), "North America")
def test_p3_jobs_stop_at_the_ends_and_q_keeps_the_error_code(self):
state = checked_state()
state.jobs = 1
install._option(state, "jobs", -1)
self.assertEqual(state.jobs, 1)
state.jobs = 32
install._option(state, "jobs", 1)
self.assertEqual(state.jobs, 32)
state = fresh_state()
state.stack, state.error_code = ["setup", "error"], 2
press(state, "q")
self.assertEqual(state.exit_code, 2)
def test_p3_ascii_keeps_the_columns(self):
self.assertEqual(install.ascii_text("Édition ファミ"), "Edition ??????")
def test_p3_a_refused_tui_says_why_without_the_usage(self):
proc = install_run("--tui")
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stderr.strip().splitlines(),
["install.py: error: --tui cannot open the full-screen interface "
"here: the input is not a terminal"])
def urllib_error(reason: BaseException):
import urllib.error
return urllib.error.URLError(reason)
class WorkFolders(unittest.TestCase):
"""Every run's folder of logs goes, but the one a failure names."""
class Terminal:
state_to = None
def __init__(self, *_streams) -> None:
self.frame = []
def enter(self) -> None:
pass
def leave(self) -> None:
pass
def size(self) -> tuple:
return 80, 24
def draw(self, lines, cols) -> None:
pass
def keys(self, timeout):
WorkFolders.Terminal.state_to(self)
return [Key("enter")]
def run_to(self, code: int) -> tuple:
work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, work, True)
state = checked_state()
state.stack.append("result")
state.code = code
state.log = str(work / "install.log")
state.summary = {"installed": 1, "failed": [] if code == 0 else [
{"dest": "a.bin", "reason": "HTTP 404"}]}
WorkFolders.Terminal.state_to = lambda _t: None
out = io.StringIO()
with mock.patch.object(install, "_TuiTerminal", self.Terminal), \
mock.patch.object(install, "tui_start", return_value=(state, [])), \
mock.patch.object(install, "_open_local_sources"), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \
mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", out):
self.assertEqual(install.run_tui(_args()), code)
return work, out.getvalue()
def test_a_success_leaves_nothing(self):
work, out = self.run_to(0)
self.assertFalse(work.exists())
self.assertNotIn("Details:", out)
def test_a_failure_keeps_the_log_it_names(self):
work, out = self.run_to(1)
self.assertTrue(work.exists())
self.assertIn(f"Details: {work / 'install.log'}", out)
def test_a_stop_leaves_nothing(self):
work, out = self.run_to(130)
self.assertFalse(work.exists())
class Crash(unittest.TestCase):
"""An unexpected error ends on one sentence and a log, never a traceback."""
@@ -1441,13 +1806,11 @@ class EndToEnd(unittest.TestCase):
term.send("\r")
term.wait("Preferred regions")
term.send(DOWN, "\r", "\x1b")
screen = term.wait("Preferred regions: north-america")
screen = term.wait("Preferred regions: North America")
term.send(DOWN, RIGHT, "\x1b")
screen = term.wait("> Install")
self.assertIn("Your choices: --region north-america --no-game-data", screen)
term.send("\r")
# Back on the plan, the check runs again under the new options.
screen = term.wait("4 files to download")
self.assertIn("RetroBIOS: Confirm", screen)
self.assertIn("> Install", screen)
term.send("\r")
screen = term.wait("installed.")
self.assertIn("Left out as game data: 1 file.", screen)
@@ -1477,11 +1840,36 @@ class EndToEnd(unittest.TestCase):
term.wait("1 of 6 files")
term.send("\x03")
screen = term.wait("Stopped:")
self.assertRegex(screen, r"Stopped: \d of 6 installed, run again to finish\.")
term.send("\r")
self.assertEqual(term.close(), 130)
self.assertLess(len(repo.installed()), 6)
self.assertIn("RetroBIOS: Stopped", term.after())
# The count said is the count on disk: "6 of 6" after a stop at 1
# passed a regex and was false.
on_disk = len(repo.installed())
self.assertLess(on_disk, 6)
self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", screen)
self.assertIn(f"Stopped: {on_disk} of 6 installed", term.after())
def test_sigterm_during_the_download_is_a_stop(self):
"""Start+Select on a handheld is gptokeyb's pkill: SIGTERM, SIGKILL 3 s on."""
import signal
repo = self.repo(GameDataRepository, slow=True)
term = self.start("--platform", "retroarch", "--dest", str(repo.dest), "--jobs", "1")
term.wait("> Install")
term.send("\r")
term.wait("1 of 6 files")
started = time.monotonic()
os.kill(term.pid, signal.SIGTERM)
code = term.close()
self.assertLess(time.monotonic() - started, 3)
self.assertEqual(code, 130)
on_disk = len(repo.installed())
self.assertGreaterEqual(on_disk, 1)
after = term.after()
self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", after)
self.assertNotIn("Nothing was installed", after)
time.sleep(1)
self.assertEqual(len(repo.installed()), on_disk)
def test_ctrl_c_on_the_first_screen(self):
self.repo()
@@ -1524,7 +1912,7 @@ class EndToEnd(unittest.TestCase):
term.resize(100, 30)
term.send(DOWN)
screen = term.wait("> Another setup or folder")
self.assertIn("A/Enter Continue", screen.splitlines()[29])
self.assertIn("B/Esc Quit", screen.splitlines()[29])
term.resize(30, 10)
term.wait("Make the window larger")
term.resize(40, 12)
+16 -8
View File
@@ -92,8 +92,10 @@ play, with the total to download under the list. **Options** sets the
preferred regions, game data, the installed cores only, the cores, the parallel
downloads and the folder. Each row names the option it sets, and **Show the
command line** gives the same choice as one command for a script. **Preview the
file list** shows what would be downloaded and writes nothing. When a choice
changes the plan, the exact count appears before the install starts.
file list** shows what would be downloaded and writes nothing. Back on the plan
after a change of options, the folder is checked again, so its numbers are the
exact ones for the new choice. Ticked systems get their exact count before the
install starts.
| Action | Keyboard | Controller |
|--------|----------|------------|
@@ -106,14 +108,19 @@ changes the plan, the exact count appears before the install starts.
| Options | `o`, Tab | Y |
| Filter a long list | `/` then type | the Filter row |
| Keys of the screen | `?`, F1 | |
| Stop an install | Esc, `q`, Ctrl+C | B, Start+Select |
| Quit | `q`, Ctrl+C | B on the first screen |
The bottom line of each screen names its keys, with both spellings (`A/Enter`,
`B/Esc`), since A and B swap places between controller layouts. On the regions
screen, Left and Right move the region under the cursor up and down the order of
preference. During the download, Ctrl+C stops after the files in progress, a
second Ctrl+C stops at once, and the result says how many were installed. The result stays printed in the
terminal once the screen closes, with the command that repeats the run.
The bottom line of each screen names what A does on the row under the cursor,
with both spellings (`A/Enter`, `B/Esc`), since A and B swap places between
controller layouts. A narrow screen shortens them to `A`, `B` and `X`. On the
regions screen, Left and Right move the region under the cursor up and down the
order of preference. During the download, a stop waits for the files in
progress, and a second one stops at once. Start+Select on a handheld ends the
installer with a signal (SIGTERM), which stops it the same way. Either way the
result says how many files were installed, and it stays printed in the terminal
once the screen closes, with the command that repeats the run. A run that failed
keeps its log and names it, and every other run leaves nothing behind.
It opens only for an install, at a terminal of at least 40 columns and 12 rows
that is not `TERM=dumb`. A listing, `--check`, `--dry-run`, `--events`,
@@ -1099,6 +1106,7 @@ kind, and each kind has exactly the keys below, in this order. The schema is
| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` |
| `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, or `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one). `message` says it in words |
| `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 |
| `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 |