From e35b352a2a0c40a82b89fbbb3ea6db71b3b7a5a1 Mon Sep 17 00:00:00 2001 From: nocode Date: Tue, 7 Jul 2026 02:47:43 +0200 Subject: [PATCH] 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. --- .../airootfs/usr/local/bin/fws-recovery-gui | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100755 configs/releng/airootfs/usr/local/bin/fws-recovery-gui diff --git a/configs/releng/airootfs/usr/local/bin/fws-recovery-gui b/configs/releng/airootfs/usr/local/bin/fws-recovery-gui new file mode 100755 index 0000000..cd46998 --- /dev/null +++ b/configs/releng/airootfs/usr/local/bin/fws-recovery-gui @@ -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 → répare (sortie streamée ici) +# fws-recovery shell → 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("Réparation du système FWS") + 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"{label}") + 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"{n} 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( + "Authentification annulée. Ferme puis relance" + " l'application pour réessayer.") + else: + self._status.set_markup( + "Aucune installation FWS détectée. 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"{label} 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("Terminé ✔ — autre réparation, ou redémarre.") + elif rc in (126, 127): + self._status.set_markup( + "Authentification annulée — rien n'a été modifié.") + else: + self._status.set_markup( + f"Échec (code {rc}) — 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())