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.
This commit is contained in:
2026-07-08 23:56:41 +02:00
parent 4d15dc2632
commit ce6e9533c2
@@ -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