diff --git a/install.py b/install.py index c33a3077..ba823e75 100644 --- a/install.py +++ b/install.py @@ -12941,9 +12941,27 @@ def tui_farewell(state: TuiState) -> list[str]: return [screen_text(line) for line in _farewell_lines(state)] +def tui_idle_code(state: TuiState) -> int: + """The exit code of an idle close: an error's or a result's on those + screens, so a launcher tells them from a close with nothing to say.""" + if state.screen == "error": + return state.error_code + if state.screen == "result" and state.code is not None: + return state.code + return 0 + + def _farewell_lines(state: TuiState) -> list[str]: - if state.idle_closed: - return [f"RetroBIOS: {TUI_IDLE_WORDS}"] + if not state.idle_closed: + return _closing_lines(state) + idle = f"RetroBIOS: {TUI_IDLE_WORDS}" + # What an error or a result screen said stays said. + if state.screen in ("error", "result"): + return [idle, *_closing_lines(state)] + return [idle] + + +def _closing_lines(state: TuiState) -> list[str]: if state.screen != "result" and state.download is not None: # An install ran: what it wrote is said, whatever ended it. title, lines = tui_outcome(state) @@ -13114,7 +13132,7 @@ def run_tui(args: argparse.Namespace) -> int: quiet_since = time.monotonic() elif (idle is not None and not pressed and state.exit_code is None and time.monotonic() - quiet_since >= idle): - state.idle_closed, state.exit_code = True, 0 + state.idle_closed, state.exit_code = True, tui_idle_code(state) if flags["interrupt"]: flags["interrupt"] = False if time.monotonic() >= ignore_interrupt_until[0]: diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index a3adb6f4..872828ba 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2595,6 +2595,60 @@ class IdleExit(unittest.TestCase): self.assertEqual(install.tui_farewell(state), ["RetroBIOS: No button pressed, closing."]) + def test_an_error_screen_closes_with_its_code(self): + """Closed idle on "No internet: offline mode", the launcher read 0 + and showed nothing.""" + state = fresh_state() + state.stack = ["setup", "checking"] + install._fail(state, ["Give the file list with --manifest FILE."], 1) + state.error_title = "No internet: offline mode" + self.assertEqual(self.idle_close(state), 1) + self.assertEqual(install.tui_farewell(state), [ + "RetroBIOS: No button pressed, closing.", + "RetroBIOS: No internet: offline mode", + "Give the file list with --manifest FILE."]) + + def test_a_result_closes_with_the_install_code(self): + state = fresh_state() + state.stack = ["setup", "result"] + state.code = 2 + state.summary = {"event": "summary", "mode": "install", "installed": 1, + "up_to_date": 0, "pending": 0, + "failed": [{"dest": "a.bin", "reason": "disk full"}], + "skipped": [], "left_out": [], "kept": [], "interrupted": False} + state.dest = SYSTEM_DIR + self.assertEqual(self.idle_close(state), 2) + farewell = install.tui_farewell(state) + self.assertEqual(farewell[:2], ["RetroBIOS: No button pressed, closing.", + "RetroBIOS: Finished with errors"]) + + def test_any_other_screen_closes_with_0(self): + state = fresh_state() + state.stack = ["setup"] + self.assertEqual(self.idle_close(state), 0) + + def idle_close(self, state: install.TuiState) -> int: + """run_tui on state, no key ever pressed, closed by the idle timer.""" + class Quiet(Crash.FakeTerminal): + def keys(self, timeout): + time.sleep(0.01) + return [] + + out = io.StringIO() + work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, work, True) + with mock.patch.object(install, "_TuiTerminal", Quiet), \ + mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \ + 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.dict(os.environ, {install.TUI_IDLE_ENV: "0.05"}), \ + mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", out): + code = install.run_tui(_args()) + self.assertTrue(state.idle_closed) + self.assertIn("No button pressed, closing.", out.getvalue()) + return code + class AReadErrorIsNotAClosedWindow(unittest.TestCase): """Any EIO read as the window closing: a failing SD card answers EIO diff --git a/wiki/installer.md b/wiki/installer.md index 83e12994..51f061a0 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1087,6 +1087,9 @@ A launcher with no mapper at all (a keyboard) leaves it unset. A launcher can also set `RETROBIOS_IDLE_EXIT` to a number of seconds: when no button has reached the interface since it opened, and nothing has run for that long, it closes with `No button pressed, closing.` and exit status 0. +Closed on an error or a result screen, it prints that screen's words after +the line and exits with its code (1 for `No internet: offline mode`), so +the launcher can tell an error from a close with nothing to report. The time counts from the end of the last check, so a launcher that names the platform, whose interface moves on to the plan by itself, closes the same way. A mapper that never opened the pad would otherwise hold the device on that @@ -1341,7 +1344,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `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 | | `RETROBIOS_PAD` | The buttons a handheld launcher's mapper sends to the full screen (`gptokeyb`, `gptokeyb_muxterm`, `muxterm`, `evmapy`, `miyoo`), so its hints name them. See [On a handheld](#on-a-handheld-the-retrobios-port) | -| `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 0 | +| `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 0, or with the code of the error or result screen it closed on | | `HTTPS_PROXY`, `NO_PROXY` | Honoured for every download through the standard library's default proxy handling: one names the proxy, the other the hosts to reach directly | ## When something goes wrong