fix: follow linked dirs and ask for a platform

This commit is contained in:
Abdessamad Derraz committed 2026-09-03 17:51:08 +02:00
1 parent d66e891051
commit 3d9df87dd0
4 files changed
+164 -12

No files matched your search

+1 -1
View File
@@ -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
+75 -10
View File
@@ -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}")
+1 -1
View File
@@ -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() {
+87
View File
@@ -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."""