From ce6e9533c2462ccaa292e7040ce7514f0d9cdbf0 Mon Sep 17 00:00:00 2001 From: nocode Date: Wed, 8 Jul 2026 23:56:41 +0200 Subject: [PATCH] feat(gui): add dual-boot windows spoke for anaconda installer Add new FWSWindowsSpoke to handle dual-boot Windows gaming configuration in the Anaconda GUI. This spoke allows users to: - Enable/disable dual-boot Windows gaming mode - Select a disk to dedicate to Windows - Configure NTFS partition size (100-200 GiB default) - Provide path to Windows 11 ISO The spoke persists configuration to /tmp/fws-windows for processing by the kickstart script. It implements validation for disk selection, ISO path, and partition size constraints. Platform detection ensures the feature only appears on UEFI systems. --- .../pyanaconda/ui/gui/spokes/fws_windows.py | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 configs/baseline/airootfs/usr/lib/python3.14/site-packages/pyanaconda/ui/gui/spokes/fws_windows.py diff --git a/configs/baseline/airootfs/usr/lib/python3.14/site-packages/pyanaconda/ui/gui/spokes/fws_windows.py b/configs/baseline/airootfs/usr/lib/python3.14/site-packages/pyanaconda/ui/gui/spokes/fws_windows.py new file mode 100644 index 0000000..08f3d7f --- /dev/null +++ b/configs/baseline/airootfs/usr/lib/python3.14/site-packages/pyanaconda/ui/gui/spokes/fws_windows.py @@ -0,0 +1,325 @@ +# FWS — Spoke « Dual-boot Windows gaming » : case + disque + taille + ISO. +# +# POURQUOI ICI (airootfs, pas dans le paquet anaconda) : identique à +# fws_desktop.py / fws_recap.py — Anaconda découvre les spokes au RUNTIME via +# collect() (os.listdir du dossier des spokes puis import). Déposer ce .py dans +# le dossier des spokes via l'overlay airootfs suffit à le faire apparaître SANS +# rebuild du paquet anaconda (juste ./build.sh). Le glade associé est dans +# /usr/share/anaconda/ui/spokes/fws_windows.glade (aussi via l'airootfs). +# +# CATÉGORIE = SystemCategory (pas SoftwareCategory) : c'est du disque/boot, il +# vit sur le hub Résumé à côté de « Destination de l'installation », pas dans +# « Logiciel ». +# +# CE QU'IL FAIT : coche « Dual-boot Windows gaming », choix du disque à dédier à +# Windows, taille NTFS (défaut 200 GiO, min 100, max = taille du disque), chemin +# de l'ISO Windows 11 fournie par l'utilisateur. Il n'exécute AUCUNE opération +# disque : son seul canal vers l'installation est /tmp/fws-windows, relu par le +# kickstart (%post --nochroot : carve + wimlib apply + injection gameboot ; +# %post chroot : swap/resume + Secure Boot). Exactement le modèle fws_desktop. +# +# ⚠ Chemin /usr/lib/python3.14/... couplé à la version de Python d'Arch (3.14 au +# build d'anaconda) — à mettre à jour au bump majeur de Python (= rebuild +# anaconda de toute façon). SystemCategory / internes GUI (anaconda 45.8) à +# re-vérifier à chaque bump, comme l'avertit l'en-tête de fws_recap.py. + +import os + +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.core.i18n import CN_, _ +from pyanaconda.ui.categories.system import SystemCategory +from pyanaconda.ui.gui.spokes import NormalSpoke + +log = get_module_logger(__name__) + +__all__ = ["FWSWindowsSpoke"] + +# Contrat de fichier relu par le kickstart (clé=valeur, une par ligne). +_STATE_FILE = "/tmp/fws-windows" + +_DEFAULT_SIZE_GIB = 200 +_MIN_SIZE_GIB = 100 +# Marge à laisser sur le disque en fin de partition NTFS (ESP Windows + MSR). +_TAIL_RESERVE_GIB = 3 + + +def _capture(cmd, args): + try: + return util.execWithCapture(cmd, args) or "" + except (OSError, RuntimeError): + return "" + + +def _list_disks(): + """[(devnode, libellé, taille_gib)] pour chaque disque physique (lsblk).""" + disks = [] + for line in _capture("lsblk", ["-dpno", "NAME,SIZE,MODEL,TYPE"]).splitlines(): + parts = line.split() + # NAME SIZE [MODEL les mots du milieu] TYPE ; TYPE = dernier champ. + if len(parts) < 2 or parts[-1] != "disk": + continue + name = parts[0] + size = parts[1] + model = " ".join(parts[2:-1]) or "?" + label = f"{name} — {size} {model}" + disks.append((name, label, _disk_size_gib(name))) + return disks + + +def _disk_size_gib(devnode): + """Taille du disque en GiO entiers (0 si inconnue).""" + raw = _capture("lsblk", ["-dbno", "SIZE", devnode]).strip().split("\n")[0] + try: + return int(raw) // (1024 ** 3) + except (ValueError, TypeError): + return 0 + + +def _disk_nonempty(devnode): + """True si le disque porte des partitions (donc des données à écraser).""" + return "part" in _capture("lsblk", ["-rno", "TYPE", devnode]).split() + + +def _hib_reserve_gib(): + """Réserve de queue = swap d'hibernation (RAM+VRAM+marge) + ESP/MSR, ALIGNÉE + sur fws-windows-deploy (qui exige size_gib <= disque − swap − 4). Sans ça, le + GUI proposerait une taille NTFS que le déployeur refuserait (ou qui déborde).""" + mem = 0 + try: + with open("/proc/meminfo") as fh: + for line in fh: + if line.startswith("MemTotal"): + mem = int(line.split()[1]) // (1024 * 1024) + break + except OSError: + pass + vram = 0 + for tok in _capture( + "nvidia-smi", + ["--query-gpu=memory.total", "--format=csv,noheader,nounits"]).split(): + if tok.isdigit(): + vram += int(tok) // 1024 + swap = max(96, mem + vram + 4) + return swap + 6 # + ESP (1 GiO) + MSR + marge + + +class FWSWindowsSpoke(NormalSpoke): + """Dual-boot Windows gaming : écrit /tmp/fws-windows (enabled/disk/size/iso).""" + + builderObjects = ["adj_size", "fwsWindowsWindow"] + mainWidgetName = "fwsWindowsWindow" + uiFile = "spokes/fws_windows.glade" + category = SystemCategory + icon = "applications-games-symbolic" + title = CN_("GUI|Spoke", "_Dual-boot Windows") + + @staticmethod + def get_screen_id(): + return "fws-windows-dualboot" + + def __init__(self, *args): + NormalSpoke.__init__(self, *args) + # État courant (persisté dans /tmp/fws-windows par apply()). + self._enabled = False + self._disk = "" + self._size = _DEFAULT_SIZE_GIB + self._iso = "" + self._uefi = os.path.exists("/sys/firmware/efi") + self._disks = [] + # Réserve de queue alignée sur le swap d'hibernation du déployeur [M7]. + self._reserve = _hib_reserve_gib() + + # ------------------------------------------------------------------ cycle + def initialize(self): + NormalSpoke.initialize(self) + self._chk = self.builder.get_object("chk_enable") + self._box = self.builder.get_object("box_options") + self._combo = self.builder.get_object("combo_disk") + self._adj = self.builder.get_object("adj_size") + self._spin = self.builder.get_object("spin_size") + self._entry = self.builder.get_object("entry_iso") + self._btn = self.builder.get_object("btn_iso") + self._warn = self.builder.get_object("iso_warn") + self._note = self.builder.get_object("note_platform") + + # Peuple le combo des disques (une fois). + self._disks = _list_disks() + for name, label, _gib in self._disks: + self._combo.append(name, label) + + # Reprend un choix déjà écrit (revisite du spoke). + self._load_state() + + # Câblage des signaux (pas de handler dans le .glade, comme fws_recap). + self._chk.connect("toggled", self._on_toggle) + self._combo.connect("changed", self._on_disk_changed) + self._entry.connect("changed", self._on_iso_changed) + self._btn.connect("clicked", self._on_browse) + + # Sur BIOS (non-UEFI), le dual-boot est impossible : on grise tout. + if not self._uefi: + self._enabled = False + self._chk.set_sensitive(False) + self._note.set_visible(True) + + def refresh(self): + # Reflète l'état courant dans les widgets. + self._chk.set_active(self._enabled) + if self._disk: + self._combo.set_active_id(self._disk) + # AUCUNE présélection par défaut : sans choix explicite de l'utilisateur, + # `completed` refuse (il exige `disk`) → jamais d'effacement « par + # accident » d'un disque en cliquant simplement Suivant. (Bug corrigé : + # set_active(0) ciblait le 1er disque lsblk — souvent un disque de jeux.) + self._update_size_bounds() + self._spin.set_value(self._size) + self._entry.set_text(self._iso) + self._box.set_sensitive(self._enabled and self._uefi) + self._validate() + + def apply(self): + # Relit les widgets et persiste pour le kickstart. + self._enabled = self._chk.get_active() and self._uefi + self._disk = self._combo.get_active_id() or "" + self._size = int(self._spin.get_value()) + self._iso = self._entry.get_text().strip() + self._save_state() + + # --------------------------------------------------------------- handlers + def _on_toggle(self, _btn): + self._box.set_sensitive(self._chk.get_active()) + self._validate() + + def _on_disk_changed(self, _combo): + self._update_size_bounds() + self._validate() + + def _on_iso_changed(self, _entry): + self._validate() + + def _on_browse(self, _btn): + dialog = Gtk.FileChooserNative.new( + _("Select the Windows 11 ISO"), self.window, + Gtk.FileChooserAction.OPEN, _("_Select"), _("_Cancel")) + flt = Gtk.FileFilter() + flt.set_name(_("ISO images")) + flt.add_pattern("*.iso") + flt.add_pattern("*.ISO") + dialog.add_filter(flt) + # Démarrer sur les médias amovibles montés (clé USB avec l'ISO). + for start in ("/run/media", "/mnt", os.path.expanduser("~")): + if os.path.isdir(start): + dialog.set_current_folder(start) + break + if dialog.run() == Gtk.ResponseType.ACCEPT: + path = dialog.get_filename() + if path: + self._entry.set_text(path) + dialog.destroy() + + # ----------------------------------------------------------------- helpers + def _update_size_bounds(self): + """Borne haute du spin = taille du disque choisi − réserve ESP/MSR.""" + name = self._combo.get_active_id() + gib = 0 + for dn, _label, dgib in self._disks: + if dn == name: + gib = dgib + break + upper = max(_MIN_SIZE_GIB, gib - self._reserve) if gib else 2000 + self._adj.set_lower(_MIN_SIZE_GIB) + self._adj.set_upper(upper) + if self._spin.get_value() > upper: + self._spin.set_value(upper) + + def _validate(self): + """Message inline (jamais bloquant) : reflète la validité des choix.""" + if not self._chk.get_active(): + self._warn.set_text("") + return + iso = self._entry.get_text().strip() + msgs = [] + if not self._combo.get_active_id(): + msgs.append(_("Select the disk to dedicate to Windows.")) + if not iso: + msgs.append(_("Provide the path to your Windows 11 ISO.")) + elif not os.path.isfile(iso): + msgs.append(_("ISO not found: %s") % iso) + if msgs: + self._warn.set_text("⚠ " + " ".join(msgs)) + else: + disk = self._combo.get_active_id() + extra = _(" — ⚠ THIS DISK CONTAINS DATA") if _disk_nonempty(disk) else "" + self._warn.set_text( + (_("⚠ The whole disk %s will be ERASED and dedicated to Windows.") + % disk) + extra) + + def _load_state(self): + try: + with open(_STATE_FILE) as fh: + data = fh.read() + except OSError: + return + for line in data.splitlines(): + if "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + val = val.strip() + if key == "enabled": + self._enabled = val == "1" + elif key == "disk": + self._disk = val + elif key == "size_gib" and val.isdigit(): + self._size = int(val) + elif key == "iso": + self._iso = val + + def _save_state(self): + lines = [ + "# Écrit par le spoke FWS « Dual-boot Windows ». Relu par le kickstart.", + "enabled=%d" % (1 if self._enabled else 0), + "disk=%s" % self._disk, + "size_gib=%d" % self._size, + "iso=%s" % self._iso, + ] + try: + with open(_STATE_FILE, "w") as fh: + fh.write("\n".join(lines) + "\n") + except OSError as exc: + log.warning("FWS windows: écriture %s impossible : %s", + _STATE_FILE, exc) + + # -------------------------------------------------------------- propriétés + @property + def status(self): + if not self._uefi: + return _("Unavailable (requires UEFI)") + if not self._enabled: + return _("Disabled (FWS only)") + iso = os.path.basename(self._iso) if self._iso else _("no ISO") + return _("Windows on %(disk)s — %(size)d GiB — %(iso)s") % { + "disk": self._disk or "?", "size": self._size, "iso": iso} + + @property + def completed(self): + # Désactivé = choix valide → jamais bloquant. Activé = exige disque + + # ISO lisible (sinon l'écran passe « orange » : l'utilisateur voit qu'il + # manque quelque chose, mais l'install FWS reste possible). + if not self._enabled: + return True + return bool(self._disk) and bool(self._iso) and os.path.isfile(self._iso) + + @property + def mandatory(self): + return False + + @property + def ready(self): + return True