feat(fws): add recap spoke for pre-installation summary
Add FWSRecapSpoke, a standalone spoke that displays a comprehensive summary of user configuration choices and detected hardware before installation begins. The spoke intercepts the installation flow between the SummaryHub and ProgressSpoke, allowing users to review and modify their choices before any disk writes occur. Features: - Displays user-selected configuration (language, keyboard, timezone, users, partitioning, desktop, hostname) - Shows detected hardware (virtualization, GPUs, disks, network status) - Provides installation flow overview and warnings about automatic partitioning - Implements back navigation to return to the hub for modifications - Never marks as completed to ensure proper flow control in anaconda's action queue
This commit is contained in:
+347
@@ -0,0 +1,347 @@
|
||||
# FWS — Page « Récapitulatif » : écran À PART ENTIÈRE avant l'installation.
|
||||
#
|
||||
# POURQUOI ICI (airootfs, pas dans le paquet anaconda) : même mécanisme que la
|
||||
# spoke « Desktop Environment » (fws_desktop.py) — Anaconda découvre les spokes
|
||||
# au RUNTIME (collect) dans ce dossier → l'overlay airootfs suffit, AUCUN
|
||||
# rebuild du paquet anaconda. Le glade associé est dans
|
||||
# /usr/share/anaconda/ui/spokes/fws_recap.glade (aussi via l'airootfs).
|
||||
#
|
||||
# PLACEMENT DANS LE FLUX : standalone spoke « postForHub = SummaryHub » avec
|
||||
# priority = -10. L'ordre des écrans est pre(SummaryHub) → SummaryHub →
|
||||
# post(SummaryHub) trié par priority croissante (UserInterface.
|
||||
# _orderActionClasses). ProgressSpoke — l'écran qui DÉMARRE l'installation
|
||||
# dans son refresh() — est aussi post(SummaryHub), priority 0 par défaut →
|
||||
# cette page s'intercale ENTRE les deux : l'utilisateur clique « Commencer
|
||||
# l'installation » sur le hub, voit CE récapitulatif (le disque n'a encore
|
||||
# subi AUCUNE écriture), et l'installation ne démarre qu'à son clic
|
||||
# « Lancer l'installation ». Le bouton « Précédent » ramène au hub pour
|
||||
# modifier les choix (cf. _on_back_clicked : la file d'actions du GUI est
|
||||
# consommée à chaque écran, on y réinsère le hub).
|
||||
#
|
||||
# CONTENU : configuration choisie par l'utilisateur (via les modules D-Bus
|
||||
# d'Anaconda : langue, clavier, fuseau, comptes, partitionnement + les
|
||||
# fichiers /tmp des spokes FWS : bureau, hostname) ET matériel détecté
|
||||
# (virtualisation, GPU, disques, réseau) — mêmes sources que ce que le
|
||||
# %post du kickstart et fws-setup-hardware appliqueront.
|
||||
#
|
||||
# ⚠ « completed » doit rester False : _instantiateAction SAUTE les standalone
|
||||
# spokes complétées (mécanisme prévu pour les installs kickstart).
|
||||
# ⚠ Chemin couplé à la version Python d'Arch (3.14) — comme fws_desktop.py —
|
||||
# et le bouton « Précédent » s'appuie sur les internes du GUI d'anaconda
|
||||
# 45.8 (_actions/_currentAction) : à re-vérifier à chaque bump d'anaconda.
|
||||
|
||||
import inspect
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
from pyanaconda.anaconda_loggers import get_module_logger
|
||||
from pyanaconda.core import util
|
||||
from pyanaconda.modules.common.constants.objects import DISK_SELECTION
|
||||
from pyanaconda.modules.common.constants.services import (
|
||||
LOCALIZATION,
|
||||
STORAGE,
|
||||
TIMEZONE,
|
||||
USERS,
|
||||
)
|
||||
from pyanaconda.modules.common.structures.user import UserData
|
||||
from pyanaconda.ui.gui import GraphicalUserInterface
|
||||
from pyanaconda.ui.gui.hubs.summary import SummaryHub
|
||||
from pyanaconda.ui.gui.spokes import StandaloneSpoke
|
||||
|
||||
log = get_module_logger(__name__)
|
||||
|
||||
__all__ = ["FWSRecapSpoke"]
|
||||
|
||||
# Doit rester aligné sur le %post du kickstart (étape « Bureau choisi ») et
|
||||
# sur les libellés de la spoke fws_desktop.
|
||||
_DESKTOP_LABELS = {
|
||||
"cli": "ligne de commande uniquement (aucun bureau)",
|
||||
"gnome": "GNOME (+ Firefox, GDM)",
|
||||
"kde": "KDE Plasma (+ Firefox, SDDM)",
|
||||
"hyprland": "Hyprland (+ Firefox, SDDM)",
|
||||
"xfce": "XFCE (+ Firefox, SDDM)",
|
||||
}
|
||||
|
||||
_PARTITIONING_LABELS = {
|
||||
"AUTOMATIC": "automatique (recommandé)",
|
||||
"INTERACTIVE": "personnalisé (assistant)",
|
||||
"CUSTOM": "personnalisé",
|
||||
"BLIVET": "avancé (blivet-gui)",
|
||||
"MANUAL": "points de montage manuels",
|
||||
}
|
||||
|
||||
|
||||
def _safe(getter, default):
|
||||
"""Valeur d'un getter D-Bus/système, ou défaut : le récap ne doit JAMAIS
|
||||
faire tomber l'installateur, quel que soit le module indisponible (d'où
|
||||
le except large : dasbus lève des types variés selon la panne)."""
|
||||
try:
|
||||
return getter()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return default
|
||||
|
||||
|
||||
def _capture(*argv):
|
||||
"""stdout d'une commande, chaîne vide si absente ou en échec."""
|
||||
try:
|
||||
return util.execWithCapture(argv[0], list(argv[1:])) or ""
|
||||
except (OSError, RuntimeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _read_flag(path):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
return fh.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _config_lines():
|
||||
"""Section « configuration choisie » : interroge les modules D-Bus
|
||||
d'Anaconda (remplis par les spokes) + les fichiers /tmp des spokes FWS."""
|
||||
lines = []
|
||||
|
||||
lang = _safe(lambda: LOCALIZATION.get_proxy().Language, "")
|
||||
if lang:
|
||||
lines.append(f" • Langue : {lang}")
|
||||
|
||||
layouts = _safe(lambda: list(LOCALIZATION.get_proxy().XLayouts), [])
|
||||
if layouts:
|
||||
lines.append(f" • Clavier (X11) : {', '.join(layouts)}")
|
||||
|
||||
tz = _safe(lambda: TIMEZONE.get_proxy().Timezone, "")
|
||||
if tz:
|
||||
ntp = _safe(lambda: TIMEZONE.get_proxy().NTPEnabled, None)
|
||||
suffix = {True: " — synchronisation NTP activée",
|
||||
False: " — synchronisation NTP désactivée"}.get(ntp, "")
|
||||
lines.append(f" • Fuseau horaire : {tz}{suffix}")
|
||||
|
||||
users = _safe(
|
||||
lambda: UserData.from_structure_list(USERS.get_proxy().Users), [])
|
||||
if users:
|
||||
names = ", ".join(u.name for u in users if u.name)
|
||||
lines.append(f" • Utilisateur(s) : {names} (sudo via wheel)")
|
||||
else:
|
||||
lines.append(" • Utilisateur(s) : AUCUN — pensez-y avant de lancer !")
|
||||
|
||||
root_locked = _safe(lambda: USERS.get_proxy().IsRootAccountLocked, None)
|
||||
root_pw = _safe(lambda: USERS.get_proxy().IsRootPasswordSet, None)
|
||||
if root_locked:
|
||||
root_txt = "verrouillé (recommandé)"
|
||||
elif root_pw:
|
||||
root_txt = "actif, mot de passe défini"
|
||||
elif root_pw is not None:
|
||||
# Même règle que l'étape « Sécurité » du %post : un root resté sans
|
||||
# mot de passe sera verrouillé en fin d'installation.
|
||||
root_txt = "sans mot de passe → sera VERROUILLÉ en fin d'install"
|
||||
else:
|
||||
root_txt = ""
|
||||
if root_txt:
|
||||
lines.append(f" • Compte root : {root_txt}")
|
||||
|
||||
# Hostname : écrit par le spoke Utilisateur (patch user.py du paquet
|
||||
# anaconda). Même nettoyage / même défaut que le %post --nochroot.
|
||||
hn = "".join(c for c in _read_flag("/tmp/fws-hostname")
|
||||
if c.isalnum() or c in "._-") or "fws"
|
||||
lines.append(f" • Nom de machine : {hn}")
|
||||
|
||||
# Bureau : écrit par fws_desktop.apply() (sortie de la spoke). Absent =
|
||||
# spoke jamais visitée = « cli », exactement le défaut du %post.
|
||||
desk = _read_flag("/tmp/fws-desktop") or "cli"
|
||||
lines.append(f" • Bureau : {_DESKTOP_LABELS.get(desk, desk)}")
|
||||
|
||||
method = _safe(
|
||||
lambda: STORAGE.get_proxy(
|
||||
STORAGE.get_proxy().AppliedPartitioning).PartitioningMethod, "")
|
||||
if method:
|
||||
lines.append(" • Partitionnement : "
|
||||
f"{_PARTITIONING_LABELS.get(method, method)}")
|
||||
|
||||
return lines, desk, method
|
||||
|
||||
|
||||
def _build_summary():
|
||||
config_lines, desk, method = _config_lines()
|
||||
config_text = "\n".join(config_lines) \
|
||||
or " (configuration non disponible)"
|
||||
|
||||
# Disques ciblés par l'installation (spoke « Destination »).
|
||||
selected = set(_safe(
|
||||
lambda: list(STORAGE.get_proxy(DISK_SELECTION).SelectedDisks), []))
|
||||
disks = []
|
||||
for line in _capture("lsblk", "-dno", "NAME,SIZE,MODEL,TYPE").splitlines():
|
||||
fields = line.split()
|
||||
if fields and fields[-1] == "disk":
|
||||
mark = " ← CIBLE de l'installation" if fields[0] in selected else ""
|
||||
disks.append(" • " + " ".join(fields[:-1]) + mark)
|
||||
disk_text = "\n".join(disks) or " (aucun disque détecté)"
|
||||
|
||||
if method == "AUTOMATIC":
|
||||
disk_warn = (
|
||||
" ⚠ Rien n'a encore été écrit. Le partitionnement automatique\n"
|
||||
" EFFACERA ENTIÈREMENT le(s) disque(s) cible(s) au lancement.")
|
||||
else:
|
||||
disk_warn = (
|
||||
" ⚠ Rien n'a encore été écrit. Au lancement, ce sont vos choix\n"
|
||||
" de partitionnement faits dans l'installateur qui s'appliqueront.")
|
||||
|
||||
virt = (_capture("systemd-detect-virt").strip().splitlines() or ["none"])[0]
|
||||
if virt in ("", "none"):
|
||||
virt = "aucune (machine physique)"
|
||||
|
||||
gpus = []
|
||||
for line in _capture("lspci").splitlines():
|
||||
low = line.lower()
|
||||
if ("vga" in low or "3d" in low or "display" in low) and ":" in line:
|
||||
gpus.append(" • " + line.split(":", 2)[-1].strip())
|
||||
gpu_text = "\n".join(gpus) or " (aucune carte graphique détectée)"
|
||||
|
||||
try:
|
||||
net_ok = util.execWithRedirect("ip", ["route", "get", "1.1.1.1"]) == 0
|
||||
except (OSError, RuntimeError):
|
||||
net_ok = False
|
||||
if net_ok:
|
||||
net = "disponible ✔"
|
||||
else:
|
||||
net = ("INDISPONIBLE ✖ — bureau, drivers et stack FWS ne\n"
|
||||
" s'installeront PAS (rattrapable au 1er boot)")
|
||||
|
||||
extras = ("Steam, Wine, VR (Monado/OpenXR), winboat-bin"
|
||||
if desk != "cli" else "rien de plus (choix CLI)")
|
||||
|
||||
return f"""\
|
||||
══════════════════════════════════════════════════════════
|
||||
FWS — RÉCAPITULATIF AVANT INSTALLATION
|
||||
══════════════════════════════════════════════════════════
|
||||
|
||||
CONFIGURATION CHOISIE
|
||||
{config_text}
|
||||
|
||||
DISQUES
|
||||
{disk_warn}
|
||||
{disk_text}
|
||||
|
||||
MATÉRIEL DÉTECTÉ
|
||||
• Virtualisation : {virt}
|
||||
• Réseau : {net}
|
||||
Cartes graphiques (drivers installés automatiquement en fin d'install) :
|
||||
{gpu_text}
|
||||
|
||||
DÉROULEMENT DE L'INSTALLATION
|
||||
1. Partitionnement, puis copie du système live FWS (payload LiveOS)
|
||||
2. Noyau + initramfs régénérés (mkinitcpio), GRUB installé
|
||||
3. Bureau choisi + navigateur installés (réseau requis)
|
||||
4. Matériel : drivers GPU, outils VM, son, Bluetooth, Podman
|
||||
+ si bureau choisi : {extras}
|
||||
|
||||
« Précédent » pour revenir modifier vos choix —
|
||||
« Lancer l'installation » pour démarrer réellement l'installation.
|
||||
"""
|
||||
|
||||
|
||||
class FWSRecapSpoke(StandaloneSpoke):
|
||||
"""Récapitulatif des choix, affiché entre le hub et l'installation."""
|
||||
|
||||
builderObjects = ["fwsRecapWindow"]
|
||||
mainWidgetName = "fwsRecapWindow"
|
||||
uiFile = "spokes/fws_recap.glade"
|
||||
|
||||
postForHub = SummaryHub
|
||||
priority = -10
|
||||
|
||||
@staticmethod
|
||||
def get_screen_id():
|
||||
"""Identifiant unique de cet écran."""
|
||||
return "fws-recap"
|
||||
|
||||
@property
|
||||
def completed(self):
|
||||
# False, sinon _instantiateAction saute l'écran (cf. en-tête).
|
||||
return False
|
||||
|
||||
def apply(self):
|
||||
# Rien à persister : cette page ne fait que montrer l'état.
|
||||
pass
|
||||
|
||||
def initialize(self):
|
||||
StandaloneSpoke.initialize(self)
|
||||
# Le bouton « continuer » de CETTE page est le vrai déclencheur :
|
||||
# l'écran suivant (ProgressSpoke) démarre la file d'installation.
|
||||
self.window.get_continue_button().set_label("_Lancer l'installation")
|
||||
|
||||
# Capture pour « Précédent » : initialize() est appelé DEPUIS
|
||||
# GraphicalUserInterface._on_continue_clicked (clic « Commencer
|
||||
# l'installation » du hub), AVANT la bascule de _currentAction → à
|
||||
# cet instant ui._currentAction est ENCORE l'instance du hub Résumé,
|
||||
# et le « self » de l'appelant est l'UI. Aucune API publique ne les
|
||||
# expose ; on remonte la pile d'appels (déterministe — pas de scan
|
||||
# mémoire, pas de variable globale).
|
||||
self._gui = None
|
||||
self._hub = None
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
frame = frame.f_back
|
||||
while frame is not None:
|
||||
candidate = frame.f_locals.get("self")
|
||||
if isinstance(candidate, GraphicalUserInterface):
|
||||
self._gui = candidate
|
||||
current = getattr(candidate, "_currentAction", None)
|
||||
if isinstance(current, SummaryHub):
|
||||
self._hub = current
|
||||
break
|
||||
frame = frame.f_back
|
||||
finally:
|
||||
del frame
|
||||
|
||||
# Bouton « Précédent », placé avec les boutons de navigation
|
||||
# (à côté de « Quitter » — leur parent est la GtkButtonBox du bas).
|
||||
quit_button = self.window.get_quit_button()
|
||||
button_box = quit_button.get_parent()
|
||||
self._back_button = Gtk.Button.new_with_mnemonic("_Précédent")
|
||||
self._back_button.connect("clicked", self._on_back_clicked)
|
||||
button_box.add(self._back_button)
|
||||
button_box.reorder_child(self._back_button, 0)
|
||||
if self._gui is None or self._hub is None:
|
||||
# Références introuvables (flux inattendu) : bouton grisé plutôt
|
||||
# qu'une navigation cassée — l'install reste possible.
|
||||
log.warning("FWS recap: hub/UI introuvables, « Précédent » désactivé.")
|
||||
self._back_button.set_sensitive(False)
|
||||
self._back_button.show()
|
||||
|
||||
def _on_back_clicked(self, _button):
|
||||
ui = self._gui
|
||||
hub = self._hub
|
||||
if ui is None or hub is None:
|
||||
return
|
||||
log.info("FWS recap: retour au hub Résumé pour modification.")
|
||||
# La file d'actions du GUI est CONSOMMÉE (pop(0)) à chaque écran :
|
||||
# ici _actions = [FWSRecapSpoke, ProgressSpoke] et le hub en est déjà
|
||||
# sorti. On le réinsère en tête pour que le prochain « Commencer
|
||||
# l'installation » ré-enchaîne hub → NOUVELLE page récap (résumé
|
||||
# recalculé avec les choix modifiés) → installation.
|
||||
if not ui._actions or ui._actions[0] is not SummaryHub:
|
||||
ui._actions.insert(0, SummaryHub)
|
||||
# _on_continue_clicked a désactivé le « continuer » du hub en le
|
||||
# quittant : on le réarme, sinon « Commencer l'installation » est mort.
|
||||
hub.window.set_may_continue(True)
|
||||
# Même séquence que la transition officielle (_on_continue_clicked).
|
||||
self.exited.emit(self)
|
||||
hub.refresh()
|
||||
ui.mainWindow.setCurrentAction(hub)
|
||||
hub.entered.emit(hub)
|
||||
ui._currentAction = hub
|
||||
|
||||
def refresh(self):
|
||||
StandaloneSpoke.refresh(self)
|
||||
text = _build_summary()
|
||||
self.builder.get_object("recapView").get_buffer().set_text(text)
|
||||
# Copie pour le harnais de debug du .xinitrc (fws-debug.txt).
|
||||
try:
|
||||
with open("/tmp/fws-hw-summary.txt", "w") as fh:
|
||||
fh.write(text)
|
||||
except OSError:
|
||||
pass
|
||||
Reference in New Issue
Block a user