From 4f6773bcc4a178bd008760ee533d8b57f5749fca Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:00:50 +0200 Subject: [PATCH] fix: resolve shell values in installer configs --- install.py | 97 ++++++++++++++++++++---- tests/test_install.py | 171 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 tests/test_install.py diff --git a/install.py b/install.py index a046facc..dbf47acd 100644 --- a/install.py +++ b/install.py @@ -39,6 +39,16 @@ RELEASE_URL = ( ) MAX_RETRIES = 3 +# Fallback BIOS destination per platform when --platform is forced +# but auto-detection finds nothing on the machine. +DEFAULT_DESTS = { + "batocera": Path("/userdata/bios"), + "recalbox": Path("/recalbox/share/bios"), + "lakka": Path("/storage/system"), + "retrodeck": Path.home() / "retrodeck", + "emudeck": Path.home() / "Emulation" / "bios", +} + def detect_os() -> str: """Return normalized OS identifier.""" @@ -98,8 +108,46 @@ def _parse_retroarch_system_dir(cfg_path: Path) -> Path | None: return None +def _shell_unquote(value: str) -> str: + """Resolve a shell right-hand side into its effective string value. + + Handles concatenated quoted/unquoted segments the way bash does + (e.g. "/run/media/deck/EmuSD"/Emulation), expands variables in + double-quoted and unquoted segments, and stops at unquoted + whitespace or a comment. + """ + parts: list[str] = [] + i = 0 + n = len(value) + while i < n: + c = value[i] + if c == '"': + end = value.find('"', i + 1) + if end == -1: + parts.append(os.path.expandvars(value[i + 1:])) + break + parts.append(os.path.expandvars(value[i + 1:end])) + i = end + 1 + elif c == "'": + end = value.find("'", i + 1) + if end == -1: + parts.append(value[i + 1:]) + break + parts.append(value[i + 1:end]) + i = end + 1 + elif c.isspace() or c == "#": + break + else: + end = i + while end < n and value[end] not in "\"'#" and not value[end].isspace(): + end += 1 + parts.append(os.path.expandvars(value[i:end])) + i = end + return os.path.expanduser("".join(parts)) + + def _parse_bash_var(path: Path, key: str) -> str | None: - """Extract value of key= from a bash/shell file.""" + """Extract the effective value of key= from a bash/shell file.""" if not path.exists(): return None try: @@ -107,12 +155,25 @@ def _parse_bash_var(path: Path, key: str) -> str | None: line = line.strip() if line.startswith(f"{key}="): _, _, value = line.partition("=") - return value.strip('"').strip("'") + return _shell_unquote(value) except OSError: pass return None +def _parse_json_path(path: Path, *keys: str) -> str | None: + """Extract a nested string value from a JSON file.""" + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8", errors="replace")) + for key in keys: + data = data[key] + return data if isinstance(data, str) and data else None + except (OSError, json.JSONDecodeError, KeyError, TypeError): + return None + + def _parse_ps1_var(path: Path, key: str) -> str | None: """Extract value of $key= or key= from a PowerShell file.""" if not path.exists(): @@ -152,7 +213,10 @@ def _detect_embedded() -> list[tuple[str, Path]]: found.append(("batocera", Path("/userdata/bios"))) return found - if Path("/usr/bin/recalbox-settings").exists(): + if ( + Path("/recalbox/recalbox.version").exists() + or Path("/usr/bin/recalbox-settings").exists() + ): found.append(("recalbox", Path("/recalbox/share/bios"))) return found @@ -187,14 +251,17 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]: bios_dir = Path(emu_path) / "bios" found.append(("emudeck", bios_dir)) - # RetroDECK - retrodeck_cfg = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck" / "retrodeck.cfg" - if retrodeck_cfg.exists(): - bios_path = _parse_bash_var(retrodeck_cfg, "rdhome") - if bios_path: - found.append(("retrodeck", Path(bios_path))) - else: - found.append(("retrodeck", home / "retrodeck")) + # RetroDECK: retrodeck.json since the cfg-to-json migration, which + # renames the old retrodeck.cfg to retrodeck.bak (global.sh:149-153) + retrodeck_conf_dir = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck" + retrodeck_json = retrodeck_conf_dir / "retrodeck.json" + retrodeck_cfg = retrodeck_conf_dir / "retrodeck.cfg" + if retrodeck_json.exists(): + rd_home = _parse_json_path(retrodeck_json, "paths", "rd_home_path") + found.append(("retrodeck", Path(rd_home) if rd_home else home / "retrodeck")) + elif retrodeck_cfg.exists(): + rd_home = _parse_bash_var(retrodeck_cfg, "rdhome") + found.append(("retrodeck", Path(rd_home) if rd_home else home / "retrodeck")) # RetroArch Flatpak flatpak_cfg = home / ".var" / "app" / "org.libretro.RetroArch" / "config" / "retroarch" / "retroarch.cfg" @@ -572,8 +639,12 @@ def main() -> None: if matched: platforms = matched else: - print(f" Platform '{args.platform}' not detected, using default path.") - platforms = [(args.platform, Path.home() / "bios")] + default_dest = DEFAULT_DESTS.get(args.platform, Path.home() / "bios") + print( + f" Platform '{args.platform}' not detected, " + f"using default path: {default_dest}" + ) + platforms = [(args.platform, default_dest)] elif args.dest: print(f" Using destination: {args.dest}") platforms = [("retroarch", args.dest)] diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 00000000..0531fff8 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,171 @@ +"""Tests for install.py platform detection and config parsing.""" +from __future__ import annotations + +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py") +install = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(install) + + +class TestShellUnquote(unittest.TestCase): + def test_plain_value(self): + self.assertEqual(install._shell_unquote("/userdata/bios"), "/userdata/bios") + + def test_double_quoted(self): + self.assertEqual(install._shell_unquote('"/home/deck/Emulation"'), "/home/deck/Emulation") + + def test_single_quoted(self): + self.assertEqual(install._shell_unquote("'/home/deck/Emulation'"), "/home/deck/Emulation") + + def test_concatenated_quoted_unquoted(self): + # EmuDeck SD card layout (issue: quote left in the middle of the path) + self.assertEqual( + install._shell_unquote('"/run/media/deck/EmuSD"/Emulation'), + "/run/media/deck/EmuSD/Emulation", + ) + + def test_concatenated_preserves_spaces_in_quotes(self): + self.assertEqual( + install._shell_unquote('"/run/media/deck/My Card"/Emulation'), + "/run/media/deck/My Card/Emulation", + ) + + def test_home_variable_expansion(self): + old = os.environ.get("HOME") + os.environ["HOME"] = "/home/deck" + try: + self.assertEqual( + install._shell_unquote('"$HOME"/Emulation'), + "/home/deck/Emulation", + ) + self.assertEqual( + install._shell_unquote('"${HOME}/Emulation"'), + "/home/deck/Emulation", + ) + finally: + if old is not None: + os.environ["HOME"] = old + + def test_single_quotes_do_not_expand(self): + self.assertEqual(install._shell_unquote("'$HOME/x'"), "$HOME/x") + + def test_trailing_comment(self): + self.assertEqual(install._shell_unquote("/data/bios # main dir"), "/data/bios") + + def test_unquoted_stops_at_whitespace(self): + self.assertEqual(install._shell_unquote("/data/bios extra"), "/data/bios") + + def test_tilde_expansion(self): + old = os.environ.get("HOME") + os.environ["HOME"] = "/home/deck" + try: + self.assertEqual(install._shell_unquote("~/Emulation"), "/home/deck/Emulation") + finally: + if old is not None: + os.environ["HOME"] = old + + +class TestParseBashVar(unittest.TestCase): + def _write(self, content: str) -> Path: + fd, path = tempfile.mkstemp(suffix=".sh") + os.close(fd) + Path(path).write_text(content, encoding="utf-8") + self.addCleanup(os.unlink, path) + return Path(path) + + def test_emudeck_sd_card_concatenation(self): + path = self._write( + 'emulationPath="/run/media/deck/EmuSD"/Emulation\n' + 'biosPath="/run/media/deck/EmuSD"/Emulation/bios\n' + ) + self.assertEqual( + install._parse_bash_var(path, "emulationPath"), + "/run/media/deck/EmuSD/Emulation", + ) + self.assertEqual( + install._parse_bash_var(path, "biosPath"), + "/run/media/deck/EmuSD/Emulation/bios", + ) + + def test_emudeck_home_variable(self): + old = os.environ.get("HOME") + os.environ["HOME"] = "/home/deck" + try: + path = self._write('emulationPath="$HOME"/Emulation\n') + self.assertEqual( + install._parse_bash_var(path, "emulationPath"), + "/home/deck/Emulation", + ) + finally: + if old is not None: + os.environ["HOME"] = old + + def test_missing_key(self): + path = self._write("other=1\n") + self.assertIsNone(install._parse_bash_var(path, "emulationPath")) + + def test_missing_file(self): + self.assertIsNone( + install._parse_bash_var(Path("/nonexistent/settings.sh"), "emulationPath") + ) + + +class TestParseJsonPath(unittest.TestCase): + def _write(self, data) -> Path: + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + Path(path).write_text( + data if isinstance(data, str) else json.dumps(data), encoding="utf-8" + ) + self.addCleanup(os.unlink, path) + return Path(path) + + def test_retrodeck_rd_home_path(self): + path = self._write( + {"version": "1.0", "paths": {"rd_home_path": "/home/deck/retrodeck"}} + ) + self.assertEqual( + install._parse_json_path(path, "paths", "rd_home_path"), + "/home/deck/retrodeck", + ) + + def test_missing_key_returns_none(self): + path = self._write({"paths": {}}) + self.assertIsNone(install._parse_json_path(path, "paths", "rd_home_path")) + + def test_invalid_json_returns_none(self): + path = self._write("{not json") + self.assertIsNone(install._parse_json_path(path, "paths", "rd_home_path")) + + def test_empty_value_returns_none(self): + path = self._write({"paths": {"rd_home_path": ""}}) + self.assertIsNone(install._parse_json_path(path, "paths", "rd_home_path")) + + def test_missing_file(self): + self.assertIsNone( + install._parse_json_path(Path("/nonexistent/rd.json"), "paths", "rd_home_path") + ) + + +class TestDefaultDests(unittest.TestCase): + def test_retrodeck_defaults_to_rdhome(self): + self.assertEqual( + install.DEFAULT_DESTS["retrodeck"], Path.home() / "retrodeck" + ) + + def test_known_fixed_paths(self): + self.assertEqual(install.DEFAULT_DESTS["batocera"], Path("/userdata/bios")) + self.assertEqual( + install.DEFAULT_DESTS["recalbox"], Path("/recalbox/share/bios") + ) + + +if __name__ == "__main__": + unittest.main()