mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 13:33:24 -05:00
fix: resolve shell values in installer configs
This commit is contained in:
1 parent
e90095abd9
commit
4f6773bcc4
2 files changed
+255
-13
No files matched your search
+84
-13
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user