fix: harden the full-screen installer

Control characters from names, folders and the installer's own text
are shown as ?, on screen and in the lines left after it. The terminal
mode and the signal handlers come back even when the last write fails
or a second signal arrives. A stop from outside hands the terminal
back, the second Ctrl-C is announced, speed reads the last seconds,
values are glued to their options, and counts read before a change of
options say so.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 15:35:28 +02:00
1 parent 75743fde24
commit 5a8fb2248f
9 files changed
+499 -71

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "2606f825ea887d70d58c8938fa726bd92236161b907159d34643bff24ca349fc"
$defaultInstallSha256 = "d0a92cac2d09da791fe3dbacdad37ec7e40b5a3685a39e3cc02f0dcbd5a556e6"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+199 -54
View File
@@ -20,6 +20,7 @@ import concurrent.futures
import contextlib
import datetime
import errno
import functools
import hashlib
import http.client
import io
@@ -8190,19 +8191,28 @@ class KeyDecoder:
return self._drain(final=False)
def flush(self) -> list[TuiKey]:
"""What is left once the wait for the rest of a sequence is over."""
"""What is left once the wait for the rest of a sequence is over.
An open paste stays open: over SSH its end arrives later than 40 ms.
"""
return self._drain(final=True)
def close_paste(self) -> list[TuiKey]:
"""The paste so far, its end never seen, without its newlines."""
text = "".join(char for char in self.paste or "" if char >= " " and char != "\x7f")
self.paste = None
return [TuiKey("paste", text)] if text else []
@property
def waiting(self) -> bool:
return bool(self.buffer) or self.paste is not None
def _drain(self, final: bool) -> list[TuiKey]:
keys: list[TuiKey] = []
while self.buffer or (final and self.paste is not None):
while self.buffer:
if self.paste is not None:
end = self.buffer.find(_PASTE_END)
if end < 0 and not final:
if end < 0:
# The end marker may be cut between two reads.
keep = next((n for n in range(len(_PASTE_END) - 1, 0, -1)
if self.buffer.endswith(_PASTE_END[:n])), 0)
@@ -8210,14 +8220,9 @@ class KeyDecoder:
self.paste += self.buffer[:cut]
self.buffer = self.buffer[cut:]
break
skip = len(_PASTE_END) if end >= 0 else 0
end = end if end >= 0 else len(self.buffer)
text = self.paste + self.buffer[:end]
self.buffer = self.buffer[end + skip:]
self.paste = None
text = "".join(char for char in text if char >= " " and char != "\x7f")
if text:
keys.append(TuiKey("paste", text))
self.paste += self.buffer[:end]
self.buffer = self.buffer[end + len(_PASTE_END):]
keys += self.close_paste()
continue
if self.buffer[0] == "\x1b":
taken, key = _escape(self.buffer)
@@ -8259,8 +8264,20 @@ def cell_width(text: str) -> int:
return sum(_char_width(char) for char in text)
# C0 and C1 controls, DEL and lone surrogates: a file, folder or core name
# holding ESC would drive the terminal (clear it, or set the clipboard with
# OSC 52) instead of being shown.
_CONTROLS = re.compile("[\x00-\x1f\x7f-\x9f\ud800-\udfff]")
def screen_text(text: str) -> str:
"""Text as a terminal shows it, every control character a "?"."""
return _CONTROLS.sub("?", text)
def fit(text: str, width: int) -> str:
"""The text cut to width cells, never through a character."""
text = screen_text(text)
kept: list[str] = []
used = 0
for char in text:
@@ -8336,7 +8353,7 @@ class TuiLine(str):
def _styled(text: str, style: str = "") -> TuiLine:
line = TuiLine(text)
line = TuiLine(screen_text(text))
line.style = style
return line
@@ -8407,6 +8424,7 @@ class TuiState:
self.ticked: set = set()
self.cli_systems: list = []
self.regions: list = []
self.cli_regions: list = []
self.cores: list = []
self.no_game_data: bool = False
self.installed_only: bool = False
@@ -8415,7 +8433,9 @@ class TuiState:
# The install.
self.download: dict | None = None
self.progress: dict | None = None
self.first_sample: tuple | None = None
# (time, bytes done) of the last progress events: a partial copy
# resumed counts in the first one and never in a speed.
self.samples: list = []
self.finished: list = []
self.summary: dict | None = None
self.code: int | None = None
@@ -8528,11 +8548,11 @@ _TUI_MODES = {
def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]:
"""The child that does what the screen decided."""
argv = [python, script, "--platform", state.platform, "--dest", str(state.dest)]
# --dest=VALUE: a folder typed as -x would otherwise be read as an option.
argv = [python, script, f"--platform={state.platform}", f"--dest={state.dest}"]
if state.manifest_path is not None:
argv += ["--manifest", str(state.manifest_path)]
for flag, value in tui_flags(state):
argv += [flag] if value is None else [flag, value]
argv.append(f"--manifest={state.manifest_path}")
argv += [flag if value is None else f"{flag}={value}" for flag, value in tui_flags(state)]
return argv + _TUI_MODES[run]
@@ -8565,8 +8585,14 @@ def _needed(row: dict) -> int:
def _tui_system_label(row: dict) -> str:
"""The name a person reads: the platform's own when it is one (Sony -
PlayStation), the id with the platform's short name otherwise (psx)."""
name = row.get("name") or row["system"]
return row["system"] if name == row["system"] else f"{row['system']} ({name})"
if name == row["system"]:
return name
if " " in name or any(char.isupper() for char in name):
return name
return f"{row['system']} ({name})"
def _matches(text: str, needle: str) -> bool:
@@ -8624,9 +8650,9 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
if screen == "regions":
ranked = [TuiRow("region", f"[{rank}] {name}", value=name)
for rank, name in enumerate(state.regions, 1)]
known = sorted(set(tui_choices(state)["regions"]) | set(state.cli_regions))
return ranked + [TuiRow("region", f"[ ] {name}", value=name)
for name in tui_choices(state)["regions"]
if name not in state.regions]
for name in known if name not in state.regions]
if screen == "cores":
needle = state.filters.get(screen, "")
return [_filter_row(state)] + [
@@ -8664,7 +8690,7 @@ def _system_rows(state: TuiState) -> list[TuiRow]:
rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)]
for row in _sorted_systems(state):
label = _tui_system_label(row)
if not _matches(label, needle):
if not _matches(f"{label} {row['system']}", needle):
continue
box = "[x]" if row["system"] in state.ticked else "[ ]"
need = _needed(row)
@@ -8675,6 +8701,10 @@ def _system_rows(state: TuiState) -> list[TuiRow]:
return rows
def _cores_dir_given(state: TuiState) -> bool:
return any(flag == "--cores-dir" for flag, _value in state.passthrough)
def _yes(value: bool) -> str:
return "yes" if value else "no"
@@ -8690,7 +8720,10 @@ def _option_rows(state: TuiState) -> list[TuiRow]:
rows.append(TuiRow("game_data", f"Leave out game data: {_yes(state.no_game_data)}",
"--no-game-data"))
found = len(state.facts.get("cores", []))
if state.platform in CORE_PLATFORMS and "system_cores" in manifest and found:
if _cores_dir_given(state):
rows.append(TuiRow("installed", "Installed cores only: yes, implied by --cores-dir",
"--cores-dir"))
elif state.platform in CORE_PLATFORMS and "system_cores" in manifest and found:
rows.append(TuiRow("installed", f"Installed cores only: "
f"{_yes(state.installed_only)} ({found} found)",
"--installed-cores"))
@@ -8728,6 +8761,10 @@ def _back(state: TuiState) -> list[tuple]:
state.focus["quit"] = 0
return effects
state.stack.pop()
if state.screen == "checking" and not state.running:
# Stay after Esc on a check started from the command line: a
# Checking screen nothing feeds counted seconds forever.
return effects + _start_check(state)
return effects
@@ -8762,7 +8799,8 @@ def _start_run(state: TuiState, run: str) -> list[tuple]:
elif run in ("confirm", "preview"):
state.dry, state.dry_files, state.dry_summary = None, [], None
else:
state.download = state.progress = state.first_sample = None
state.download = state.progress = None
state.samples = []
state.finished, state.summary, state.code, state.stopping = [], None, None, False
return [("run", run)]
@@ -8979,7 +9017,7 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]:
"""A row of the options: step 0 is Enter, -1 and 1 are Left and Right."""
if action == "game_data":
state.no_game_data = step > 0 if step else not state.no_game_data
elif action == "installed":
elif action == "installed" and not _cores_dir_given(state):
state.installed_only = step > 0 if step else not state.installed_only
elif action == "jobs":
state.jobs = (state.jobs - 1 + (step or 1)) % 32 + 1
@@ -8999,7 +9037,7 @@ def _toggle_region(state: TuiState, name: str) -> None:
else:
state.regions.append(name)
rows = tui_rows(state)
state.focus["regions"] = next(i for i, row in enumerate(rows) if row.value == name)
state.focus["regions"] = next((i for i, row in enumerate(rows) if row.value == name), 0)
def _move_region(state: TuiState, name: str, step: int) -> None:
@@ -9117,12 +9155,15 @@ def _pager_key(state: TuiState, key: TuiKey) -> list[tuple]:
elif name == "home" or char == "g":
top = 0
elif name == "end" or char == "G":
top = 1 << 30
top = len(_text_lines(_pager_items(state), min(state.cols, TUI_MAX_WIDTH) - 2))
elif name in ("enter", "esc", "backspace") or char == " ":
if screen == "result":
return tui_quit(state)
return _back(state)
state.focus[screen] = max(0, top)
total = len(_text_lines(_pager_items(state), min(state.cols, TUI_MAX_WIDTH) - 2))
height = state.rows - 3
last = total - (height - 1) if total > height else 0
state.focus[screen] = max(0, min(top, last))
return []
@@ -9229,9 +9270,11 @@ def _take_event(state: TuiState, event: dict) -> None:
elif state.run == "install":
if kind == "download":
state.download = event
state.first_sample = (state.now, 0)
elif kind in ("progress", "downloaded"):
state.progress = event
state.samples = [sample for sample in state.samples
if sample[0] >= state.now - 10] + [
(state.now, event["bytes_done"])]
if kind == "downloaded":
state.finished.append((event["dest"], event["ok"], event["reason"]))
elif kind == "summary":
@@ -9494,6 +9537,14 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
(primary, "B/Esc Back", "Y/o Options", "? Help"))
def _counts_predate_options(state: TuiState) -> bool:
"""Whether the check's counts were read under other options than the
ones chosen since: a region or a core changes what each system needs."""
def others(flags: list) -> list:
return [flag for flag in flags if flag[0] != "--system"]
return others(tui_flags(state)) != others(state.checked_flags)
def _ticked_files(state: TuiState) -> list[dict]:
return [event for event in state.files
if set(event["systems"]) & state.ticked
@@ -9506,10 +9557,16 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple:
# The name first: the size alone stays beside it, the count is in
# the total below.
rows = [row._replace(right=row.right.split(", ")[-1]) for row in rows]
elif width >= 78:
# The id --system takes, beside a name that does not show it.
rows = [row._replace(label=f"{row.label} {row.value}")
if row.action == "system" and row.value not in row.label else row
for row in rows]
chosen = _ticked_files(state)
footer = [f"{len(state.ticked)} of {len(state.systems)} systems: "
f"{_plural(len(chosen), 'file')}, "
f"{format_size(sum(e['size'] for e in chosen))}"]
f"{format_size(sum(e['size'] for e in chosen))}"
+ (" before your options" if _counts_predate_options(state) else "")]
needle = state.filters.get("systems", "")
after = [f"No system matches '{needle}'."] if len(rows) == 3 and needle else []
if state.status:
@@ -9551,7 +9608,12 @@ def _view_cores(state: TuiState, width: int, height: int) -> tuple:
_STATE_WORDS = {"missing": "missing", "wrong_hash": "wrong copy"}
def _view_preview(state: TuiState, width: int, height: int) -> tuple:
def _pager_items(state: TuiState) -> list:
"""The text of a screen that scrolls, the same for its view and its keys."""
if state.screen == "result":
return tui_outcome(state)[1]
if state.screen == "command":
return ["The same choice without questions:", "", tui_command(state)]
if state.running:
items = ["Counting the files for these choices..."]
elif state.dry is None:
@@ -9564,12 +9626,17 @@ def _view_preview(state: TuiState, width: int, height: int) -> tuple:
for event in state.dry_files]
for kept in (state.dry_summary or {}).get("kept") or []:
items.append(f"Replaced files would be kept in {kept['folder']}")
return ("Preview", _pager(state, width, height, items), ("A/Enter Back", "Up/Down Scroll"))
return items
def _view_preview(state: TuiState, width: int, height: int) -> tuple:
return ("Preview", _pager(state, width, height, _pager_items(state)),
("A/Enter Back", "Up/Down Scroll"))
def _view_command(state: TuiState, width: int, height: int) -> tuple:
items = ["The same choice without questions:", "", tui_command(state)]
return ("Command line", _pager(state, width, height, items), ("A/Enter Back",))
return ("Command line", _pager(state, width, height, _pager_items(state)),
("A/Enter Back",))
def _view_confirm(state: TuiState, width: int, height: int) -> tuple:
@@ -9598,9 +9665,10 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple:
done = progress.get("bytes_done", 0)
lines.append(_bar(min(done / total, 1.0) if total else 1.0, width))
lines.append(f"{format_size(done)} of {format_size(total)}")
start, base = state.first_sample or (state.now, 0)
window = [sample for sample in state.samples if sample[0] >= state.now - 5]
start, base = window[0] if window else (state.now, done)
seconds = state.now - start
speed = (done - base) / seconds if seconds > 0.5 else 0
speed = (done - base) / seconds if seconds >= 1 else 0
if speed > 0:
lines.append(f"{format_size(int(speed))}/s, "
f"{format_duration((total - done) / speed)} left")
@@ -9608,7 +9676,8 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple:
lines.append(f"{files_done} of {_plural(state.download['files'], 'file')}")
if files_done >= state.download["files"] and state.summary is None:
lines.append("Finishing...")
lines.append(_styled("Stopping. What is done stays done." if state.stopping
lines.append(_styled("Stopping, finishing the files in flight. Press Ctrl-C again "
"to quit now." if state.stopping
else "What is done stays done if you stop.", "dim"))
room = height - len(lines) - 1
failed = [item for item in reversed(state.finished) if not item[1]]
@@ -9625,7 +9694,9 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple:
def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
"""The result in words, first what happened, then where and what next."""
summary = state.summary or {}
installed = summary.get("installed", 0)
# Killed by the second Ctrl-C, the child wrote no summary: what it
# reported finished is what is installed.
installed = summary.get("installed", sum(1 for _dest, ok, _why in state.finished if ok))
failed = summary.get("failed") or []
up_to_date = summary.get("up_to_date", 0)
total = (state.download or {}).get("files", 0)
@@ -9795,6 +9866,7 @@ def tui_start(
state = TuiState(os_type=os_type, home=Path.home())
state.cli_systems = list(args.system or [])
state.regions = resolve_regions(args.region) if args.region else []
state.cli_regions = list(state.regions)
state.cores = list(dict.fromkeys(
token.strip() for value in args.core or [] for token in value.split(",")
if token.strip()))
@@ -9953,13 +10025,17 @@ class _TuiTerminal:
getattr(stdin, "encoding", None) or "utf-8")(errors="replace")
self.encoding = getattr(stdout, "encoding", None) or "utf-8"
self.ended = False
self.heard = 0.0
self.frame: list[tuple[str, str]] = []
def enter(self) -> None:
if os.name != "nt":
import termios # noqa: PLC0415
self.saved = termios.tcgetattr(self.fd)
# Entered again after a stop, the mode to go back to stays the
# one found the first time.
if self.saved is None:
self.saved = termios.tcgetattr(self.fd)
mode = termios.tcgetattr(self.fd)
mode[0] &= ~(termios.ICRNL | termios.INLCR | termios.IGNCR | termios.IXON)
# ISIG off: Ctrl-C arrives as a key and is forwarded once, by hand.
@@ -9970,12 +10046,16 @@ class _TuiTerminal:
self.write(_SCREEN_ON)
def leave(self) -> None:
self.write(_SCREEN_OFF)
if self.saved is not None:
import termios # noqa: PLC0415
"""The main screen and the mode found, the mode even when the write
fails: a closed window answers EIO."""
try:
self.write(_SCREEN_OFF)
finally:
if self.saved is not None:
import termios # noqa: PLC0415
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved)
self.saved = None
saved, self.saved = self.saved, None
termios.tcsetattr(self.fd, termios.TCSADRAIN, saved)
def write(self, text: str) -> None:
self.stdout.write(text.encode(self.encoding, "replace").decode(self.encoding))
@@ -9991,6 +10071,7 @@ class _TuiTerminal:
for index, (text, style) in enumerate(frame):
if index < len(self.frame) and self.frame[index] == (text, style):
continue
text = screen_text(text)
if style == "focus":
text = _pad(text, min(cols, TUI_MAX_WIDTH) - (index == len(frame) - 1))
out.append(f"\x1b[{index + 1};1H{_STYLES.get(style, '')}{text}\x1b[0m\x1b[K")
@@ -10007,17 +10088,22 @@ class _TuiTerminal:
import select # noqa: PLC0415
if not select.select([self.fd], [], [], timeout)[0]:
# A paste whose end never came, after a second of silence.
if self.decoder.paste is not None and time.monotonic() - self.heard > 1:
return self.decoder.close_paste()
return []
keys = self._read()
while self.decoder.waiting and not self.ended:
while self.decoder.buffer and not self.ended:
if select.select([self.fd], [], [], TUI_ESC_WAIT)[0]:
keys += self._read()
else:
keys += self.decoder.flush()
break
return keys
def _read(self) -> list[TuiKey]:
data = os.read(self.fd, 4096)
self.heard = time.monotonic()
if not data:
# The terminal went away: what Ctrl-D at a question does.
self.ended = True
@@ -10042,6 +10128,10 @@ class _TuiTerminal:
def tui_farewell(state: TuiState) -> list[str]:
"""What stays in the terminal once the full screen is gone."""
return [screen_text(line) for line in _farewell_lines(state)]
def _farewell_lines(state: TuiState) -> list[str]:
if state.screen == "crashed":
return [f"RetroBIOS stopped on an unexpected error. What it was is in {state.log}."]
if state.screen == "result":
@@ -10066,7 +10156,7 @@ def run_tui(args: argparse.Namespace) -> int:
inbox: queue.Queue = queue.Queue()
current: list[_EngineRun] = []
written: dict[str, Path] = {}
flags = {"interrupt": False, "resized": False}
flags = {"interrupt": False, "resized": False, "suspend": False, "resume": False}
ignore_interrupt_until = [0.0]
terminal = _TuiTerminal(sys.stdin, sys.stdout)
real_out, real_err = sys.stdout, sys.stderr
@@ -10117,9 +10207,16 @@ def run_tui(args: argparse.Namespace) -> int:
def on_stop(signum: int, _frame: object) -> None:
raise _TuiStop(signum)
def on_suspend(_signum: int, _frame: object) -> None:
flags["suspend"] = True
def on_resume(_signum: int, _frame: object) -> None:
flags["resume"] = True
try:
for name, handler in (("SIGINT", on_interrupt), ("SIGWINCH", on_resize),
("SIGTERM", on_stop), ("SIGHUP", on_stop)):
("SIGTERM", on_stop), ("SIGHUP", on_stop),
("SIGTSTP", on_suspend), ("SIGCONT", on_resume)):
number = getattr(signal, name, None)
if number is not None:
handlers[number] = signal.signal(number, handler)
@@ -10127,6 +10224,17 @@ def run_tui(args: argparse.Namespace) -> int:
terminal.enter()
perform(effects)
while state.exit_code is None:
if flags["suspend"]:
# Stopped from outside (kill -TSTP): the shell gets its
# terminal back as it lent it, and the screen returns on fg.
flags["suspend"] = False
terminal.leave()
os.kill(os.getpid(), signal.SIGSTOP) # type: ignore[attr-defined]
flags["resume"] = True
if flags["resume"]:
flags["resume"] = False
terminal.enter()
terminal.frame = []
cols, rows = terminal.size()
if (cols, rows) != (state.cols, state.rows) or flags["resized"]:
flags["resized"] = False
@@ -10160,12 +10268,10 @@ def run_tui(args: argparse.Namespace) -> int:
state.log, state.code, state.exit_code = str(crash), 1, 1
state.stack = ["crashed"]
finally:
# An install stopped by a signal still gets to write its summary.
for run in current:
run.stop(10 if state.running and state.run == "install" else 0)
terminal.leave()
for number, previous in handlers.items():
signal.signal(number, previous)
# Each step on its own: a write the closed window refuses, or a
# second signal while the install writes its summary, must not
# skip the ones after it and leave the terminal raw.
problems = _tui_cleanup(terminal, current, handlers, state)
sys.stdout, sys.stderr = real_out, real_err
for path in written.values():
if path.exists():
@@ -10173,11 +10279,50 @@ def run_tui(args: argparse.Namespace) -> int:
if not (state.log and state.code not in (0, None)):
shutil.rmtree(work, ignore_errors=True)
stream = sys.stderr if state.screen in ("error", "crashed") else sys.stdout
for line in tui_farewell(state):
print(line, file=stream)
lines = tui_farewell(state)
if problems:
lines.append(f"The terminal may not be restored ({problems[0]}): type reset "
"and press Enter if it looks wrong.")
try:
for line in lines:
print(line, file=stream)
except OSError:
# The terminal is gone: the exit code is all that still reaches anyone.
return state.exit_code if state.exit_code is not None else 1
return state.exit_code if state.exit_code is not None else 1
def _tui_cleanup(
terminal: _TuiTerminal, runs: list[_EngineRun], handlers: dict, state: TuiState,
) -> list[str]:
"""Stop the child, put the terminal and the signal handlers back: what failed."""
failures: tuple = (OSError, ValueError)
if os.name != "nt":
import termios # noqa: PLC0415
failures += (termios.error,)
problems: list[str] = []
def step(action: Callable[[], object]) -> None:
try:
action()
except failures as exc:
problems.append(str(exc) or type(exc).__name__)
for name in ("SIGTERM", "SIGHUP", "SIGTSTP"):
number = getattr(signal, name, None)
if number is not None:
step(functools.partial(signal.signal, number, signal.SIG_IGN))
# An install stopped by a signal still gets to write its summary.
for run in runs:
step(functools.partial(run.stop, 10 if state.running and state.run == "install"
else 0))
step(terminal.leave)
for number, previous in handlers.items():
step(functools.partial(signal.signal, number, previous))
return problems
def _line_buffered_stdout() -> None:
"""Flush stdout line by line, as stderr is.
+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="2606f825ea887d70d58c8938fa726bd92236161b907159d34643bff24ca349fc"
DEFAULT_INSTALL_SHA256="d0a92cac2d09da791fe3dbacdad37ec7e40b5a3685a39e3cc02f0dcbd5a556e6"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+1 -1
View File
@@ -2,7 +2,7 @@
[##############################################---------------------------------------------] 50%
528.0 KB of 1.0 MB
132.0 KB/s, 0:04 left
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
+1 -1
View File
@@ -2,7 +2,7 @@
[###############----------------] 50%
528.0 KB of 1.0 MB
132.0 KB/s, 0:04 left
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
+1 -1
View File
@@ -2,7 +2,7 @@
[###############----------------] 50%
528.0 KB of 1.0 MB
132.0 KB/s, 0:04 left
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
+1 -1
View File
@@ -2,7 +2,7 @@
[####################################-----------------------------------] 50%
528.0 KB of 1.0 MB
132.0 KB/s, 0:04 left
128.9 KB/s, 0:04 left
2 of 4 files
What is done stays done if you stop.
+292 -9
View File
@@ -383,8 +383,8 @@ class Flow(unittest.TestCase):
self.assertEqual(press(state, "enter"), [("run", "install")])
self.assertEqual(state.screen, "installing")
argv = install.tui_argv(state, "install", "py", "install.py")
self.assertEqual(argv, ["py", "install.py", "--platform", "retroarch", "--dest",
str(SYSTEM_DIR), "--events", "--yes"])
self.assertEqual(argv, ["py", "install.py", "--platform=retroarch",
f"--dest={SYSTEM_DIR}", "--events", "--yes"])
for event in ({"event": "download", "platform": "retroarch", "files": 4,
"bytes": 1064964},
{"event": "downloaded", "dest": "gba_bios.bin", "ok": True, "reason": "",
@@ -407,8 +407,8 @@ class Flow(unittest.TestCase):
state.platform, state.dest = "retroarch", SYSTEM_DIR
state.manifest, state.manifest_for = MANIFEST, "retroarch"
self.assertEqual(install._start_check(state), [("run", "check")])
self.assertEqual(install.tui_argv(state, "check", "py", "i.py")[6:], [
"--target", "switch", "--system", "psx", "--region", "north-america",
self.assertEqual(install.tui_argv(state, "check", "py", "i.py")[4:], [
"--target=switch", "--system=psx", "--region=north-america",
"--check", "--events"])
def test_one_system_goes_through_the_dry_run(self):
@@ -427,7 +427,7 @@ class Flow(unittest.TestCase):
press(state, "right")
self.assertEqual(press(state, "enter"), [("run", "confirm")])
self.assertEqual(state.screen, "confirm")
self.assertIn("--system", install.tui_argv(state, "confirm", "py", "i.py"))
self.assertIn("--system=nintendo-gba", install.tui_argv(state, "confirm", "py", "i.py"))
self.assertIn("--dry-run", install.tui_argv(state, "confirm", "py", "i.py"))
dry = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
"bytes_needed": 16384}
@@ -436,7 +436,7 @@ class Flow(unittest.TestCase):
self.assertEqual(state.screen, "confirm")
self.assertEqual(press(state, "enter"), [("run", "install")])
argv = install.tui_argv(state, "install", "py", "i.py")
self.assertEqual(argv[6:], ["--system", "nintendo-gba", "--events", "--yes"])
self.assertEqual(argv[4:], ["--system=nintendo-gba", "--events", "--yes"])
def test_a_choice_that_changes_nothing_skips_the_confirmation(self):
state = checked_state()
@@ -472,8 +472,8 @@ class Flow(unittest.TestCase):
self.assertTrue(state.installed_only)
self.assertEqual(state.jobs, 10)
argv = install.tui_argv(state, "install", "py", "i.py")
self.assertEqual(argv[6:], ["--region", "japan,north-america", "--no-game-data",
"--installed-cores", "--jobs", "10", "--events", "--yes"])
self.assertEqual(argv[4:], ["--region=japan,north-america", "--no-game-data",
"--installed-cores", "--jobs=10", "--events", "--yes"])
self.assertIn("--region japan,north-america --no-game-data", install.tui_command(state))
def test_cores_checklist(self):
@@ -848,7 +848,7 @@ def golden_states() -> dict:
installing.stack.append("installing")
installing.run, installing.running = "install", True
installing.download = {"event": "download", "files": 4, "bytes": 1064964}
installing.first_sample, installing.now = (100.0, 0), 104.0
installing.samples, installing.now = [(100.0, 12672), (104.0, 540672)], 104.0
installing.progress = {"files_done": 2, "files_total": 4, "bytes_done": 540672,
"bytes_total": 1064964}
installing.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "HTTP 404")]
@@ -949,6 +949,263 @@ class Rendering(unittest.TestCase):
self.assertEqual(out.getvalue(), "??")
class TheSectionStandsApart(unittest.TestCase):
def test_no_name_of_the_installer_is_defined_again(self):
"""_run and _system_label were defined twice: the later one won, and
the installer's listing called the interface's."""
import ast
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
start = source[:source.index("# Full-screen interface\n#")].count("\n") + 1
seen: dict = {}
for node in ast.parse(source).body:
names = [node.name] if isinstance(node, (ast.FunctionDef, ast.ClassDef)) else [
target.id for target in getattr(node, "targets", [])
if isinstance(target, ast.Name)]
for name in names:
seen.setdefault(name, []).append(node.lineno)
twice = {name: lines for name, lines in seen.items()
if len(lines) > 1 and max(lines) >= start}
self.assertEqual(twice, {})
class ControlCharacters(unittest.TestCase):
"""Names from the file list, the folders on disk and the installer's own
words reach the screen: an escape sequence among them would drive the
terminal (clear it, or set the clipboard with OSC 52)."""
HOSTILE = "a\x1b[2Jb\x1b]52;c;x\x07c\rd\ne\x7ff\x9bg\x85h"
def test_the_cleaning(self):
self.assertEqual(install.screen_text(self.HOSTILE), "a?[2Jb?]52;c;x?c?d?e?f?g?h")
self.assertEqual(install.screen_text("ファミコン é"), "ファミコン é")
def assertClean(self, lines) -> None:
for line in lines:
self.assertIsNone(re.search(r"[\x00-\x1f\x7f-\x9f]", line), repr(line))
def test_a_file_name_in_the_preview(self):
state = checked_state()
state.stack += ["options", "preview"]
state.dry = dict(CHECK)
state.dry_files = [{**FILES[0], "dest": "\x1b[2Jevil.bin"}]
lines = install.tui_view(state, 80, 24)
self.assertClean(lines)
self.assertIn("?[2Jevil.bin", "\n".join(lines))
def test_a_folder_name_in_the_browser(self):
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
(root / "x\x1b]52;c;x\x07").mkdir()
state = fresh_state()
state.platform, state.stack = "retroarch", ["folder", "browse"]
with mock.patch.object(install, "_mounted_cards", return_value=[]):
install._browse_to(state, root)
lines = install.tui_view(state, 80, 24)
self.assertClean(lines)
self.assertIn("x?]52;c;x?/", "\n".join(lines))
def test_the_child_tail_on_the_error_screen(self):
state = fresh_state()
state.stack, state.run = ["setup", "error"], "check"
state.error = ["\x1b[31mError\x1b[0m: \x07bad\r"]
self.assertClean(install.tui_view(state, 40, 15))
def test_the_renderer_guards_whatever_reaches_it(self):
out = io.StringIO()
terminal = install._TuiTerminal.__new__(install._TuiTerminal)
terminal.stdout, terminal.encoding = out, "utf-8"
terminal.frame = [("", "")] * 3
terminal.draw(["x", "\x1b]52;c;x\x07", "z"], 40)
self.assertNotIn("\x1b]", out.getvalue())
self.assertNotIn("\x07", out.getvalue())
def test_the_farewell_is_clean(self):
state = checked_state()
state.dest = Path("/media/\x1b[2J")
state.stack.append("result")
state.code = 0
state.summary = {"installed": 1, "failed": [{"dest": "\x1b]0;t\x07x", "reason": "\x1b"}]}
self.assertClean(install.tui_farewell(state))
class SmallFixes(unittest.TestCase):
def test_a_command_line_region_the_list_lacks(self):
state = checked_state()
state.cli_regions = ["europe"]
state.regions = list(state.cli_regions)
state.stack += ["options", "regions"]
self.assertEqual([row.value for row in install.tui_rows(state)],
["europe", "japan", "north-america"])
press(state, "enter")
self.assertEqual(state.regions, [])
self.assertIn("europe", [row.value for row in install.tui_rows(state)])
press(state, "enter")
self.assertEqual(state.regions, ["europe"])
def test_stay_after_esc_on_a_direct_check_checks_again(self):
state = fresh_state()
state.stack = []
state.manifest, state.manifest_for = MANIFEST, "retroarch"
install._choose(state, "retroarch", Path(REPO_ROOT))
self.assertEqual(state.screen, "checking")
self.assertEqual(press(state, "esc"), [("kill",)])
self.assertEqual(state.screen, "quit")
self.assertEqual(press(state, "down", "enter"), [("run", "check")])
self.assertEqual((state.stack, state.running), (["checking"], True))
press(state, "esc")
self.assertEqual(press(state, "esc"), [("run", "check")])
def test_speed_ignores_a_resumed_partial(self):
state = checked_state()
press(state, "enter")
mega = 1024 * 1024
state.now = 9.0
install.tui_receive(state, ("event", state.run_id, {
"event": "download", "platform": "retroarch", "files": 2, "bytes": 1000 * mega}))
# The first event carries the 500 MB already on disk.
for when, done in ((10.0, 500 * mega), (11.0, 501 * mega), (12.0, 502 * mega)):
state.now = when
install.tui_receive(state, ("event", state.run_id, {
"event": "progress", "files_done": 0, "files_total": 2,
"bytes_done": done, "bytes_total": 1000 * mega}))
text = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("1.0 MB/s, 8:18 left", text)
def test_numbers_from_before_the_options_say_so(self):
state = checked_state()
state.stack.append("systems")
self.assertNotIn("before your options", "\n".join(install.tui_view(state, 80, 24)))
state.regions = ["japan"]
footer = "\n".join(install.tui_view(state, 80, 24)[-3:])
self.assertIn("before your options", footer)
def test_a_cores_folder_implies_the_installed_cores(self):
state = checked_state()
state.passthrough = [("--cores-dir", "/opt/cores")]
state.installed_only = True
state.stack.append("options")
row = next(r for r in install.tui_rows(state) if r.action == "installed")
self.assertEqual(row.label, "Installed cores only: yes, implied by --cores-dir")
state.focus["options"] = install.tui_rows(state).index(row)
press(state, "enter", "left")
self.assertTrue(state.installed_only)
self.assertNotIn("--installed-cores", [f for f, _v in install.tui_flags(state)])
def test_end_then_up_in_a_pager(self):
state = checked_state()
state.stack += ["options", "preview"]
state.dry, state.dry_files = dict(CHECK), list(FILES) * 10
state.cols, state.rows = 40, 15
press(state, "end")
last = install.tui_view(state, 40, 15)
press(state, "up")
self.assertNotEqual(install.tui_view(state, 40, 15), last)
def test_a_paste_slower_than_the_escape_wait_stays_one_piece(self):
decoder = install.KeyDecoder()
self.assertEqual(decoder.feed("\x1b[200~/media/"), [])
self.assertEqual(decoder.flush(), [])
self.assertTrue(decoder.waiting)
self.assertEqual(decoder.feed("sd\x1b[201~"), [Key("paste", "/media/sd")])
def test_values_cannot_be_read_as_options(self):
state = checked_state()
state.dest = Path("-rf")
state.cores = ["-x"]
argv = install.tui_argv(state, "install", "py", "i.py")
self.assertIn("--dest=-rf", argv)
self.assertIn("--core=-x", argv)
self.assertIn("--platform=retroarch", argv)
def test_the_second_ctrl_c_is_announced_and_the_result_stays_honest(self):
state = checked_state()
press(state, "enter")
install.tui_receive(state, ("event", state.run_id, {
"event": "download", "platform": "retroarch", "files": 4, "bytes": 100}))
install.tui_receive(state, ("event", state.run_id, {
"event": "downloaded", "dest": "a.bin", "ok": True, "reason": "", "size": 1,
"files_done": 1, "files_total": 4, "bytes_done": 1, "bytes_total": 100}))
press(state, "ctrl-c")
self.assertIn("Press Ctrl-C again to quit now.",
" ".join(install.tui_view(state, 120, 40)))
self.assertEqual(press(state, "ctrl-c"), [("kill",)])
install.tui_receive(state, ("ended", state.run_id, -9, []))
title, lines = install.tui_outcome(state)
self.assertEqual((title, state.code), ("Stopped", 130))
self.assertEqual(lines[0], "Stopped: 1 of 4 installed, run again to finish.")
def test_systems_show_the_platform_names(self):
state = checked_state()
state.systems.append({"event": "system", "system": "sega-saturn",
"name": "Sega - Saturn", "files": 1, "present": 0,
"missing": 1, "wrong_hash": 0, "bytes_needed": 5})
state.ticked.add("sega-saturn")
state.stack.append("systems")
wide = "\n".join(install.tui_view(state, 120, 40))
self.assertIn("[x] Sega - Saturn", wide)
self.assertIn("sega-saturn", wide)
narrow = "\n".join(install.tui_view(state, 40, 15))
self.assertIn("[x] Sega - Saturn", narrow)
self.assertNotIn("sega-saturn", narrow)
press(state, "/", "s", "a", "t", "u")
self.assertEqual([row.value for row in install.tui_rows(state)][3:], ["sega-saturn"])
class CleanupSurvivesABrokenTerminal(unittest.TestCase):
"""A write that fails (EIO when the window closed, EPIPE) on the way out
left the terminal raw and the handlers installed."""
class Tty(io.StringIO):
broken = False
def __init__(self, fd: int) -> None:
super().__init__()
self.fd = fd
def isatty(self) -> bool:
return True
def fileno(self) -> int:
return self.fd
def write(self, text: str) -> int:
if CleanupSurvivesABrokenTerminal.Tty.broken:
raise OSError(5, "Input/output error")
return super().write(text)
def test_termios_and_handlers_come_back(self):
import signal
master, slave = os.openpty()
self.addCleanup(os.close, master)
self.addCleanup(os.close, slave)
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
before = termios.tcgetattr(slave)
handlers = {n: signal.getsignal(n) for n in (signal.SIGINT, signal.SIGTERM)}
tty = self.Tty(slave)
real_draw = install._TuiTerminal.draw
def breaking_draw(terminal, lines, cols):
terminal.frame = []
real_draw(terminal, lines, cols)
CleanupSurvivesABrokenTerminal.Tty.broken = True
self.addCleanup(setattr, CleanupSurvivesABrokenTerminal.Tty, "broken", False)
with mock.patch.object(install._TuiTerminal, "draw", breaking_draw), \
mock.patch.object(install, "tui_start", return_value=(fresh_state(), [])), \
mock.patch.object(install, "_open_local_sources"), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(sys, "stdin", tty), mock.patch.object(sys, "stdout", tty), \
mock.patch.object(sys, "stderr", tty):
code = install.run_tui(_args())
self.assertIs(sys.stdout, tty)
self.assertEqual(code, 1)
self.assertEqual(termios.tcgetattr(slave), before)
for number, handler in handlers.items():
self.assertEqual(signal.getsignal(number), handler)
class Crash(unittest.TestCase):
"""An unexpected error ends on one sentence and a log, never a traceback."""
@@ -1234,6 +1491,32 @@ class EndToEnd(unittest.TestCase):
self.assertEqual(term.close(), 130)
self.assertIn(b"\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l", term.raw)
def test_a_stop_from_outside_gives_the_terminal_back(self):
import signal
self.repo()
term = self.start()
term.wait("Where do you play?")
mark = len(term.raw)
os.kill(term.pid, signal.SIGTSTP)
end = time.monotonic() + 10
stopped = False
while time.monotonic() < end and not stopped:
term._pump(0.05)
pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED)
stopped = bool(pid) and os.WIFSTOPPED(status)
self.assertTrue(stopped)
self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw[mark:])
mark = len(term.raw)
os.kill(term.pid, signal.SIGCONT)
term.wait("A/Enter Continue")
end = time.monotonic() + 5
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
term.send("q")
self.assertEqual(term.close(), 130)
def test_resize_redraws(self):
self.repo()
term = self.start(cols=40, rows=15)
+2 -2
View File
@@ -111,8 +111,8 @@ changes the plan, the exact count appears before the install starts.
The bottom line of each screen names its keys, with both spellings (`A/Enter`,
`B/Esc`), since A and B swap places between controller layouts. On the regions
screen, Left and Right move the region under the cursor up and down the order of
preference. During the download, Ctrl+C stops after the files in progress, and
the result says how many were installed. The result stays printed in the
preference. During the download, Ctrl+C stops after the files in progress, a
second Ctrl+C stops at once, and the result says how many were installed. The result stays printed in the
terminal once the screen closes, with the command that repeats the run.
It opens only for an install, at a terminal of at least 40 columns and 12 rows