From 812d5d157e6e28d7967a6b6ff8557907ccd61e8d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:56:37 +0200 Subject: [PATCH] fix: tell offline from a lost connection on screen With --offline and no list, the full screen said No connection and offered a Try again that could never work. It now names offline mode and the two ways a run gets its list without the network. With a list, the plan counted downloads the run forbids: it says what this machine can supply and what needs the internet, and the printed command keeps --offline. --- install.ps1 | 2 +- install.py | 46 ++++++++++++++++++++++++++++++++++--- install.sh | 2 +- tests/test_install_tui.py | 48 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index 38b7ee9e..b00b2a73 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "eaa912af47dc6f29e4436ec200a1b431b8ddd8006ca08e57c8c73d3a05717818" + $defaultInstallSha256 = "1f6b90760303d04cb6beef7fd294371b2e1d4ba733578c59a95e1b0395c2f376" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index d7987b3a..874efc26 100644 --- a/install.py +++ b/install.py @@ -10140,6 +10140,8 @@ def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: if state.jobs != 8: flags.append(("--jobs", str(state.jobs))) flags += [("--from", str(folder)) for folder in state.from_folders] + if offline_requested() and not any(flag == "--offline" for flag, _v in flags): + flags.append(("--offline", None)) return flags @@ -10312,7 +10314,8 @@ def tui_rows(state: TuiState) -> list[TuiRow]: return [TuiRow("install", "Install"), TuiRow("back", "Back")] if screen == "error": rows = ([TuiRow("retry", "Try again")] - if state.run == "check" and state.error_title else []) + if state.run == "check" and state.error_title + and state.error_title != TUI_OFFLINE_TITLE else []) return rows + [TuiRow("back", "Choose another setup or folder" if state.run == "check" else "Back"), TuiRow("quit", "Quit")] @@ -10328,6 +10331,10 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: size = check.get("bytes_needed", 0) label = f"Install {format_size(size)}" if size >= CONFIRM_FROM_BYTES else "Install" rows = [TuiRow("install", label)] + if _offline_only() and not _plan_copies(state, check): + # Nothing here to copy, and the network refused: Install would + # only fail every file. + rows = [] if state.facts.get("writable") is False or _short_of_space(state, check): # Every file would fail alike, or the run would stop before the # first one: the way out is another folder. @@ -10343,6 +10350,18 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] +def _offline_only() -> bool: + """--offline without a pack: what is not on this machine cannot come.""" + return offline_requested() and LOCAL.pack is None + + +def _plan_copies(state: TuiState, check: dict) -> list[str]: + """The files to install that this machine holds, by destination.""" + local = {event["dest"] for event in state.found} if check is state.check else set() + sizes = {event["dest"] for event in state.files} + return [dest for dest in local if dest in sizes] + + def _short_of_space(state: TuiState, check: dict) -> bool: free = state.facts.get("free") return free is not None and check.get("bytes_needed", 0) > free @@ -11443,13 +11462,18 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: fetch = max(0, need - len(copied)) fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied)) elsewhere = _tui_found_elsewhere(state) if check is state.check else {} + offline = _offline_only() if elsewhere: # The folder by its last name: the whole path pushed the BIOS # folder off a 40x15 screen. The Options row spells it out. where = ", ".join(f"{len(events)} found in {_last_name(folder)}" for folder, events in elsewhere.items()) - headline = (f"{where}, {fetch} to download ({format_size(fetch_bytes)})" + headline = (f"{where}, {fetch} need the internet (--offline)" if fetch and offline + else f"{where}, {fetch} to download ({format_size(fetch_bytes)})" if fetch else f"{where}, nothing to download") + elif need and fetch and offline: + headline = (f"{_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " + "internet, which --offline does not use") elif need and fetch: headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}" elif need: @@ -11461,6 +11485,9 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: headline = (f"{_plural(data, 'data file')} to fetch" if data else "Everything is in place") wrong = check["wrong_hash"] + offline_lines = (["Copy them from your old card or a folder, or run the installer " + "again with a pack (--from-pack) or without --offline."] + if offline and fetch else []) # The folder comes second: on a 40x12 screen it is what is left once # the rows are drawn, and nothing is written before it is seen. lines: list = [ @@ -11505,7 +11532,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines += _unavailable_lines(state) if check is state.check: lines += _unplaced_lines(state.check_summary, state) - return lines + return lines[:2] + offline_lines + lines[2:] def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]: @@ -12379,6 +12406,9 @@ class _EngineRun: TUI_FETCH_TIMEOUT = 15 +TUI_OFFLINE_TITLE = "No internet: offline mode" + + def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: """The file list, fetched once in this process, or what went wrong.""" if LOCAL.manifest is not None or LOCAL.pack is not None: @@ -12407,6 +12437,16 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: if exc.code == 404 else f"The server answered HTTP {exc.code}."] except (urllib.error.URLError, OSError) as exc: code, words = network_words(exc) + if code == "offline": + # Asked for, not a failure: no Try again, and the two ways a + # run without the network gets its list. + inbox.put(("manifest_failed", plat, TUI_OFFLINE_TITLE, [ + "This run makes no network request (--offline), and the list of " + "BIOS files comes from the network.", + "Run the installer again with the list saved as a file " + "(--manifest FILE, a copy of install/.json) or with a pack " + "(--from-pack PACK), or without --offline."], 1)) + return title = {"clock": "The clock is wrong", "no_ca": "No trusted certificates"}.get(code, "No connection") # The screen's first row is Try again: "then run again" was a second way. diff --git a/install.sh b/install.sh index f64306ff..07943fb3 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="eaa912af47dc6f29e4436ec200a1b431b8ddd8006ca08e57c8c73d3a05717818" +DEFAULT_INSTALL_SHA256="1f6b90760303d04cb6beef7fd294371b2e1d4ba733578c59a95e1b0395c2f376" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index f8ba0aa8..d9a9fac8 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -19,6 +19,7 @@ import http.server import io import json import os +import queue import re import select import shutil @@ -2578,6 +2579,53 @@ class AuditRoundFour(unittest.TestCase): self.assertIn(line, install.tui_outcome(state)[1]) +class OfflineInTheInterface(unittest.TestCase): + """--offline in the full screen: a "No connection" error offering a Try + again that can never work, and a plan counting downloads the run + forbids.""" + + def setUp(self): + patcher = mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}) + patcher.start() + self.addCleanup(patcher.stop) + + def test_no_list_names_the_ways_that_work(self): + inbox = queue.Queue() + install._tui_fetch("retroarch", inbox, io.StringIO()) + kind, plat, title, lines, code = inbox.get_nowait() + self.assertEqual((kind, title, code), ("manifest_failed", + "No internet: offline mode", 1)) + text = " ".join(lines) + for words in ("--manifest", "--from-pack", "without --offline"): + self.assertIn(words, text) + state = fresh_state() + state.stack = ["setup"] + with mock.patch.object(install.Path, "exists", return_value=True): + install.tui_update(state, Key("enter")) + install.tui_receive(state, (kind, plat, title, lines, code)) + self.assertEqual(state.screen, "error") + self.assertNotIn("retry", [row.action for row in install.tui_rows(state)]) + view = "\n".join(install.tui_view(state, 80, 24)) + self.assertIn("No internet: offline mode", view) + self.assertNotIn("Try again", view) + + def test_the_plan_offers_no_download(self): + state = checked_state() + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertNotIn("to download", text) + self.assertIn("need the internet, which --offline does not use", text) + self.assertNotIn("install", [row.action for row in install.tui_rows(state)]) + # A file found on this machine can still be copied. + state.found = [{"event": "found", "platform": "retroarch", "dest": FILES[0]["dest"], + "folder": "/media/old", "path": "/media/old/scph5500.bin", + "member": "", "reason": ""}] + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("1 found in old,", text) + self.assertIn("need the internet (--offline)", text) + self.assertEqual(install.tui_rows(state)[0].action, "install") + self.assertIn("--offline", install.tui_command(state)) + + class NarrowSystemNames(unittest.TestCase): """At 40 columns the size column took half the line: Game Boy, Game Boy Advance and Game Boy Color all read "Nintendo Gam"."""