#!/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())