chore: merge origin/main

This commit is contained in:
Abdessamad Derraz committed 2026-11-10 00:50:05 +01:00
commit 2dc427d5d0
106 files changed
+5458 -13655

No files matched your search

+145 -18
View File
@@ -3141,6 +3141,72 @@ def fold_case_twins(files: list[dict]) -> tuple[list[dict], list[str]]:
return kept, folded
# The characters FAT and exFAT refuse in a name, as Windows does on any drive
# (Linux 7475ea8b, fs/fat/namei_vfat.c:201-207 and fs/exfat/nls.c:396-403;
# "Naming Files, Paths, and Namespaces" in the Win32 documentation). The
# slash, the backslash and the controls never reach a destination
# (_safe_relative_path). RetroDECK names three files zxs:48.rom,
# zxs:if1-2.rom and snes:st018.data.rom (its retroarch component manifest):
# on an exFAT card each write failed, on every run.
FAT_REFUSED = re.compile(r'[<>:"|?*]')
FAT_REFUSED_SHOWN = '< > : " | ? *'
def holds_fat_refused_names(directory: Path) -> bool:
"""Whether a name holding a colon can be created there.
Probed on the nearest existing directory, as keeps_case is: an ext4
card on a Steam Deck holds one, an exFAT card refuses it with EINVAL.
Windows refuses it on every drive. A probe refused for another cause
(a folder this user cannot write) decides nothing.
"""
if os.name == "nt":
return False
probe_dir = directory
while not probe_dir.is_dir() and probe_dir != probe_dir.parent:
probe_dir = probe_dir.parent
try:
handle, name = tempfile.mkstemp(prefix=".retrobios-name-", suffix=":",
dir=str(probe_dir))
except OSError as exc:
return exc.errno != errno.EINVAL
os.close(handle)
os.unlink(name)
return True
def split_fat_refused(files: list[dict]) -> tuple[list[dict], list[str]]:
"""The entries whose destination a FAT, exFAT or Windows folder can
hold, and the destinations it cannot."""
kept = [entry for entry in files if not FAT_REFUSED.search(str(entry.get("dest", "")))]
refused = [str(entry.get("dest", "")) for entry in files
if FAT_REFUSED.search(str(entry.get("dest", "")))]
return kept, refused
# Android's media scanner marks a folder holding a .nomedia file hidden, and
# every folder below it (MediaProvider a183e2b9, FileUtils.java:1587-1629
# isDirectoryHidden, ModernMediaScanner.java:845-846 and 1017-1020). Without
# one, the samples, overlays and videos of a BIOS folder in shared storage
# showed in the phone's gallery and music apps. RetroArch writes none
# (libretro-common/file/file_path_io.c:194 only skips one).
NOMEDIA = ".nomedia"
def hide_from_media_scanner(folder: Path) -> bool:
"""Leave an empty .nomedia in folder, True when this call wrote it."""
marker = folder / NOMEDIA
if not _is_dir(folder) or _exists(marker):
return False
try:
marker.touch()
except OSError as exc:
print(f" {NOMEDIA} not written in {shown_path(folder)}: {exc.strerror}",
file=sys.stderr)
return False
return True
def installed_cores(dirs: list[Path]) -> list[str]:
"""The cores present in these directories, by the name the buildbot uses.
@@ -7930,8 +7996,13 @@ def _install_data_member(
def select_data_directories(manifest: dict, files: list[dict], narrowed: bool,
required_only: bool = False,
games: GamesPlan | None = None) -> list[dict]:
games: GamesPlan | None = None,
systems: list[str] | None = None) -> list[dict]:
"""The data trees this install keeps: all, or those of a kept system.
systems, the systems chosen by name, decide when given: a file several
systems share named them all, and the arcade choice took blueMSX's
machines through FBNeo's msx.zip, which the pack built with --system
arcade leaves out.
--required-only leaves out a tree every core reading it runs without
(`optional`): a GBA and a SNES game took FBNeo's cheats and samples,
3,472 files no game opened. For the games of a card, a tree only cores
@@ -7945,8 +8016,14 @@ def select_data_directories(manifest: dict, files: list[dict], narrowed: bool,
or set(e.get("systems") or []) & games.any_core]
if not narrowed:
return entries
kept = {system for f in files for system in entry_systems(f)}
return [e for e in entries if kept & set(e.get("systems") or [])]
kept = (set(systems) if systems else
{system for f in files for system in entry_systems(f)})
# A tree no platform system serves goes with the files of none, as the
# pack built with --system puts it in the part of its own.
other = (OTHER_SYSTEMS in kept if systems else
any(not entry_systems(f) for f in files))
return [e for e in entries if kept & set(e.get("systems") or [])
or (other and not e.get("systems"))]
def _copy_targets(
@@ -9375,7 +9452,8 @@ def _prompt_custom_selection(
kept_trees = select_data_directories({"data_directories": trees}, kept,
narrowed or current is not None,
isinstance(current, GamesChoice) and current.required,
current.plan if isinstance(current, GamesChoice) else None)
current.plan if isinstance(current, GamesChoice) else None,
chosen_systems(current))
if kept and all_in_place(kept, kept_trees, bios_path):
# Read before the menu, as the screen reads before it asks: a
# rerun was offered an install that installs nothing, then told
@@ -9506,7 +9584,8 @@ def _print_menu_head(
kept_trees = select_data_directories({"data_directories": trees}, shown,
narrowed or current is not None,
isinstance(current, GamesChoice) and current.required,
current.plan if isinstance(current, GamesChoice) else None)
current.plan if isinstance(current, GamesChoice) else None,
chosen_systems(current))
data = sum(len(entry.get("files") or {}) for entry in kept_trees)
data_size = sum(meta.get("size", 0) for entry in kept_trees
for meta in (entry.get("files") or {}).values())
@@ -9692,6 +9771,11 @@ def read_in_place(pairs: list[tuple[dict, Path]]) -> list[str]:
return verdicts
def chosen_systems(choice: object) -> list[str] | None:
"""The systems a choice names, None for one that names none."""
return list(choice[0]) or None if isinstance(choice, tuple) else None
def menu_choice(
files: list[dict], omitted: list[dict],
installed: tuple[list[dict], list[dict]] | None, current: object, cut: bool,
@@ -10317,6 +10401,13 @@ def games_setups(index: dict, plat: str, bios_path: Path, os_type: str = "") ->
if not named:
above = card_root_above(bios_path)
named = above[0] if above else ""
if not named:
# --platform names the list, not the card: the full screen passes
# --platform=retroarch for a muOS card, detection never runs in that
# run, and the card's ROMS/ was never read. The card's own files
# name its system, as card_app reads them for the app.
card = card_app(bios_path)
named = card.system if card else ""
if not named:
# --dest on a handheld's games part from a computer (ArkOS's
# EASYROMS/bios): detection never ran there, and the card's own rom
@@ -16000,6 +16091,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
f" {_plural(len(folded), 'file')} left out: {bios_path} does not keep "
f"case and holds another file under the same name ({shown})"
)
if any(FAT_REFUSED.search(str(entry.get("dest", ""))) for entry in files) \
and not holds_fat_refused_names(bios_path):
files, refused = split_fat_refused(files)
shown = ", ".join(refused[:3]) + (" ..." if len(refused) > 3 else "")
print(
f" {_plural(len(refused), 'file')} left out: the file system of "
f"{shown_path(bios_path)} cannot hold a name with any of "
f"{FAT_REFUSED_SHOWN} ({screen_text(shown)})"
)
game_data_cut = 0
if args.no_game_data and not manifest.get("categorized"):
@@ -16309,7 +16409,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
data_dirs = select_data_directories(manifest, files, narrowed,
bool(args.required_only),
games_plan if games_applied else None)
games_plan if games_applied else None,
chosen_systems(selection))
# The kinds as the plan and the menu head say them (parts_words),
# the data files of the folders kept among them: this head said
# BIOS files and game data beside a plan of three kinds, neither
@@ -16759,6 +16860,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
failures.append((where, f"{NOT_KEPT} ({reason})"))
total_errors += 1
if os_type == "android" and not args.check and hide_from_media_scanner(bios_path):
print(f" Added {NOMEDIA} to {shown_path(bios_path)}: the gallery and music "
"apps leave its images, sounds and videos out")
# Standalone copies
if (
manifest.get("standalone_copies")
@@ -18717,8 +18822,9 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
# The size the pack can give: "Install 6.6 GB" from a pack holding
# none of the files.
lacking = set(_tui_pack_lacks(state, [event["dest"] for event in state.files]))
size = check.get("bytes_needed", 0) - sum(event["size"] for event in state.files
if event["dest"] in lacking)
size = (_ticked_estimate(state)[1] if _tui_estimating(state)
else check.get("bytes_needed", 0) - sum(event["size"] for event in state.files
if event["dest"] in lacking))
label = (f"{TUI_INSTALL_ROW} {format_size(size)}" if size >= CONFIRM_FROM_BYTES
else TUI_INSTALL_ROW)
rows = [TuiRow("install", label)]
@@ -22258,20 +22364,17 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
# The headline counts the systems ticked, as the list just did: at
# 40x12 the line below it was dropped, and the plan showed the
# total of all of them. Their data files too, as line mode's head.
chosen = _ticked_files(state)
# One total, the data files in it, the kinds under the folder
# adding up to it.
lines[0] = _styled(f"About {_plural(len(chosen) + data, 'file')}, "
f"{format_size(sum(e['size'] for e in chosen) + (data_size or 0))}"
count, size = _ticked_estimate(state)
lines[0] = _styled(f"About {_plural(count, 'file')}, {format_size(size)}"
f", for {len(state.ticked)} of {len(state.systems)} systems", "title")
if _offline_only():
# Offline, what the folders copied from hold of the systems
# ticked: the plan counted the whole list beside "For your 1 of
# 11 systems".
took = {event["dest"] for events in _tui_found_elsewhere(state).values()
for event in events}
held = [event for event in chosen if event["dest"] in took]
rest = len(chosen) - len(held) + data
held = _ticked_held(state)
rest = len(_ticked_files(state)) - len(held) + data
# Nothing at hand: the head the whole list's plan says, for the
# systems ticked. "About 0 files, 0 B" stood over 20 files the
# internet would bring.
@@ -22279,8 +22382,8 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
f"{agree(rest, 'file needs', 'files need')} the internet, which "
f"{offline_named()} does not use, for {len(state.ticked)} of "
f"{len(state.systems)} systems", "title") if not held else _styled(
f"About {_plural(len(held), 'file')}, "
f"{format_size(sum(e['size'] for e in held))}, for {len(state.ticked)} of "
f"About {_plural(count, 'file')}, "
f"{format_size(size)}, for {len(state.ticked)} of "
f"{len(state.systems)} systems"
+ (f", {agree(rest, 'needs', 'need')} the internet "
f"({offline_named()})" if rest else ""), "title")
@@ -22465,7 +22568,10 @@ def _plan_data(state: TuiState, check: dict) -> tuple[int, int | None]:
data = check.get("data_missing", 0)
return data, check.get("data_bytes") if data else None
required = any(flag == "--required-only" for flag, _value in tui_flags(state))
trees = select_data_directories(state.manifest or {}, _choice_entries(state), True, required)
named = [name for _flag, name in _tui_system_flags(state) if name]
known = {row["system"] for row in state.systems}
trees = select_data_directories(state.manifest or {}, _choice_entries(state), True, required,
systems=named if named and set(named) <= known else None)
sizes = [meta.get("size", 0) for entry in trees for meta in (entry.get("files") or {}).values()]
return len(sizes), sum(sizes)
@@ -22525,6 +22631,27 @@ def _ticked_files(state: TuiState) -> list[dict]:
or (not event["systems"] and OTHER_SYSTEMS in state.ticked)]
def _ticked_held(state: TuiState) -> list[dict]:
"""Offline, the files of the systems ticked that the folders copied
from hold: all an install without the internet brings."""
took = {event["dest"] for events in _tui_found_elsewhere(state).values()
for event in events}
return [event for event in _ticked_files(state) if event["dest"] in took]
def _ticked_estimate(state: TuiState) -> tuple[int, int]:
"""The files and bytes the systems ticked since the check bring, their
data files in them, or offline what the folders copied from hold: the
plan's headline and its Install row both say it. One system ticked,
the headline said 82.2 MB and Install 6.6 GB, the whole list's size."""
if _offline_only():
held = _ticked_held(state)
return len(held), sum(event["size"] for event in held)
chosen = _ticked_files(state)
data, data_size = _plan_data(state, state.check or {})
return len(chosen) + data, sum(event["size"] for event in chosen) + (data_size or 0)
def _view_systems(state: TuiState, width: int, height: int) -> tuple:
rows = tui_rows(state)
whole_rows = rows