feat(recovery): add fws-recovery-gui GTK3 application for system repair

Implement a graphical interface for the FWS system recovery tool using GTK3.

Features:
- Scans for FWS installations on disk
- Provides action buttons for common repairs: GRUB reinstall, initramfs
  regeneration, pacman repair, and full system repair
- Displays real-time engine output in a log viewer
- Supports both root session (live mode) and regular user (pkexec elevation)
- Includes terminal access (chroot) for manual diagnostics
- Offers system reboot/poweroff controls

The GUI replaces Arch Linux branding with FWS in output for consistency
while preserving technical identifiers (package names, file paths) for
accurate diagnostics.
This commit is contained in:
2026-07-07 02:47:43 +02:00
parent 44b476e1d0
commit e35b352a2a
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
# FWS — application GRAPHIQUE de réparation du système (mode « Réparer FWS »
# du live). GTK3 : le live embarque python-gobject/gtk3 via Anaconda.
#
# Pilote le MOTEUR fws-recovery en sous-commandes :
# fws-recovery scan → détecte les installations FWS
# fws-recovery repair <action> <dev> → répare (sortie streamée ici)
# fws-recovery shell <dev> → terminal chroot (xterm)
#
# Deux contextes de lancement :
# • mode « Réparer FWS » (live OU entrée GRUB de l'OS installé) : session
# root via fws-recovery-session → le moteur est appelé directement ;
# • menu d'applications de l'OS installé : lancée en UTILISATEUR → chaque
# appel du moteur passe par pkexec (politique org.fws.recovery,
# auth_admin_keep : un seul mot de passe pour toute la session).
import os
import shutil
import subprocess
import sys
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import GLib, Gtk, Pango # noqa: E402
GLib.set_prgname("fws-recovery-gui")
_ENGINE = "/usr/local/bin/fws-recovery"
def _engine_cmd(args):
"""Commande moteur, élevée via pkexec si on n'est pas root."""
cmd = [_ENGINE] + args
if os.geteuid() != 0:
cmd = ["pkexec"] + cmd
return cmd
_ACTIONS = [
("grub", "Réinstaller GRUB",
"le système ne démarre plus / « Operating System not found »"),
("initramfs", "Régénérer l'initramfs",
"kernel panic au démarrage, module manquant (mkinitcpio -P)"),
("pacman", "Réparer pacman",
"verrou bloqué, clés de signature cassées"),
("pacman-update", "Réparer pacman + mise à jour complète",
"idem, puis pacman -Syu (réseau requis)"),
("all", "Tout réparer",
"initramfs puis GRUB — le réflexe « ça ne boote plus »"),
]
class RecoveryWindow(Gtk.Window):
def __init__(self):
super().__init__(title="FWS — Réparation du système")
self.set_icon_name("fws-recovery")
self.set_default_size(720, 560)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_border_width(18)
self.connect("destroy", Gtk.main_quit)
self._proc = None
self._installs = [] # [(dev, nom), …]
self._action_buttons = []
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.add(root)
title = Gtk.Label()
title.set_markup("<span size='x-large' weight='bold'>Réparation du système FWS</span>")
root.pack_start(title, False, False, 0)
self._status = Gtk.Label(label="Recherche des installations FWS sur les disques…")
self._status.set_xalign(0)
root.pack_start(self._status, False, False, 0)
# Sélecteur d'installation (rempli après le scan).
picker_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
picker_row.pack_start(Gtk.Label(label="Système à réparer :"), False, False, 0)
self._picker = Gtk.ComboBoxText()
picker_row.pack_start(self._picker, True, True, 0)
root.pack_start(picker_row, False, False, 0)
# Boutons d'action (style fws-hello : titre + description).
for key, label, desc in _ACTIONS:
button = Gtk.Button()
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
lab = Gtk.Label()
lab.set_markup(f"<b>{label}</b>")
lab.set_xalign(0)
sub = Gtk.Label(label=desc)
sub.set_xalign(0)
sub.get_style_context().add_class("dim-label")
inner.pack_start(lab, False, False, 0)
inner.pack_start(sub, False, False, 0)
button.add(inner)
button.connect("clicked", self._on_action, key)
root.pack_start(button, False, False, 0)
self._action_buttons.append(button)
shell_btn = Gtk.Button(label="Ouvrir un terminal dans le système (chroot)")
shell_btn.connect("clicked", self._on_shell)
root.pack_start(shell_btn, False, False, 0)
self._action_buttons.append(shell_btn)
# Journal (sortie du moteur, en continu).
frame = Gtk.Frame(label="Journal")
scroll = Gtk.ScrolledWindow()
scroll.set_min_content_height(150)
self._log = Gtk.TextView()
self._log.set_editable(False)
self._log.set_cursor_visible(False)
self._log.set_monospace(True)
self._log.modify_font(Pango.FontDescription("Monospace 9"))
scroll.add(self._log)
frame.add(scroll)
root.pack_start(frame, True, True, 0)
# Pied : redémarrer / éteindre.
footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
reboot = Gtk.Button(label="Redémarrer")
reboot.connect("clicked", lambda _b: self._power("reboot"))
poweroff = Gtk.Button(label="Éteindre")
poweroff.connect("clicked", lambda _b: self._power("poweroff"))
footer.pack_end(reboot, False, False, 0)
footer.pack_end(poweroff, False, False, 0)
root.pack_start(footer, False, False, 0)
self._set_busy(True)
GLib.idle_add(self._start_scan)
# ── Utilitaires ─────────────────────────────────────────────────────────
def _append(self, text):
# Rebranding d'affichage : les outils sous-jacents parlent
# d'« Arch Linux » — pas le journal FWS. (Les identifiants techniques
# comme archlinux-keyring restent tels quels : ce sont de vrais noms
# de fichiers/paquets, les masquer fausserait le diagnostic.)
text = text.replace("Arch Linux", "FWS").replace("Fedora", "FWS")
buf = self._log.get_buffer()
buf.insert(buf.get_end_iter(), text)
mark = buf.create_mark(None, buf.get_end_iter(), False)
self._log.scroll_mark_onscreen(mark)
buf.delete_mark(mark)
def _set_busy(self, busy):
for b in self._action_buttons:
b.set_sensitive(not busy and bool(self._installs))
# Sensible même avec UNE seule install : un combo grisé donne
# l'impression que TOUTE l'app est désactivée (retour utilisateur).
self._picker.set_sensitive(not busy and bool(self._installs))
def _selected_dev(self):
idx = self._picker.get_active()
if 0 <= idx < len(self._installs):
return self._installs[idx][0]
return None
def _power(self, what):
subprocess.run(["systemctl", what], check=False)
# ── Scan des installations ──────────────────────────────────────────────
def _start_scan(self):
self._run_engine(["scan"], self._scan_done, quiet=True)
return False
def _scan_done(self, rc, output):
self._installs = []
for line in output.splitlines():
if "|" in line:
dev, _, name = line.partition("|")
self._installs.append((dev.strip(), name.strip()))
self._picker.append_text(f"{name.strip()} — {dev.strip()}")
if self._installs:
self._picker.set_active(0)
n = len(self._installs)
self._status.set_markup(
f"<b>{n}</b> installation{'s' if n > 1 else ''} FWS détectée{'s' if n > 1 else ''}."
" Choisis une réparation :")
elif rc in (126, 127):
# pkexec : authentification annulée ou refusée.
self._status.set_markup(
"<b>Authentification annulée.</b> Ferme puis relance"
" l'application pour réessayer.")
else:
self._status.set_markup(
"<b>Aucune installation FWS détectée.</b> Vérifie les disques"
" (terminal ci-dessous) ou redémarre.")
self._append("Aucune installation FWS trouvée.\n\n"
+ subprocess.run(["lsblk", "-o", "NAME,SIZE,TYPE,FSTYPE"],
capture_output=True, text=True,
check=False).stdout)
self._set_busy(False)
# Le terminal reste utile même sans installation détectée (diagnostic).
self._action_buttons[-1].set_sensitive(True)
# ── Exécution du moteur avec sortie en continu ──────────────────────────
def _run_engine(self, args, done_cb, quiet=False):
self._set_busy(True)
self._proc = subprocess.Popen(
_engine_cmd(args), stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=1)
chunks = []
def on_io(source, _cond):
line = source.readline()
if line:
chunks.append(line)
if not quiet:
self._append(line)
return True
rc = self._proc.wait()
done_cb(rc, "".join(chunks))
return False
GLib.io_add_watch(self._proc.stdout,
GLib.IO_IN | GLib.IO_HUP, on_io)
def _on_action(self, _button, key):
dev = self._selected_dev()
if not dev:
return
label = next(lab for k, lab, _d in _ACTIONS if k == key)
self._status.set_markup(f"<b>{label}</b> sur {dev} — en cours…")
self._append(f"\n───── {label} ({dev}) ─────\n")
self._run_engine(["repair", key, dev], self._action_done)
def _action_done(self, rc, _output):
if rc == 0:
self._status.set_markup("<b>Terminé ✔</b> — autre réparation, ou redémarre.")
elif rc in (126, 127):
self._status.set_markup(
"<b>Authentification annulée</b> — rien n'a été modifié.")
else:
self._status.set_markup(
f"<b>Échec (code {rc})</b> — détail dans le journal ci-dessous.")
self._set_busy(False)
def _on_shell(self, _button):
dev = self._selected_dev()
if dev and os.geteuid() == 0:
subprocess.Popen([_ENGINE, "shell", dev])
elif dev:
# pkexec purge DISPLAY → le moteur ouvre le chroot DANS cet xterm
# (lancé en utilisateur, donc visible sur X11 comme Wayland).
subprocess.Popen(["xterm", "-title",
"FWS — terminal dans le système (chroot)",
"-e", "pkexec", _ENGINE, "shell", dev])
elif shutil.which("xterm"):
subprocess.Popen(["xterm", "-title", "FWS — terminal"])
def main():
if not Gtk.init_check()[0]:
# Pas d'affichage : le .zprofile retombera sur l'assistant console.
return 1
if os.geteuid() != 0 and not shutil.which("pkexec"):
dlg = Gtk.MessageDialog(message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.CLOSE,
text="pkexec introuvable : lance fws-recovery-gui en root.")
dlg.run()
return 1
win = RecoveryWindow()
win.show_all()
Gtk.main()
return 0
if __name__ == "__main__":
sys.exit(main())