From bc3d60e78fe0de18a1c59afc058fe29d9cb3a5de Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 06:03:09 +0100 Subject: [PATCH 01/16] Add dialog to install EFI bootloader to removable location This is just for GRUB and Limine for now. --- archinstall/lib/args.py | 3 ++ archinstall/lib/global_menu.py | 11 ++++++- archinstall/lib/installer.py | 24 +++++++------- archinstall/lib/interactions/system_conf.py | 36 +++++++++++++++++++++ archinstall/scripts/guided.py | 2 +- 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 48a379b09a..804d82ebad 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -65,6 +65,7 @@ class ArchConfig: mirror_config: MirrorConfiguration | None = None network_config: NetworkConfiguration | None = None bootloader: Bootloader | None = None + bootloader_removable: bool = False uki: bool = False app_config: ApplicationConfiguration | None = None auth_config: AuthenticationConfiguration | None = None @@ -103,6 +104,7 @@ def safe_json(self) -> dict[str, Any]: 'hostname': self.hostname, 'kernels': self.kernels, 'uki': self.uki, + 'bootloader_removable': self.bootloader_removable, 'ntp': self.ntp, 'packages': self.packages, 'parallel_downloads': self.parallel_downloads, @@ -183,6 +185,7 @@ def from_config(cls, args_config: dict[str, Any], args: Arguments) -> 'ArchConfi arch_config.bootloader = Bootloader.from_arg(bootloader_config, args.skip_boot) arch_config.uki = args_config.get('uki', False) + arch_config.bootloader_removable = args_config.get('bootloader_removable', False) if args_config.get('uki') and (arch_config.bootloader is None or not arch_config.bootloader.has_uki_support()): arch_config.uki = False diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 8acafd8a4c..f1b9fb07e2 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -22,7 +22,7 @@ ask_ntp, ) from .interactions.network_menu import ask_to_configure_network -from .interactions.system_conf import ask_for_bootloader, ask_for_swap, ask_for_uki, select_kernel +from .interactions.system_conf import ask_for_bootloader, ask_for_bootloader_removable, ask_for_swap, ask_for_uki, select_kernel from .locale.locale_menu import LocaleMenu from .menu.abstract_menu import CONFIG_KEY, AbstractMenu from .mirrors import MirrorMenu @@ -512,6 +512,15 @@ def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None: else: uki.enabled = True + # If GRUB or Limine is selected on UEFI, immediately ask about removable installation + if bootloader in [Bootloader.Grub, Bootloader.Limine] and SysInfo.has_uefi(): + current_removable = self._arch_config.bootloader_removable + removable = ask_for_bootloader_removable(current_removable) + self._arch_config.bootloader_removable = removable + else: + # Reset removable flag for other bootloaders + self._arch_config.bootloader_removable = False + return bootloader def _select_profile(self, current_profile: ProfileConfiguration | None) -> ProfileConfiguration | None: diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 71e3b51712..84ca175842 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1268,6 +1268,7 @@ def _add_grub_bootloader( boot_partition: PartitionModification, root: PartitionModification | LvmVolume, efi_partition: PartitionModification | None, + bootloader_removable: bool = False, ) -> None: debug('Installing grub bootloader') @@ -1311,9 +1312,11 @@ def _add_grub_bootloader( f'--efi-directory={efi_partition.mountpoint}', *boot_dir_arg, '--bootloader-id=GRUB', - '--removable', ] + if bootloader_removable: + add_options.append('--removable') + command.extend(add_options) try: @@ -1354,6 +1357,7 @@ def _add_limine_bootloader( efi_partition: PartitionModification | None, root: PartitionModification | LvmVolume, uki_enabled: bool = False, + bootloader_removable: bool = False, ) -> None: debug('Installing Limine bootloader') @@ -1376,17 +1380,11 @@ def _add_limine_bootloader( info(f'Limine EFI partition: {efi_partition.dev_path}') parent_dev_path = device_handler.get_parent_device_path(efi_partition.safe_dev_path) - is_target_usb = ( - SysCommand( - f'udevadm info --no-pager --query=property --property=ID_BUS --value --name={parent_dev_path}', - ).decode() - == 'usb' - ) try: efi_dir_path = self.target / efi_partition.mountpoint.relative_to('/') / 'EFI' efi_dir_path_target = efi_partition.mountpoint / 'EFI' - if is_target_usb: + if bootloader_removable: efi_dir_path = efi_dir_path / 'BOOT' efi_dir_path_target = efi_dir_path_target / 'BOOT' else: @@ -1406,7 +1404,7 @@ def _add_limine_bootloader( f'/usr/bin/cp /usr/share/limine/BOOTIA32.EFI {efi_dir_path_target}/ && /usr/bin/cp /usr/share/limine/BOOTX64.EFI {efi_dir_path_target}/' ) - if not is_target_usb: + if not bootloader_removable: # Create EFI boot menu entry for Limine. try: with open('/sys/firmware/efi/fw_platform_size') as fw_platform_size: @@ -1612,7 +1610,7 @@ def _config_uki( if not self.mkinitcpio(['-P']): error('Error generating initramfs (continuing anyway)') - def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False) -> None: + def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False, bootloader_removable: bool = False) -> None: """ Adds a bootloader to the installation instance. Archinstall supports one of three types: @@ -1622,6 +1620,8 @@ def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False) -> N * efistub (beta) :param bootloader: Type of bootloader to be added + :param uki_enabled: Whether to use unified kernel images + :param bootloader_removable: Whether to install to removable media location (UEFI only, for GRUB and Limine) """ for plugin in plugins.values(): @@ -1650,11 +1650,11 @@ def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False) -> N case Bootloader.Systemd: self._add_systemd_bootloader(boot_partition, root, efi_partition, uki_enabled) case Bootloader.Grub: - self._add_grub_bootloader(boot_partition, root, efi_partition) + self._add_grub_bootloader(boot_partition, root, efi_partition, bootloader_removable) case Bootloader.Efistub: self._add_efistub_bootloader(boot_partition, root, uki_enabled) case Bootloader.Limine: - self._add_limine_bootloader(boot_partition, efi_partition, root, uki_enabled) + self._add_limine_bootloader(boot_partition, efi_partition, root, uki_enabled, bootloader_removable) def add_additional_packages(self, packages: str | list[str]) -> None: return self.pacman.strap(packages) diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 99a1d8bd16..6322ae866c 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -90,6 +90,42 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: raise ValueError('Unhandled result type') +def ask_for_bootloader_removable(preset: bool = False) -> bool: + prompt = ( + tr('Would you like to install the bootloader to the default removable media search location?\n') + + '\n' + + tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:\n') + + '\n' + + tr(' • USB drives or other portable external media\n') + + tr(' • Systems where you want the disk to be bootable on any computer\n') + + tr(' • Firmware that does not properly support NVRAM boot entries\n') + + '\n' + + tr('This is NOT recommended if none of the above apply, as it makes installing multiple\n') + + tr('EFI bootloaders on the same disk more challenging, and it overwrites whatever bootloader\n') + + tr('was previously installed on the default removable media search location, if any.\n') + ) + + group = MenuItemGroup.yes_no() + group.set_focus_by_value(preset) + + result = SelectMenu[bool]( + group, + header=prompt, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER, + allow_skip=True, + ).run() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') + + def ask_for_uki(preset: bool = True) -> bool: prompt = tr('Would you like to use unified kernel images?') + '\n' diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index c0a252b888..661bc9bed5 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -103,7 +103,7 @@ def perform_installation(mountpoint: Path) -> None: if config.bootloader == Bootloader.Grub and SysInfo.has_uefi(): installation.add_additional_packages('grub') - installation.add_bootloader(config.bootloader, config.uki) + installation.add_bootloader(config.bootloader, config.uki, config.bootloader_removable) # If user selected to copy the current ISO network configuration # Perform a copy of the config From 07cf0b0c8b5f0993783a115d7cde38cb663c429a Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 19:23:40 +0100 Subject: [PATCH 02/16] Move bootloader removable and UKI selections to bootloader submenu --- archinstall/lib/args.py | 30 +-- archinstall/lib/bootloader/__init__.py | 3 + archinstall/lib/bootloader/bootloader_menu.py | 211 ++++++++++++++++++ archinstall/lib/global_menu.py | 69 ++---- archinstall/lib/interactions/__init__.py | 4 +- archinstall/lib/interactions/system_conf.py | 106 --------- archinstall/lib/models/bootloader.py | 31 +++ archinstall/scripts/guided.py | 11 +- examples/config-sample.json | 6 +- tests/data/test_config.json | 6 +- tests/test_args.py | 9 +- 11 files changed, 306 insertions(+), 180 deletions(-) create mode 100644 archinstall/lib/bootloader/__init__.py create mode 100644 archinstall/lib/bootloader/bootloader_menu.py diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 804d82ebad..53d4e84eb8 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -15,7 +15,7 @@ from archinstall.lib.crypt import decrypt from archinstall.lib.models.application import ApplicationConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration -from archinstall.lib.models.bootloader import Bootloader +from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.device import DiskEncryption, DiskLayoutConfiguration from archinstall.lib.models.locale import LocaleConfiguration from archinstall.lib.models.mirrors import MirrorConfiguration @@ -64,9 +64,7 @@ class ArchConfig: profile_config: ProfileConfiguration | None = None mirror_config: MirrorConfiguration | None = None network_config: NetworkConfiguration | None = None - bootloader: Bootloader | None = None - bootloader_removable: bool = False - uki: bool = False + bootloader_config: BootloaderConfiguration | None = None app_config: ApplicationConfiguration | None = None auth_config: AuthenticationConfiguration | None = None hostname: str = 'archlinux' @@ -103,8 +101,6 @@ def safe_json(self) -> dict[str, Any]: 'archinstall-language': self.archinstall_language.json(), 'hostname': self.hostname, 'kernels': self.kernels, - 'uki': self.uki, - 'bootloader_removable': self.bootloader_removable, 'ntp': self.ntp, 'packages': self.packages, 'parallel_downloads': self.parallel_downloads, @@ -112,7 +108,7 @@ def safe_json(self) -> dict[str, Any]: 'timezone': self.timezone, 'services': self.services, 'custom_commands': self.custom_commands, - 'bootloader': self.bootloader.json() if self.bootloader else None, + 'bootloader_config': self.bootloader_config.json() if self.bootloader_config else None, 'app_config': self.app_config.json() if self.app_config else None, 'auth_config': self.auth_config.json() if self.auth_config else None, } @@ -181,14 +177,18 @@ def from_config(cls, args_config: dict[str, Any], args: Arguments) -> 'ArchConfi if net_config := args_config.get('network_config', None): arch_config.network_config = NetworkConfiguration.parse_arg(net_config) - if bootloader_config := args_config.get('bootloader', None): - arch_config.bootloader = Bootloader.from_arg(bootloader_config, args.skip_boot) - - arch_config.uki = args_config.get('uki', False) - arch_config.bootloader_removable = args_config.get('bootloader_removable', False) - - if args_config.get('uki') and (arch_config.bootloader is None or not arch_config.bootloader.has_uki_support()): - arch_config.uki = False + # Parse bootloader configuration + if bootloader_config_dict := args_config.get('bootloader_config', None): + # New format: bootloader_config with bootloader, uki, and removable + arch_config.bootloader_config = BootloaderConfiguration.parse_arg(bootloader_config_dict, args.skip_boot) + elif bootloader_str := args_config.get('bootloader', None): + # Old format: separate bootloader and uki fields (backward compatibility) + bootloader = Bootloader.from_arg(bootloader_str, args.skip_boot) + uki = args_config.get('uki', False) + # Validate UKI compatibility + if uki and not bootloader.has_uki_support(): + uki = False + arch_config.bootloader_config = BootloaderConfiguration(bootloader=bootloader, uki=uki, removable=False) # deprecated: backwards compatibility audio_config_args = args_config.get('audio_config', None) diff --git a/archinstall/lib/bootloader/__init__.py b/archinstall/lib/bootloader/__init__.py new file mode 100644 index 0000000000..7bcbb802a6 --- /dev/null +++ b/archinstall/lib/bootloader/__init__.py @@ -0,0 +1,3 @@ +from .bootloader_menu import BootloaderMenu + +__all__ = ['BootloaderMenu'] diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py new file mode 100644 index 0000000000..58693d4476 --- /dev/null +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -0,0 +1,211 @@ +from typing import override + +from archinstall.lib.translationhandler import tr +from archinstall.tui.curses_menu import SelectMenu +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType +from archinstall.tui.types import Alignment, FrameProperties, Orientation + +from ..args import arch_config_handler +from ..hardware import SysInfo +from ..menu.abstract_menu import AbstractSubMenu +from ..models.bootloader import Bootloader, BootloaderConfiguration + + +class BootloaderMenu(AbstractSubMenu[BootloaderConfiguration]): + def __init__( + self, + bootloader_conf: BootloaderConfiguration, + ): + self._bootloader_conf = bootloader_conf + menu_options = self._define_menu_options() + + self._item_group = MenuItemGroup(menu_options, sort_items=False, checkmarks=True) + super().__init__( + self._item_group, + config=self._bootloader_conf, + allow_reset=False, + ) + + def _define_menu_options(self) -> list[MenuItem]: + return [ + MenuItem( + text=tr('Bootloader'), + action=self._select_bootloader, + value=self._bootloader_conf.bootloader, + preview_action=self._prev_bootloader, + mandatory=True, + key='bootloader', + ), + MenuItem( + text=tr('Unified kernel images'), + action=self._select_uki, + value=self._bootloader_conf.uki, + preview_action=self._prev_uki, + key='uki', + ), + MenuItem( + text=tr('Install to removable location'), + action=self._select_removable, + value=self._bootloader_conf.removable, + preview_action=self._prev_removable, + key='removable', + ), + ] + + def _prev_bootloader(self, item: MenuItem) -> str | None: + if item.value: + return f'{tr("Bootloader")}: {item.value.value}' + return None + + def _prev_uki(self, item: MenuItem) -> str | None: + if item.value: + return tr('Enabled') + return tr('Disabled') + + def _prev_removable(self, item: MenuItem) -> str | None: + if item.value: + return tr('Will install to /EFI/BOOT/ (removable location)') + return tr('Will install to standard location with NVRAM entry') + + @override + def run( + self, + additional_title: str | None = None, + ) -> BootloaderConfiguration: + super().run(additional_title=additional_title) + return self._bootloader_conf + + def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None: + bootloader = ask_for_bootloader(preset) + + if bootloader: + # Update UKI option based on bootloader + uki_item = self._menu_item_group.find_by_key('uki') + if not SysInfo.has_uefi() or not bootloader.has_uki_support(): + uki_item.enabled = False + uki_item.value = False + self._bootloader_conf.uki = False + else: + uki_item.enabled = True + + # Update removable option based on bootloader + removable_item = self._menu_item_group.find_by_key('removable') + if bootloader in [Bootloader.Grub, Bootloader.Limine] and SysInfo.has_uefi(): + removable_item.enabled = True + else: + removable_item.enabled = False + removable_item.value = False + self._bootloader_conf.removable = False + + return bootloader + + def _select_uki(self, preset: bool) -> bool: + return ask_for_uki(preset) + + def _select_removable(self, preset: bool) -> bool: + return ask_for_bootloader_removable(preset) + + +def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: + # Systemd is UEFI only + options = [] + hidden_options = [] + default = None + header = None + + if arch_config_handler.args.skip_boot: + default = Bootloader.NO_BOOTLOADER + else: + hidden_options += [Bootloader.NO_BOOTLOADER] + + if not SysInfo.has_uefi(): + options += [Bootloader.Grub, Bootloader.Limine] + if not default: + default = Bootloader.Grub + header = tr('UEFI is not detected and some options are disabled') + else: + options += [b for b in Bootloader if b not in hidden_options] + if not default: + default = Bootloader.Systemd + + items = [MenuItem(o.value, value=o) for o in options] + group = MenuItemGroup(items) + group.set_default_by_value(default) + group.set_focus_by_value(preset) + + result = SelectMenu[Bootloader]( + group, + header=header, + alignment=Alignment.CENTER, + frame=FrameProperties.min(tr('Bootloader')), + allow_skip=True, + ).run() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.get_value() + case ResultType.Reset: + raise ValueError('Unhandled result type') + + +def ask_for_bootloader_removable(preset: bool = False) -> bool: + prompt = ( + tr('Would you like to install the bootloader to the default removable media search location?\n') + + '\n' + + tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:\n') + + '\n' + + tr(' • USB drives or other portable external media\n') + + tr(' • Systems where you want the disk to be bootable on any computer\n') + + tr(' • Firmware that does not properly support NVRAM boot entries\n') + + '\n' + + tr('This is NOT recommended if none of the above apply, as it makes installing multiple\n') + + tr('EFI bootloaders on the same disk more challenging, and it overwrites whatever bootloader\n') + + tr('was previously installed on the default removable media search location, if any.\n') + ) + + group = MenuItemGroup.yes_no() + group.set_focus_by_value(preset) + + result = SelectMenu[bool]( + group, + header=prompt, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER, + allow_skip=True, + ).run() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') + + +def ask_for_uki(preset: bool = True) -> bool: + prompt = tr('Would you like to use unified kernel images?') + '\n' + + group = MenuItemGroup.yes_no() + group.set_focus_by_value(preset) + + result = SelectMenu[bool]( + group, + header=prompt, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER, + allow_skip=True, + ).run() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index f1b9fb07e2..b9e8de2d52 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -12,6 +12,7 @@ from .applications.application_menu import ApplicationMenu from .args import ArchConfig from .authentication.authentication_menu import AuthenticationMenu +from .bootloader import BootloaderMenu from .configuration import save_config from .hardware import SysInfo from .interactions.general_conf import ( @@ -22,11 +23,11 @@ ask_ntp, ) from .interactions.network_menu import ask_to_configure_network -from .interactions.system_conf import ask_for_bootloader, ask_for_bootloader_removable, ask_for_swap, ask_for_uki, select_kernel +from .interactions.system_conf import ask_for_swap, select_kernel from .locale.locale_menu import LocaleMenu from .menu.abstract_menu import CONFIG_KEY, AbstractMenu from .mirrors import MirrorMenu -from .models.bootloader import Bootloader +from .models.bootloader import Bootloader, BootloaderConfiguration from .models.locale import LocaleConfiguration from .models.mirrors import MirrorConfiguration from .models.network import NetworkConfiguration, NicType @@ -86,19 +87,10 @@ def _get_menu_options(self) -> list[MenuItem]: ), MenuItem( text=tr('Bootloader'), - value=Bootloader.get_default(), - action=self._select_bootloader, - preview_action=self._prev_bootloader, + action=self._select_bootloader_config, + preview_action=self._prev_bootloader_config, mandatory=True, - key='bootloader', - ), - MenuItem( - text=tr('Unified kernel images'), - value=False, - enabled=SysInfo.has_uefi(), - action=ask_for_uki, - preview_action=self._prev_uki, - key='uki', + key='bootloader_config', ), MenuItem( text=tr('Hostname'), @@ -379,13 +371,6 @@ def _prev_swap(self, item: MenuItem) -> str | None: return output return None - def _prev_uki(self, item: MenuItem) -> str | None: - if item.value is not None: - output = f'{tr("Unified kernel images")}: ' - output += tr('Enabled') if item.value else tr('Disabled') - return output - return None - def _prev_hostname(self, item: MenuItem) -> str | None: if item.value is not None: return f'{tr("Hostname")}: {item.value}' @@ -402,9 +387,10 @@ def _prev_kernel(self, item: MenuItem) -> str | None: return f'{tr("Kernel")}: {kernel}' return None - def _prev_bootloader(self, item: MenuItem) -> str | None: - if item.value is not None: - return f'{tr("Bootloader")}: {item.value.value}' + def _prev_bootloader_config(self, item: MenuItem) -> str | None: + bootloader_config: BootloaderConfiguration | None = item.value + if bootloader_config: + return bootloader_config.preview() return None def _validate_bootloader(self) -> str | None: @@ -418,16 +404,18 @@ def _validate_bootloader(self) -> str | None: XXX: The caller is responsible for wrapping the string with the translation shim if necessary. """ - bootloader: Bootloader | None = None + bootloader_config: BootloaderConfiguration | None = None root_partition: PartitionModification | None = None boot_partition: PartitionModification | None = None efi_partition: PartitionModification | None = None - bootloader = self._item_group.find_by_key('bootloader').value + bootloader_config = self._item_group.find_by_key('bootloader_config').value - if bootloader == Bootloader.NO_BOOTLOADER: + if not bootloader_config or bootloader_config.bootloader == Bootloader.NO_BOOTLOADER: return None + bootloader = bootloader_config.bootloader + if disk_config := self._item_group.find_by_key('disk_config').value: for layout in disk_config.device_modifications: if root_partition := layout.get_root_partition(): @@ -501,27 +489,16 @@ def _select_disk_config( return disk_config - def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None: - bootloader = ask_for_bootloader(preset) - - if bootloader: - uki = self._item_group.find_by_key('uki') - if not SysInfo.has_uefi() or not bootloader.has_uki_support(): - uki.value = False - uki.enabled = False - else: - uki.enabled = True + def _select_bootloader_config( + self, + preset: BootloaderConfiguration | None = None, + ) -> BootloaderConfiguration | None: + if preset is None: + preset = BootloaderConfiguration.get_default() - # If GRUB or Limine is selected on UEFI, immediately ask about removable installation - if bootloader in [Bootloader.Grub, Bootloader.Limine] and SysInfo.has_uefi(): - current_removable = self._arch_config.bootloader_removable - removable = ask_for_bootloader_removable(current_removable) - self._arch_config.bootloader_removable = removable - else: - # Reset removable flag for other bootloaders - self._arch_config.bootloader_removable = False + bootloader_config = BootloaderMenu(preset).run() - return bootloader + return bootloader_config def _select_profile(self, current_profile: ProfileConfiguration | None) -> ProfileConfiguration | None: from .profile.profile_menu import ProfileMenu diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py index 09b8347835..987a1f8d00 100644 --- a/archinstall/lib/interactions/__init__.py +++ b/archinstall/lib/interactions/__init__.py @@ -16,7 +16,7 @@ ) from .manage_users_conf import UserList, ask_for_additional_users from .network_menu import ManualNetworkConfig, ask_to_configure_network -from .system_conf import ask_for_bootloader, ask_for_swap, ask_for_uki, select_driver, select_kernel +from .system_conf import ask_for_swap, select_driver, select_kernel __all__ = [ 'ManualNetworkConfig', @@ -25,9 +25,7 @@ 'ask_additional_packages_to_install', 'ask_for_a_timezone', 'ask_for_additional_users', - 'ask_for_bootloader', 'ask_for_swap', - 'ask_for_uki', 'ask_hostname', 'ask_ntp', 'ask_to_configure_network', diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 6322ae866c..e37977c7fd 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -6,9 +6,7 @@ from archinstall.tui.result import ResultType from archinstall.tui.types import Alignment, FrameProperties, FrameStyle, Orientation, PreviewStyle -from ..args import arch_config_handler from ..hardware import GfxDriver, SysInfo -from ..models.bootloader import Bootloader def select_kernel(preset: list[str] = []) -> list[str]: @@ -46,110 +44,6 @@ def select_kernel(preset: list[str] = []) -> list[str]: return result.get_values() -def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: - # Systemd is UEFI only - options = [] - hidden_options = [] - default = None - header = None - - if arch_config_handler.args.skip_boot: - default = Bootloader.NO_BOOTLOADER - else: - hidden_options += [Bootloader.NO_BOOTLOADER] - - if not SysInfo.has_uefi(): - options += [Bootloader.Grub, Bootloader.Limine] - if not default: - default = Bootloader.Grub - header = tr('UEFI is not detected and some options are disabled') - else: - options += [b for b in Bootloader if b not in hidden_options] - if not default: - default = Bootloader.Systemd - - items = [MenuItem(o.value, value=o) for o in options] - group = MenuItemGroup(items) - group.set_default_by_value(default) - group.set_focus_by_value(preset) - - result = SelectMenu[Bootloader]( - group, - header=header, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Bootloader')), - allow_skip=True, - ).run() - - match result.type_: - case ResultType.Skip: - return preset - case ResultType.Selection: - return result.get_value() - case ResultType.Reset: - raise ValueError('Unhandled result type') - - -def ask_for_bootloader_removable(preset: bool = False) -> bool: - prompt = ( - tr('Would you like to install the bootloader to the default removable media search location?\n') - + '\n' - + tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:\n') - + '\n' - + tr(' • USB drives or other portable external media\n') - + tr(' • Systems where you want the disk to be bootable on any computer\n') - + tr(' • Firmware that does not properly support NVRAM boot entries\n') - + '\n' - + tr('This is NOT recommended if none of the above apply, as it makes installing multiple\n') - + tr('EFI bootloaders on the same disk more challenging, and it overwrites whatever bootloader\n') - + tr('was previously installed on the default removable media search location, if any.\n') - ) - - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = SelectMenu[bool]( - group, - header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - allow_skip=True, - ).run() - - match result.type_: - case ResultType.Skip: - return preset - case ResultType.Selection: - return result.item() == MenuItem.yes() - case ResultType.Reset: - raise ValueError('Unhandled result type') - - -def ask_for_uki(preset: bool = True) -> bool: - prompt = tr('Would you like to use unified kernel images?') + '\n' - - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = SelectMenu[bool]( - group, - header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - allow_skip=True, - ).run() - - match result.type_: - case ResultType.Skip: - return preset - case ResultType.Selection: - return result.item() == MenuItem.yes() - case ResultType.Reset: - raise ValueError('Unhandled result type') - - def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None) -> GfxDriver | None: """ Some what convoluted function, whose job is simple. diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py index 1eb3562cad..b70229f536 100644 --- a/archinstall/lib/models/bootloader.py +++ b/archinstall/lib/models/bootloader.py @@ -1,7 +1,9 @@ from __future__ import annotations import sys +from dataclasses import dataclass from enum import Enum +from typing import Any from ..hardware import SysInfo from ..output import warn @@ -48,3 +50,32 @@ def from_arg(cls, bootloader: str, skip_boot: bool) -> Bootloader: sys.exit(1) return Bootloader(bootloader) + + +@dataclass +class BootloaderConfiguration: + bootloader: Bootloader + uki: bool = False + removable: bool = False + + def json(self) -> dict[str, Any]: + return {'bootloader': self.bootloader.json(), 'uki': self.uki, 'removable': self.removable} + + @classmethod + def parse_arg(cls, config: dict[str, Any], skip_boot: bool) -> BootloaderConfiguration: + bootloader = Bootloader.from_arg(config.get('bootloader', ''), skip_boot) + uki = config.get('uki', False) + removable = config.get('removable', False) + return cls(bootloader=bootloader, uki=uki, removable=removable) + + @classmethod + def get_default(cls) -> BootloaderConfiguration: + return cls(bootloader=Bootloader.get_default(), uki=False, removable=False) + + def preview(self) -> str: + text = f'Bootloader: {self.bootloader.value}' + if self.uki and self.bootloader.has_uki_support(): + text += ' (UKI)' + if self.removable and self.bootloader in [Bootloader.Grub, Bootloader.Limine]: + text += ' (removable)' + return text diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 661bc9bed5..b22e1ea40a 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -62,7 +62,7 @@ def perform_installation(mountpoint: Path) -> None: return disk_config = config.disk_config - run_mkinitcpio = not config.uki + run_mkinitcpio = not (config.bootloader_config.uki if config.bootloader_config else False) locale_config = config.locale_config optional_repositories = config.mirror_config.optional_repositories if config.mirror_config else [] mountpoint = disk_config.mountpoint if disk_config.mountpoint else mountpoint @@ -99,11 +99,11 @@ def perform_installation(mountpoint: Path) -> None: if config.swap: installation.setup_swap('zram') - if config.bootloader and config.bootloader != Bootloader.NO_BOOTLOADER: - if config.bootloader == Bootloader.Grub and SysInfo.has_uefi(): + if config.bootloader_config and config.bootloader_config.bootloader != Bootloader.NO_BOOTLOADER: + if config.bootloader_config.bootloader == Bootloader.Grub and SysInfo.has_uefi(): installation.add_additional_packages('grub') - installation.add_bootloader(config.bootloader, config.uki, config.bootloader_removable) + installation.add_bootloader(config.bootloader_config.bootloader, config.bootloader_config.uki, config.bootloader_config.removable) # If user selected to copy the current ISO network configuration # Perform a copy of the config @@ -155,7 +155,8 @@ def perform_installation(mountpoint: Path) -> None: snapshot_config = btrfs_options.snapshot_config if btrfs_options else None snapshot_type = snapshot_config.snapshot_type if snapshot_config else None if snapshot_type: - installation.setup_btrfs_snapshot(snapshot_type, config.bootloader) + bootloader = config.bootloader_config.bootloader if config.bootloader_config else None + installation.setup_btrfs_snapshot(snapshot_type, bootloader) # If the user provided custom commands to be run post-installation, execute them now. if cc := config.custom_commands: diff --git a/examples/config-sample.json b/examples/config-sample.json index 40a349c998..30ba5b52d3 100644 --- a/examples/config-sample.json +++ b/examples/config-sample.json @@ -3,7 +3,11 @@ "audio_config": { "audio": "pipewire" }, - "bootloader": "Systemd-boot", + "bootloader_config": { + "bootloader": "Systemd-boot", + "uki": false, + "removable": false + }, "debug": false, "disk_config": { "config_type": "default_layout", diff --git a/tests/data/test_config.json b/tests/data/test_config.json index 9b0c218d32..a01c831b7f 100644 --- a/tests/data/test_config.json +++ b/tests/data/test_config.json @@ -18,7 +18,11 @@ "audio_config": { "audio": "pipewire" }, - "bootloader": "Systemd-boot", + "bootloader_config": { + "bootloader": "Systemd-boot", + "uki": false, + "removable": false + }, "services": [ "service_1", "service_2" diff --git a/tests/test_args.py b/tests/test_args.py index 28c7fc8e00..87ab1a3f47 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -9,7 +9,7 @@ from archinstall.lib.hardware import GfxDriver from archinstall.lib.models.application import ApplicationConfiguration, Audio, AudioConfiguration, BluetoothConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod -from archinstall.lib.models.bootloader import Bootloader +from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType from archinstall.lib.models.locale import LocaleConfiguration from archinstall.lib.models.mirrors import CustomRepository, CustomServer, MirrorConfiguration, MirrorRegion, SignCheck, SignOption @@ -214,8 +214,11 @@ def test_config_file_parsing( ), ], ), - bootloader=Bootloader.Systemd, - uki=False, + bootloader_config=BootloaderConfiguration( + bootloader=Bootloader.Systemd, + uki=False, + removable=False, + ), hostname='archy', kernels=['linux-zen'], ntp=True, From b0e457b02e42fcfd73c5b33daa465765b7f42377 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 20:47:58 +0100 Subject: [PATCH 03/16] Update ask_for_bootloader_removable() prompt for ease of translation --- archinstall/lib/bootloader/bootloader_menu.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 58693d4476..f01e9b0562 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -1,3 +1,4 @@ +import textwrap from typing import override from archinstall.lib.translationhandler import tr @@ -153,17 +154,26 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: def ask_for_bootloader_removable(preset: bool = False) -> bool: prompt = ( - tr('Would you like to install the bootloader to the default removable media search location?\n') - + '\n' - + tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:\n') - + '\n' - + tr(' • USB drives or other portable external media\n') - + tr(' • Systems where you want the disk to be bootable on any computer\n') - + tr(' • Firmware that does not properly support NVRAM boot entries\n') + tr('Would you like to install the bootloader to the default removable media search location?') + + '\n\n' + + tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:') + + '\n\n • ' + + tr('USB drives or other portable external media.') + + '\n • ' + + tr('Systems where you want the disk to be bootable on any computer.') + + '\n • ' + + tr('Firmware that does not properly support NVRAM boot entries.') + + '\n\n' + + tr( + textwrap.dedent( + """\ + This is NOT recommended if none of the above apply, as it makes installing multiple + EFI bootloaders on the same disk more challenging, and it overwrites whatever bootloader + was previously installed on the default removable media search location, if any. + """ + ) + ) + '\n' - + tr('This is NOT recommended if none of the above apply, as it makes installing multiple\n') - + tr('EFI bootloaders on the same disk more challenging, and it overwrites whatever bootloader\n') - + tr('was previously installed on the default removable media search location, if any.\n') ) group = MenuItemGroup.yes_no() From 385e213fbd2e5ca3db29caf0530924eded8eef76 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 21:34:34 +0100 Subject: [PATCH 04/16] Fix issue where removable and UKI options were always enabled at first --- archinstall/lib/bootloader/bootloader_menu.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index f01e9b0562..44fb9985a7 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -29,6 +29,19 @@ def __init__( ) def _define_menu_options(self) -> list[MenuItem]: + bootloader = self._bootloader_conf.bootloader + has_uefi = SysInfo.has_uefi() + + # UKI availability + uki_enabled = has_uefi and bootloader.has_uki_support() + if not uki_enabled: + self._bootloader_conf.uki = False + + # Removable availability + removable_enabled = has_uefi and bootloader in [Bootloader.Grub, Bootloader.Limine] + if not removable_enabled: + self._bootloader_conf.removable = False + return [ MenuItem( text=tr('Bootloader'), @@ -44,6 +57,7 @@ def _define_menu_options(self) -> list[MenuItem]: value=self._bootloader_conf.uki, preview_action=self._prev_uki, key='uki', + enabled=uki_enabled, ), MenuItem( text=tr('Install to removable location'), @@ -51,6 +65,7 @@ def _define_menu_options(self) -> list[MenuItem]: value=self._bootloader_conf.removable, preview_action=self._prev_removable, key='removable', + enabled=removable_enabled, ), ] From c67eef40062b66a1e0ecd8dc42e1055d7abc3021 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 21:51:05 +0100 Subject: [PATCH 05/16] Minor cosmetic fixes to bootloader removable code --- archinstall/lib/args.py | 5 +---- archinstall/lib/models/bootloader.py | 8 ++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 53d4e84eb8..e2ab864f89 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -177,15 +177,12 @@ def from_config(cls, args_config: dict[str, Any], args: Arguments) -> 'ArchConfi if net_config := args_config.get('network_config', None): arch_config.network_config = NetworkConfiguration.parse_arg(net_config) - # Parse bootloader configuration if bootloader_config_dict := args_config.get('bootloader_config', None): - # New format: bootloader_config with bootloader, uki, and removable arch_config.bootloader_config = BootloaderConfiguration.parse_arg(bootloader_config_dict, args.skip_boot) + # DEPRECATED: separate bootloader and uki fields (backward compatibility) elif bootloader_str := args_config.get('bootloader', None): - # Old format: separate bootloader and uki fields (backward compatibility) bootloader = Bootloader.from_arg(bootloader_str, args.skip_boot) uki = args_config.get('uki', False) - # Validate UKI compatibility if uki and not bootloader.has_uki_support(): uki = False arch_config.bootloader_config = BootloaderConfiguration(bootloader=bootloader, uki=uki, removable=False) diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py index b70229f536..99a7f38f42 100644 --- a/archinstall/lib/models/bootloader.py +++ b/archinstall/lib/models/bootloader.py @@ -74,8 +74,8 @@ def get_default(cls) -> BootloaderConfiguration: def preview(self) -> str: text = f'Bootloader: {self.bootloader.value}' - if self.uki and self.bootloader.has_uki_support(): - text += ' (UKI)' - if self.removable and self.bootloader in [Bootloader.Grub, Bootloader.Limine]: - text += ' (removable)' + if self.uki: + text += ', UKI' + if self.removable: + text += ', removable' return text From 99f93e295b07578966d0ff3deb128425385320e2 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 21:55:07 +0000 Subject: [PATCH 06/16] Add has_removable_support to Bootloader --- archinstall/lib/models/bootloader.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py index 99a7f38f42..6ce355d4ca 100644 --- a/archinstall/lib/models/bootloader.py +++ b/archinstall/lib/models/bootloader.py @@ -23,6 +23,13 @@ def has_uki_support(self) -> bool: case _: return False + def has_removable_support(self) -> bool: + match self: + case Bootloader.Grub | Bootloader.Limine: + return True + case _: + return False + def json(self) -> str: return self.value From 2324e963abb78cebf4328a27ab18cf367d2db241 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 21:55:07 +0000 Subject: [PATCH 07/16] Validate UKI and removable options in installer --- archinstall/lib/installer.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 84ca175842..d93c6873e5 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1643,6 +1643,20 @@ def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False, boot info(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}') + # validate UKI support + if uki_enabled and not bootloader.has_uki_support(): + warn(f'Bootloader {bootloader.value} does not support UKI; disabling.') + uki_enabled = False + + # validate removable bootloader option + if bootloader_removable: + if not SysInfo.has_uefi(): + warn('Removable install requested but system is not UEFI; disabling.') + bootloader_removable = False + elif not bootloader.has_removable_support(): + warn(f'Bootloader {bootloader.value} lacks removable support; disabling.') + bootloader_removable = False + if uki_enabled: self._config_uki(root, efi_partition) From 789e1722cc5d44cefbf75e598f8d1244dcd84178 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Sun, 16 Nov 2025 23:33:34 +0100 Subject: [PATCH 08/16] Use has_removable_support() where appropriate --- archinstall/lib/bootloader/bootloader_menu.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 44fb9985a7..343df3c818 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -38,7 +38,7 @@ def _define_menu_options(self) -> list[MenuItem]: self._bootloader_conf.uki = False # Removable availability - removable_enabled = has_uefi and bootloader in [Bootloader.Grub, Bootloader.Limine] + removable_enabled = has_uefi and bootloader.has_removable_support() if not removable_enabled: self._bootloader_conf.removable = False @@ -107,12 +107,12 @@ def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None: # Update removable option based on bootloader removable_item = self._menu_item_group.find_by_key('removable') - if bootloader in [Bootloader.Grub, Bootloader.Limine] and SysInfo.has_uefi(): - removable_item.enabled = True - else: + if not SysInfo.has_uefi() or not bootloader.has_removable_support(): removable_item.enabled = False removable_item.value = False self._bootloader_conf.removable = False + else: + removable_item.enabled = True return bootloader From 411e3ca8fbbaa963b59f48a130bb1680beb5a65f Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Mon, 17 Nov 2025 00:07:24 +0100 Subject: [PATCH 09/16] Fix potential AttributeError when bootloader_config is None --- archinstall/scripts/guided.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index b22e1ea40a..9fcb22334b 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -62,7 +62,7 @@ def perform_installation(mountpoint: Path) -> None: return disk_config = config.disk_config - run_mkinitcpio = not (config.bootloader_config.uki if config.bootloader_config else False) + run_mkinitcpio = not config.bootloader_config or not config.bootloader_config.uki locale_config = config.locale_config optional_repositories = config.mirror_config.optional_repositories if config.mirror_config else [] mountpoint = disk_config.mountpoint if disk_config.mountpoint else mountpoint From d84f1b0b590f993e2f684410aad0ab4533e91ef5 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Mon, 17 Nov 2025 00:11:04 +0100 Subject: [PATCH 10/16] Set default value for bootloader configuration menu item --- archinstall/lib/global_menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index b9e8de2d52..b0cce7fa98 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -87,9 +87,9 @@ def _get_menu_options(self) -> list[MenuItem]: ), MenuItem( text=tr('Bootloader'), + value=BootloaderConfiguration.get_default(), action=self._select_bootloader_config, preview_action=self._prev_bootloader_config, - mandatory=True, key='bootloader_config', ), MenuItem( From e2395648ba818fb460f470f3acad0cf330f3626e Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Mon, 17 Nov 2025 02:31:58 +0100 Subject: [PATCH 11/16] Update documentation after EFI removable/Limine changes --- archinstall/lib/installer.py | 2 +- docs/cli_parameters/config/config_options.csv | 2 +- docs/installing/guided.rst | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index d93c6873e5..62b762355a 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1616,7 +1616,7 @@ def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False, boot Archinstall supports one of three types: * systemd-bootctl * grub - * limine (beta) + * limine * efistub (beta) :param bootloader: Type of bootloader to be added diff --git a/docs/cli_parameters/config/config_options.csv b/docs/cli_parameters/config/config_options.csv index 3edfa205d1..a902d1d20e 100644 --- a/docs/cli_parameters/config/config_options.csv +++ b/docs/cli_parameters/config/config_options.csv @@ -2,7 +2,7 @@ Key,Value(s),Description,Required additional-repositories,[ `multilib `_!, `testing `_ ],Enables one or more of the testing and multilib repositories before proceeding with installation,No archinstall-language,`lang `__,Sets the TUI language used *(make sure to use the ``lang`` value not the ``abbr``)*,No audio_config,`pipewire `_!, `pulseaudio `_,Audioserver to be installed,No -bootloader,`Systemd-boot `_!, `grub `_,Bootloader to be installed *(grub being mandatory on BIOS machines)*,Yes +bootloader_config,"{ bootloader: `Systemd-boot `_!, `grub `_!, `limine `_!, uki: ``true``/``false``!, removable: ``true``/``false`` }","Bootloader configuration. ``bootloader`` selects which bootloader to install *(grub/limine mandatory on BIOS)*. ``uki`` enables unified kernel images *(UEFI only!, systemd-boot/limine only)*. ``removable`` installs to default removable media path /EFI/BOOT/ instead of NVRAM *(UEFI only!, grub/limine only)*",Yes debug,``true``!, ``false``,Enables debug output,No disk_config,*Read more under* :ref:`disk config`,Contains the desired disk setup to be used during installation,No disk_encryption,*Read more about under* :ref:`disk encryption`,Parameters for disk encryption applied on top of ``disk_config``,No diff --git a/docs/installing/guided.rst b/docs/installing/guided.rst index 24ee19a6c4..562d2b2980 100644 --- a/docs/installing/guided.rst +++ b/docs/installing/guided.rst @@ -66,6 +66,11 @@ The contents of :code:`https://domain.lan/config.json`: "additional-repositories": [], "archinstall-language": "English", "audio_config": null, + "bootloader_config": { + "bootloader": "Systemd-boot", + "uki": false, + "removable": false + }, "bootloader": "Systemd-boot", "debug": false, "disk_config": { From 7e0a384285f81825b678ca564e19ab2c696b5027 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Mon, 17 Nov 2025 02:46:11 +0100 Subject: [PATCH 12/16] Update limine.conf and non-removable location paths (as per Wiki) --- archinstall/lib/installer.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 62b762355a..90b5b995bc 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1387,9 +1387,15 @@ def _add_limine_bootloader( if bootloader_removable: efi_dir_path = efi_dir_path / 'BOOT' efi_dir_path_target = efi_dir_path_target / 'BOOT' + + boot_limine_path = self.target / 'boot' / 'limine' + boot_limine_path.mkdir(parents=True, exist_ok=True) + config_path = boot_limine_path / 'limine.conf' else: - efi_dir_path = efi_dir_path / 'limine' - efi_dir_path_target = efi_dir_path_target / 'limine' + efi_dir_path = efi_dir_path / 'arch-limine' + efi_dir_path_target = efi_dir_path_target / 'arch-limine' + + config_path = efi_dir_path / 'limine.conf' efi_dir_path.mkdir(parents=True, exist_ok=True) @@ -1398,8 +1404,6 @@ def _add_limine_bootloader( except Exception as err: raise DiskError(f'Failed to install Limine in {self.target}{efi_partition.mountpoint}: {err}') - config_path = efi_dir_path / 'limine.conf' - hook_command = ( f'/usr/bin/cp /usr/share/limine/BOOTIA32.EFI {efi_dir_path_target}/ && /usr/bin/cp /usr/share/limine/BOOTX64.EFI {efi_dir_path_target}/' ) @@ -1413,9 +1417,9 @@ def _add_limine_bootloader( raise OSError(f'Could not open or read /sys/firmware/efi/fw_platform_size to determine EFI bitness: {err}') if efi_bitness == '64': - loader_path = '/EFI/limine/BOOTX64.EFI' + loader_path = '\\EFI\\arch-limine\\BOOTX64.EFI' elif efi_bitness == '32': - loader_path = '/EFI/limine/BOOTIA32.EFI' + loader_path = '\\EFI\\arch-limine\\BOOTIA32.EFI' else: raise ValueError(f'EFI bitness is neither 32 nor 64 bits. Found "{efi_bitness}".') @@ -1426,7 +1430,7 @@ def _add_limine_bootloader( f' --disk {parent_dev_path}' f' --part {efi_partition.partn}' ' --label "Arch Linux Limine Bootloader"' - f' --loader {loader_path}' + f" --loader '{loader_path}'" ' --unicode' ' --verbose', ) From afea23d168df448b07e809e849bcd215c7c503c2 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Mon, 17 Nov 2025 03:54:47 +0100 Subject: [PATCH 13/16] Do not create fallback boot menu entries when using UKIs on Limine --- archinstall/lib/installer.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 90b5b995bc..4bc467ebf7 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1487,23 +1487,24 @@ def _add_limine_bootloader( path_root = f'uuid({boot_partition.partuuid})' for kernel in self.kernels: - for variant in ('', '-fallback'): - if uki_enabled: - entry = [ - 'protocol: efi', - f'path: boot():/EFI/Linux/arch-{kernel}.efi', - f'cmdline: {kernel_params}', - ] - else: + if uki_enabled: + entry = [ + 'protocol: efi', + f'path: boot():/EFI/Linux/arch-{kernel}.efi', + f'cmdline: {kernel_params}', + ] + config_contents += f'\n/Arch Linux ({kernel})\n' + config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' + else: + for variant in ('', '-fallback'): entry = [ 'protocol: linux', f'path: {path_root}:/vmlinuz-{kernel}', f'cmdline: {kernel_params}', f'module_path: {path_root}:/initramfs-{kernel}{variant}.img', ] - - config_contents += f'\n/Arch Linux ({kernel}{variant})\n' - config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' + config_contents += f'\n/Arch Linux ({kernel}{variant})\n' + config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' config_path.write_text(config_contents) From e3e26aa3c01b67c1ae0fbf151fcb6e2f5967b0e9 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Thu, 20 Nov 2025 13:00:11 +0100 Subject: [PATCH 14/16] Remove useless ask_* wrappers in bootloader_menu --- archinstall/lib/bootloader/bootloader_menu.py | 135 ++++++++---------- 1 file changed, 63 insertions(+), 72 deletions(-) diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 343df3c818..0526173f6a 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -117,14 +117,74 @@ def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None: return bootloader def _select_uki(self, preset: bool) -> bool: - return ask_for_uki(preset) + prompt = tr('Would you like to use unified kernel images?') + '\n' + + group = MenuItemGroup.yes_no() + group.set_focus_by_value(preset) + + result = SelectMenu[bool]( + group, + header=prompt, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER, + allow_skip=True, + ).run() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') def _select_removable(self, preset: bool) -> bool: - return ask_for_bootloader_removable(preset) + prompt = ( + tr('Would you like to install the bootloader to the default removable media search location?') + + '\n\n' + + tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:') + + '\n\n • ' + + tr('USB drives or other portable external media.') + + '\n • ' + + tr('Systems where you want the disk to be bootable on any computer.') + + '\n • ' + + tr('Firmware that does not properly support NVRAM boot entries.') + + '\n\n' + + tr( + textwrap.dedent( + """\ + This is NOT recommended if none of the above apply, as it makes installing multiple + EFI bootloaders on the same disk more challenging, and it overwrites whatever bootloader + was previously installed on the default removable media search location, if any. + """ + ) + ) + + '\n' + ) + + group = MenuItemGroup.yes_no() + group.set_focus_by_value(preset) + + result = SelectMenu[bool]( + group, + header=prompt, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER, + allow_skip=True, + ).run() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: - # Systemd is UEFI only options = [] hidden_options = [] default = None @@ -165,72 +225,3 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: return result.get_value() case ResultType.Reset: raise ValueError('Unhandled result type') - - -def ask_for_bootloader_removable(preset: bool = False) -> bool: - prompt = ( - tr('Would you like to install the bootloader to the default removable media search location?') - + '\n\n' - + tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:') - + '\n\n • ' - + tr('USB drives or other portable external media.') - + '\n • ' - + tr('Systems where you want the disk to be bootable on any computer.') - + '\n • ' - + tr('Firmware that does not properly support NVRAM boot entries.') - + '\n\n' - + tr( - textwrap.dedent( - """\ - This is NOT recommended if none of the above apply, as it makes installing multiple - EFI bootloaders on the same disk more challenging, and it overwrites whatever bootloader - was previously installed on the default removable media search location, if any. - """ - ) - ) - + '\n' - ) - - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = SelectMenu[bool]( - group, - header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - allow_skip=True, - ).run() - - match result.type_: - case ResultType.Skip: - return preset - case ResultType.Selection: - return result.item() == MenuItem.yes() - case ResultType.Reset: - raise ValueError('Unhandled result type') - - -def ask_for_uki(preset: bool = True) -> bool: - prompt = tr('Would you like to use unified kernel images?') + '\n' - - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = SelectMenu[bool]( - group, - header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - allow_skip=True, - ).run() - - match result.type_: - case ResultType.Skip: - return preset - case ResultType.Selection: - return result.item() == MenuItem.yes() - case ResultType.Reset: - raise ValueError('Unhandled result type') From 2acdaef66f25bd6be8c0315af46bca12da209ce9 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Thu, 20 Nov 2025 13:38:37 +0100 Subject: [PATCH 15/16] Improve bootloader menu previews --- archinstall/lib/bootloader/bootloader_menu.py | 6 +++-- archinstall/lib/models/bootloader.py | 23 +++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 0526173f6a..c127186214 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -75,9 +75,11 @@ def _prev_bootloader(self, item: MenuItem) -> str | None: return None def _prev_uki(self, item: MenuItem) -> str | None: + uki_text = f'{tr("Unified kernel images")}' if item.value: - return tr('Enabled') - return tr('Disabled') + return f'{uki_text}: {tr("Enabled")}' + else: + return f'{uki_text}: {tr("Disabled")}' def _prev_removable(self, item: MenuItem) -> str | None: if item.value: diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py index 6ce355d4ca..457d2b3b34 100644 --- a/archinstall/lib/models/bootloader.py +++ b/archinstall/lib/models/bootloader.py @@ -5,6 +5,8 @@ from enum import Enum from typing import Any +from archinstall.lib.translationhandler import tr + from ..hardware import SysInfo from ..output import warn @@ -80,9 +82,20 @@ def get_default(cls) -> BootloaderConfiguration: return cls(bootloader=Bootloader.get_default(), uki=False, removable=False) def preview(self) -> str: - text = f'Bootloader: {self.bootloader.value}' - if self.uki: - text += ', UKI' - if self.removable: - text += ', removable' + text = f'{tr("Bootloader")}: {self.bootloader.value}' + text += '\n' + if SysInfo.has_uefi() and self.bootloader.has_uki_support(): + if self.uki: + uki_string = tr('Enabled') + else: + uki_string = tr('Disabled') + text += f'UKI: {uki_string}' + text += '\n' + if SysInfo.has_uefi() and self.bootloader.has_removable_support(): + if self.removable: + removable_string = tr('Enabled') + else: + removable_string = tr('Disabled') + text += f'{tr("Removable")}: {removable_string}' + text += '\n' return text From d6dbf8e1a5c4e0f52481258a5f9ccb49ef23b020 Mon Sep 17 00:00:00 2001 From: Mintsuki Date: Fri, 21 Nov 2025 00:51:34 +0100 Subject: [PATCH 16/16] Make bootloader menu __init__.py empty --- archinstall/lib/bootloader/__init__.py | 3 --- archinstall/lib/global_menu.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/archinstall/lib/bootloader/__init__.py b/archinstall/lib/bootloader/__init__.py index 7bcbb802a6..e69de29bb2 100644 --- a/archinstall/lib/bootloader/__init__.py +++ b/archinstall/lib/bootloader/__init__.py @@ -1,3 +0,0 @@ -from .bootloader_menu import BootloaderMenu - -__all__ = ['BootloaderMenu'] diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index b0cce7fa98..fa2e51764b 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -12,7 +12,7 @@ from .applications.application_menu import ApplicationMenu from .args import ArchConfig from .authentication.authentication_menu import AuthenticationMenu -from .bootloader import BootloaderMenu +from .bootloader.bootloader_menu import BootloaderMenu from .configuration import save_config from .hardware import SysInfo from .interactions.general_conf import (