diff --git a/install.ps1 b/install.ps1 index 7f60ad69..a4f26313 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 = "1e06e5d364d93beff2f9b98f7bf81b4eca4b6364a6a99c87e8fcc9a472c70764" +$defaultInstallSha256 = "1cb76eb63b57e1390bfb4b6c2b24a5991819541414150aa88edc76d1d1188cd6" $maximumInstallerBytes = 2MB $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $temporary = $null diff --git a/install.py b/install.py index e6ed0c09..873e1d27 100644 --- a/install.py +++ b/install.py @@ -588,6 +588,20 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]: if ra_dir: found.append(("retroarch", ra_dir)) + # RetroArch from Steam: a portable install under the default library + for var in ("ProgramFiles(x86)", "ProgramFiles"): + program_files = os.environ.get(var, "") + if not program_files: + continue + steam_cfg = ( + Path(program_files) / "Steam" / "steamapps" / "common" + / "RetroArch" / "retroarch.cfg" + ) + ra_dir = _parse_retroarch_system_dir(steam_cfg) + if ra_dir and not any(name == "retroarch" for name, _ in found): + found.append(("retroarch", ra_dir)) + break + # Portable RetroArch referenced by LaunchBox lb_root = launchbox_root(os_type) if lb_root and not any(name == "retroarch" for name, _ in found): @@ -676,15 +690,16 @@ def _safe_target_dir(value: object, field: str) -> str: def _destination_path(root: Path, value: object) -> Path: - """Resolve a manifest destination and prove it remains below *root*.""" + """Place a manifest destination below *root*. + + The manifest cannot climb: _safe_relative_path refuses absolute paths, + drive letters and '..'. Symbolic links met under the root are the + user's own layout and are followed. EmuDeck links bios/shadps4/sys_modules + into shadPS4's data directory, and the file has to land where the + emulator reads it. + """ relative = _safe_relative_path(value, "dest") - resolved_root = root.resolve() - candidate = (resolved_root / Path(*relative.parts)).resolve() - try: - candidate.relative_to(resolved_root) - except ValueError as exc: - raise ValueError(f"destination escapes BIOS directory: {value!r}") from exc - return candidate + return root.resolve() / Path(*relative.parts) def _validate_manifest(data: object, plat: str) -> dict: @@ -1172,6 +1187,53 @@ def format_size(n: int) -> str: return f"{n / (1024 * 1024 * 1024):.1f} GB" +def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]: + """Ask for a platform and a BIOS directory when nothing was detected.""" + print("\nChoose the platform to install for:") + for i, name in enumerate(AVAILABLE_PLATFORMS, 1): + print(f" {i}) {name}") + print(" q) quit") + print() + plat = "" + while not plat: + try: + choice = input("> ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + sys.exit(0) + if choice == "q": + sys.exit(0) + if choice.isdigit() and 1 <= int(choice) <= len(AVAILABLE_PLATFORMS): + plat = AVAILABLE_PLATFORMS[int(choice) - 1] + elif choice in AVAILABLE_PLATFORMS: + plat = choice + default_dest = DEFAULT_DESTS.get(plat, Path.home() / "bios") + try: + raw = input(f"BIOS directory [{default_dest}]: ").strip() + except (EOFError, KeyboardInterrupt): + print() + sys.exit(0) + dest = Path(raw).expanduser() if raw else default_dest + if os_type == "windows" and raw: + dest = Path(os.path.expandvars(raw)) + return [(plat, dest)] + + +def _manual_usage_hint(os_type: str) -> list[str]: + """How to name the platform and directory when nothing is detected.""" + if os_type == "windows": + return [ + " Use --platform and --dest to specify manually, for example:", + " & ([ScriptBlock]::Create((iwr -useb " + "https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1))) " + "--platform retroarch --dest 'C:\\RetroArch\\system'", + ] + return [ + " Use --platform and --dest to specify manually, for example:", + " python3 install.py --platform retroarch --dest ~/.config/retroarch/system", + ] + + def _prompt_platform_choice( platforms: list[tuple[str, Path]], ) -> list[tuple[str, Path]]: @@ -1301,8 +1363,11 @@ def main() -> None: platforms = detect_platforms(os_type) if not platforms: print(" No supported platform detected.") - print(" Use --platform and --dest to specify manually.") - sys.exit(1) + if not sys.stdin.isatty() or args.list_targets: + for line in _manual_usage_hint(os_type): + print(line) + sys.exit(1) + platforms = _prompt_manual_platform(os_type) for name, path in platforms: print(f" Found {name.capitalize()} at {path}") diff --git a/install.sh b/install.sh index d478268d..96ff02bf 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="1e06e5d364d93beff2f9b98f7bf81b4eca4b6364a6a99c87e8fcc9a472c70764" +DEFAULT_INSTALL_SHA256="1cb76eb63b57e1390bfb4b6c2b24a5991819541414150aa88edc76d1d1188cd6" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index b58d4770..ac3ee4db 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -730,6 +730,93 @@ class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase): @unittest.skipUnless(shutil.which("pwsh"), "pwsh not available") +class TestDestinationFollowsUserLinks(unittest.TestCase): + """EmuDeck links bios/shadps4/sys_modules into shadPS4's own directory. + + The manifest still cannot climb out of the BIOS root, but a link the + user's frontend placed under it is where the emulator reads the file. + """ + + def test_symlinked_directory_under_root_is_followed(self): + bios = Path(tempfile.mkdtemp()) + elsewhere = Path(tempfile.mkdtemp()) / "sys_modules" + elsewhere.mkdir() + (bios / "shadps4").mkdir() + (bios / "shadps4" / "sys_modules").symlink_to(elsewhere) + + dest = install._destination_path(bios, "shadps4/sys_modules/libSceLibcInternal.sprx") + + self.assertEqual(dest, bios.resolve() / "shadps4" / "sys_modules" / "libSceLibcInternal.sprx") + self.assertEqual(dest.resolve().parent, elsewhere.resolve()) + + def test_check_local_survives_a_linked_directory(self): + bios = Path(tempfile.mkdtemp()) + elsewhere = Path(tempfile.mkdtemp()) / "sys_modules" + elsewhere.mkdir() + (bios / "shadps4").mkdir() + (bios / "shadps4" / "sys_modules").symlink_to(elsewhere) + entry = {"dest": "shadps4/sys_modules/x.sprx", "size": 1, "sha1": "0" * 40} + + to_download, up_to_date, mismatched = install.check_local([entry], bios) + + self.assertEqual([e["dest"] for e in to_download], ["shadps4/sys_modules/x.sprx"]) + self.assertEqual((up_to_date, mismatched), ([], [])) + + def test_climbing_is_still_refused(self): + bios = Path(tempfile.mkdtemp()) + with self.assertRaises(ValueError): + install._destination_path(bios, "../outside.bin") + with self.assertRaises(ValueError): + install._destination_path(bios, "/etc/passwd") + + +class TestManualPlatformPrompt(unittest.TestCase): + """With nothing detected, an interactive run asks instead of exiting.""" + + def test_number_picks_the_platform_and_default_directory(self): + answers = iter(["1", ""]) + with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)): + chosen = install._prompt_manual_platform("linux") + self.assertEqual(chosen, [("retroarch", Path.home() / "bios")]) + + def test_name_and_explicit_directory(self): + answers = iter(["batocera", "~/my bios"]) + with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)): + chosen = install._prompt_manual_platform("linux") + self.assertEqual(chosen, [("batocera", Path("~/my bios").expanduser())]) + + def test_default_directory_comes_from_the_table(self): + answers = iter(["batocera", ""]) + with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)): + chosen = install._prompt_manual_platform("linux") + self.assertEqual(chosen, [("batocera", install.DEFAULT_DESTS["batocera"])]) + + def test_quit_exits_cleanly(self): + with unittest.mock.patch("builtins.input", lambda _prompt="": "q"): + with self.assertRaises(SystemExit) as raised: + install._prompt_manual_platform("linux") + self.assertEqual(raised.exception.code, 0) + + def test_windows_hint_shows_the_one_liner_with_arguments(self): + hint = "\n".join(install._manual_usage_hint("windows")) + self.assertIn("--platform retroarch --dest", hint) + self.assertIn("install.ps1", hint) + + +class TestSteamRetroarchDetection(unittest.TestCase): + """The Steam build is a portable install under the default library.""" + + def test_steam_library_is_found_on_windows(self): + program_files = Path(tempfile.mkdtemp()) + ra_dir = program_files / "Steam" / "steamapps" / "common" / "RetroArch" + ra_dir.mkdir(parents=True) + (ra_dir / "retroarch.cfg").write_text('system_directory = ":\\system"\n') + env = {"ProgramFiles(x86)": str(program_files)} + with unittest.mock.patch.dict(os.environ, env, clear=True): + found = install.detect_platforms("windows") + self.assertIn(("retroarch", ra_dir / "system"), found) + + class TestLaunchboxDetectionPowershell(unittest.TestCase): """install.ps1 must resolve LaunchBox's portable RetroArch on its own."""