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