From 2571acc8203ae14d7e9899d5404c110718e5a76c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:48:29 +0100 Subject: [PATCH] fix: say when Windows does not answer under WSL --- install.ps1 | 2 +- install.py | 45 +++++++++++++++++++++++++++++++++--- install.sh | 2 +- install/launchers/index.json | 16 ++++++------- tests/test_install.py | 31 +++++++++++++++++++++++++ wiki/installer.md | 6 +++-- 6 files changed, 87 insertions(+), 15 deletions(-) diff --git a/install.ps1 b/install.ps1 index 10ef0b596..79c59f28a 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 = "1096705acbaa531b7e05458cc58b393426b7e1577d0db62bd2cd29e1c8257ca3" + $defaultInstallSha256 = "450cee7625f098d5796452b02ab9c8bffdeaaef89180d2254bbe07ce354bc359" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index e84c01276..082994522 100644 --- a/install.py +++ b/install.py @@ -1200,7 +1200,9 @@ def _parse_os_release() -> dict[str, str]: # Set under WSL: a Windows path read from a config or a rule names a drive # that Linux sees under /mnt (drvfs). C:\\RetroBat\\bios was used as a relative # path and nothing Windows-side was ever found from WSL. -_WSL = {"active": False, "root": "/mnt"} +# unasked: why Windows could not be asked for its folders, said once by +# detection. +_WSL = {"active": False, "root": "/mnt", "unasked": ""} _WINDOWS_VARIABLES = ("USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)") @@ -1255,13 +1257,27 @@ def _enter_wsl() -> None: capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL, cwd="/mnt/c" if Path("/mnt/c").is_dir() else None, ) - except (OSError, subprocess.SubprocessError): + except (OSError, subprocess.SubprocessError) as exc: + # The others would wait as long: detection is told the Windows + # side was not searched, never that nothing is there. + _windows_unasked("cmd.exe", exc) return value = result.stdout.strip() if value and not value.startswith("%"): os.environ[variable] = str(_host_path(value)) +def _windows_unasked(tool: str, exc: Exception) -> None: + """Keep why a Windows program run from WSL gave no answer: an interop + slow to start or turned off hid every Windows setup, and the run said + none was installed.""" + if isinstance(exc, subprocess.TimeoutExpired): + why = f"{tool} did not answer in {exc.timeout:g} s" + else: + why = f"{tool} could not run: {getattr(exc, 'strerror', None) or exc}" + _WSL["unasked"] = _WSL["unasked"] or why + + def _expand_retroarch_path(value: str, app_dir: Path, home: Path | None = None) -> Path: """Expand the notations RetroArch writes into its config values. @@ -2521,6 +2537,9 @@ def refused_words(folder: Path, why: str, shown: str = "") -> str: """The line said for a folder detection could not look in. shown is the folder as the screen writes it (~/.config/EmuDeck), the full path by default.""" + if _WSL["active"] and why == _WSL["unasked"] and folder == Path(_WSL["root"]): + return (f"Could not look for setups on the Windows side: {why}. " + "Run it again once Windows answers, or name the BIOS folder with --dest.") if folder in _DETECTION_UNREADABLE: return (f"Could not look for setups: {shown or folder} could not be read ({why}). " "A failing card or a link that loops gives this: check it, or name the " @@ -2555,6 +2574,22 @@ def _note_refused(path: Path, exc: OSError) -> None: print(f" {screen_text(words)}", file=sys.stderr) +def _note_windows_unasked() -> None: + """Say once, as a folder detection could not look in, that Windows did + not answer: its drives are then searched without its folders.""" + why = _WSL["unasked"] + if (not why or not getattr(_SETUP_SEARCH, "active", False) + or getattr(_SETUP_SEARCH, "quiet", False)): + return + refused = (Path(_WSL["root"]), why) + if refused in _DETECTION_REFUSED: + return + _DETECTION_REFUSED.append(refused) + words = refused_words(*refused) + EVENTS.emit("unsearched", folder=str(refused[0]), reason=why, message=words) + print(f" {screen_text(words)}", file=sys.stderr) + + def _detection_guarded(detect: Callable[..., list]) -> Callable[..., list]: """Detection whose probes treat a folder this user cannot open as absent, and say so once. @@ -2694,7 +2729,9 @@ def _read_registry(key: str, value: str) -> str | None: capture_output=True, text=True, errors="replace", timeout=10, stdin=subprocess.DEVNULL, cwd="/mnt/c" if Path("/mnt/c").is_dir() else None, ) - except (OSError, subprocess.SubprocessError): + except (OSError, subprocess.SubprocessError) as exc: + _windows_unasked("reg.exe", exc) + _note_windows_unasked() return None match = re.search( rf"^\s*{re.escape(value)}\s+REG_(?:EXPAND_)?SZ\s+(.+?)\s*$", result.stdout, re.M @@ -2734,6 +2771,8 @@ def _detect_by_path(os_type: str, found: list[tuple[str, Path]]) -> None: def detect_platforms(os_type: str) -> list[tuple[str, Path]]: """Detect installed emulator platforms and their BIOS directories.""" found: list[tuple[str, Path]] = [] + if os_type == "wsl": + _note_windows_unasked() if os_type == "android": found.extend(_detect_android()) diff --git a/install.sh b/install.sh index 1aa344316..d6e4b78ba 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="1096705acbaa531b7e05458cc58b393426b7e1577d0db62bd2cd29e1c8257ca3" +DEFAULT_INSTALL_SHA256="450cee7625f098d5796452b02ab9c8bffdeaaef89180d2254bbe07ce354bc359" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 29f624bbe..9fd32e302 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": 1113525, - "sha256": "1096705acbaa531b7e05458cc58b393426b7e1577d0db62bd2cd29e1c8257ca3", + "size": 1115328, + "sha256": "450cee7625f098d5796452b02ab9c8bffdeaaef89180d2254bbe07ce354bc359", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1167152, - "sha256": "eaed4292f1bcfdbe613e377b5e6b69933819b71c50b1b2d2f162603743d6b17a" + "size": 1168955, + "sha256": "1f52731fda2beb3ff9e7aaf7f3d9333853e66b99240b24e4f6b0026368755cb4" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1113525, - "sha256": "1096705acbaa531b7e05458cc58b393426b7e1577d0db62bd2cd29e1c8257ca3", + "size": 1115328, + "sha256": "450cee7625f098d5796452b02ab9c8bffdeaaef89180d2254bbe07ce354bc359", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1113525, - "sha256": "1096705acbaa531b7e05458cc58b393426b7e1577d0db62bd2cd29e1c8257ca3", + "size": 1115328, + "sha256": "450cee7625f098d5796452b02ab9c8bffdeaaef89180d2254bbe07ce354bc359", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index 78db13fe4..4cec55ae6 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4511,6 +4511,37 @@ class WindowsSeenFromWsl(unittest.TestCase): self.assertEqual(os.environ["ProgramFiles"], "") + def test_windows_not_answering_is_said_not_taken_for_nothing(self): + """cmd.exe timing out on the first variable ended the import without + a word, and detection said no setup was installed.""" + calls: list = [] + + def slow(argv, **kwargs): + calls.append(argv) + raise subprocess.TimeoutExpired(argv, kwargs.get("timeout", 10)) + + cleared = {name: "" for name in install._WINDOWS_VARIABLES} + home = Path(_tempdir(self)) + err = io.StringIO() + with unittest.mock.patch.dict(os.environ, {**cleared, "HOME": str(home)}), \ + unittest.mock.patch.object(install.subprocess, "run", slow), \ + unittest.mock.patch.object(Path, "home", return_value=home), \ + unittest.mock.patch.object(install, "_DETECTION_REFUSED", []), \ + unittest.mock.patch.object(install.EVENTS, "emit") as emit, \ + contextlib.redirect_stderr(err): + install._enter_wsl() + self.assertEqual(len(calls), 1) + install.detect_platforms("wsl") + install.detect_platforms("wsl") + said = err.getvalue() + self.assertEqual(said.count("on the Windows side"), 1, said) + self.assertIn("cmd.exe did not answer in 10 s", said) + kinds = [c.args[0] for c in emit.call_args_list] + self.assertEqual(kinds.count("unsearched"), 1) + self.assertEqual(emit.call_args_list[kinds.index("unsearched")].kwargs["folder"], + str(self.root)) + + class SteamRetroArchOnMacIsPortable(unittest.TestCase): """platform_darwin.m makes the Steam build portable (HAVE_STEAM) with no portable.txt: a default system_directory is Application Support's, and diff --git a/wiki/installer.md b/wiki/installer.md index 8dd96352c..b89874475 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1160,7 +1160,9 @@ Under WSL both sides are searched. The Windows one is reached through its `/mnt/` mount: a `C:\` path from a rule or a config becomes `/mnt/c/...`, and the Windows variables the rules use (`USERPROFILE`, `APPDATA`, `LOCALAPPDATA`, `ProgramFiles`) are read through `cmd.exe` when -WSL does not pass them. A forced `--platform retrobat` falls back to +WSL does not pass them. When `cmd.exe` or `reg.exe` gives no answer within +10 seconds or cannot run (interop turned off), the run says the Windows side +was not searched, never that nothing is installed there. A forced `--platform retrobat` falls back to `/mnt/c/RetroBat/bios`. With nothing found, the folder question offers both usual folders of a setup that has one on each side: RetroArch's in this Linux (`~/.config/retroarch/system`, under Enter) and the Windows one @@ -1797,7 +1799,7 @@ 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` | -| `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it or a read failing there (a failing card, a link that loops). What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | +| `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it or a read failing there (a failing card, a link that loops), or under WSL the Windows side when Windows did not answer (`folder` is then the drive mount, `/mnt`). What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `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), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, 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`), or is a file or lies under one (`reason` `not_a_folder`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. 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, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. 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 |