diff --git a/install.ps1 b/install.ps1 index 72c24e32..58bbdef8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "d86244b95c49d37d3950e0547fe13fa6df589dc21ba9868d46da4ab305522b3f" +$defaultInstallSha256 = "142edea98e646ed11e27052d3dd02125a27d4cdb7ee04392e6af8fcd28f9762f" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index f3060a58..0d54fde9 100644 --- a/install.py +++ b/install.py @@ -26,6 +26,7 @@ import platform import re import shutil import stat +import subprocess import sys import tarfile import tempfile @@ -181,9 +182,16 @@ def _os_default_dests(os_type: str) -> dict[str, Path]: return { # RetroArch's system directory defaults to \system # (platform_win32.c), and its installer to C:\RetroArch-Win64. - "retroarch": Path("C:/RetroArch-Win64/system"), + "retroarch": _host_path("C:/RetroArch-Win64/system"), # RetroBat's installer defaults to C:\RetroBat (installer.iss). - "retrobat": Path("C:/RetroBat/bios"), + "retrobat": _host_path("C:/RetroBat/bios"), + } + if os_type == "wsl": + # RetroArch may be the Linux one inside the distribution; RetroBat + # only exists on the Windows side, reached through /mnt/c. + return { + "retroarch": home / ".config" / "retroarch" / "system", + "retrobat": _host_path("C:/RetroBat/bios"), } if os_type == "darwin": return {"retroarch": _macos_retroarch_base() / "system"} @@ -435,6 +443,42 @@ def _parse_os_release() -> dict[str, str]: return result +# 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"} +_WINDOWS_VARIABLES = ("USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)") + + +def _host_path(text: str) -> Path: + """A path as this machine opens it: under WSL, C:\\X is /mnt/c/X.""" + match = re.match(r"^([A-Za-z]):(?:[\\/]+(.*))?$", text) if _WSL["active"] else None + if match: + rest = [part for part in re.split(r"[\\/]", match.group(2) or "") if part] + return Path(_WSL["root"], match.group(1).lower(), *rest) + return Path(text) + + +def _enter_wsl() -> None: + """Read Windows paths through drvfs, and the Windows variables detection + needs, which WSL does not pass to Linux (only those named in WSLENV).""" + _WSL["active"] = True + for variable in _WINDOWS_VARIABLES: + if os.environ.get(variable): + continue + try: + result = subprocess.run( + ["cmd.exe", "/c", f"echo %{variable}%"], + capture_output=True, text=True, timeout=10, + cwd="/mnt/c" if Path("/mnt/c").is_dir() else None, + ) + except (OSError, subprocess.SubprocessError): + return + value = result.stdout.strip() + if value and not value.startswith("%"): + os.environ[variable] = str(_host_path(value)) + + def _expand_retroarch_path(value: str, app_dir: Path) -> Path: """Expand the notations RetroArch writes into its config values. @@ -446,7 +490,7 @@ def _expand_retroarch_path(value: str, app_dir: Path) -> Path: return Path(str(Path.home())) / value[2:] if value[:1] == ":": return app_dir / value[2:] - return Path(os.path.expandvars(os.path.expanduser(value))) + return _host_path(os.path.expandvars(os.path.expanduser(value))) def _retroarch_cfg_dir( @@ -506,7 +550,7 @@ def _windows_retroarch_dirs() -> list[Path]: names under Program Files, every Steam library, and the Emulators folder of an installed ES-DE (es_find_rules.xml lists all of them). """ - candidates = [Path("C:/RetroArch-Win64"), Path("C:/RetroArch")] + candidates = [_host_path("C:/RetroArch-Win64"), _host_path("C:/RetroArch")] for var in ("ProgramFiles", "ProgramFiles(x86)"): root = os.environ.get(var, "") if root: @@ -960,7 +1004,7 @@ def _rule_path(template: str) -> Path: if template.startswith(variable): rest = [part for part in re.split(r"[\\/]", template[len(variable):]) if part] return base.joinpath(*rest) - return Path(template) + return _host_path(template) # The registry's executable rules: (os, platform, glob under $HOME or @@ -3501,6 +3545,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(f"{term.bold('RetroBIOS')} {term.dim(installer_id())}\n") os_type = detect_os() + if os_type == "wsl": + _enter_wsl() # Early exit for listing if args.list_platforms: diff --git a/install.sh b/install.sh index b4be73f4..2545286f 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="d86244b95c49d37d3950e0547fe13fa6df589dc21ba9868d46da4ab305522b3f" +DEFAULT_INSTALL_SHA256="142edea98e646ed11e27052d3dd02125a27d4cdb7ee04392e6af8fcd28f9762f" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index 24bc7360..e11887ef 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2786,5 +2786,45 @@ class APartialIsClosedBeforeItIsDeleted(unittest.TestCase): self.assertLess(branch.index("partial.close()"), branch.index("tmp_path.unlink()")) +class WindowsSeenFromWsl(unittest.TestCase): + """Under WSL, C:\\RetroBat\\bios was a relative path, %APPDATA% and + %USERPROFILE% were unset, and --platform retrobat wrote 7 753 files into + ~/bios inside the distribution, where RetroBat reads nothing.""" + + def setUp(self): + self.root = Path(_tempdir(self)) + patcher = unittest.mock.patch.dict(install._WSL, {"active": True, "root": str(self.root)}) + patcher.start() + self.addCleanup(patcher.stop) + + def test_a_drive_path_is_its_mount(self): + self.assertEqual(install._host_path("C:\\RetroBat\\bios"), self.root / "c" / "RetroBat" / "bios") + self.assertEqual(install._rule_path("C:\\RetroBat\\bios"), self.root / "c" / "RetroBat" / "bios") + self.assertEqual(install._default_dest("wsl", "retrobat"), self.root / "c" / "RetroBat" / "bios") + self.assertEqual(install._host_path("relative/path"), Path("relative/path")) + + def test_retrobat_on_the_windows_side_is_detected(self): + (self.root / "c" / "RetroBat" / "bios").mkdir(parents=True) + found: list = [] + install._detect_by_path("wsl", found) + self.assertIn(("retrobat", self.root / "c" / "RetroBat" / "bios"), found) + + def test_the_windows_variables_are_read_through_cmd(self): + answers = {"USERPROFILE": "C:\\Users\\me", "APPDATA": "C:\\Users\\me\\AppData\\Roaming"} + + def cmd(argv, **_kwargs): + name = argv[-1].split("%")[1] + return subprocess.CompletedProcess(argv, 0, answers.get(name, f"%{name}%") + "\r\n", "") + + cleared = {name: "" for name in install._WINDOWS_VARIABLES} + with unittest.mock.patch.dict(os.environ, cleared), \ + unittest.mock.patch.object(install.subprocess, "run", cmd): + install._enter_wsl() + self.assertEqual(os.environ["USERPROFILE"], str(self.root / "c" / "Users" / "me")) + self.assertEqual(os.environ["APPDATA"], + str(self.root / "c" / "Users" / "me" / "AppData" / "Roaming")) + self.assertEqual(os.environ["ProgramFiles"], "") + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 64f1ad7e..e7e14283 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -291,6 +291,13 @@ not already found that way. Desktop installs are read from their own configuration, and several can be found on one machine. +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 +`/mnt/c/RetroBat/bios`. + | Evidence | Platform | BIOS directory | |----------|----------|----------------| | `~/.config/EmuDeck/settings.sh` | EmuDeck | `emulationPath` + `/bios` |