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:
+269
@@ -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())
|
||||
Reference in New Issue
Block a user