Send patches - preferably formatted by git format-patch - to patches at archlinux32 dot org.
summaryrefslogtreecommitdiff
path: root/archinstall/lib/interactions
diff options
context:
space:
mode:
Diffstat (limited to 'archinstall/lib/interactions')
-rw-r--r--archinstall/lib/interactions/__init__.py19
-rw-r--r--archinstall/lib/interactions/disk_conf.py572
-rw-r--r--archinstall/lib/interactions/general_conf.py209
-rw-r--r--archinstall/lib/interactions/manage_users_conf.py94
-rw-r--r--archinstall/lib/interactions/network_menu.py159
-rw-r--r--archinstall/lib/interactions/system_conf.py138
-rw-r--r--archinstall/lib/interactions/utils.py39
7 files changed, 1230 insertions, 0 deletions
diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py
new file mode 100644
index 00000000..4b696a78
--- /dev/null
+++ b/archinstall/lib/interactions/__init__.py
@@ -0,0 +1,19 @@
+from .manage_users_conf import UserList, ask_for_additional_users
+from .network_menu import ManualNetworkConfig, ask_to_configure_network
+from .utils import get_password
+
+from .disk_conf import (
+ select_devices, select_disk_config, get_default_partition_layout,
+ select_main_filesystem_format, suggest_single_disk_layout,
+ suggest_multi_disk_layout
+)
+
+from .general_conf import (
+ ask_ntp, ask_hostname, ask_for_a_timezone, ask_for_audio_selection,
+ select_archinstall_language, ask_additional_packages_to_install,
+ add_number_of_parallel_downloads, select_additional_repositories
+)
+
+from .system_conf import (
+ select_kernel, ask_for_bootloader, ask_for_uki, select_driver, ask_for_swap
+)
diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py
new file mode 100644
index 00000000..4fce4fe5
--- /dev/null
+++ b/archinstall/lib/interactions/disk_conf.py
@@ -0,0 +1,572 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, TYPE_CHECKING
+from typing import Optional, List, Tuple
+
+from .. import disk
+from ..disk.device_model import BtrfsMountOption
+from ..hardware import SysInfo
+from ..menu import Menu
+from ..menu import TableMenu
+from ..menu.menu import MenuSelectionType
+from ..output import FormattedOutput, debug
+from ..utils.util import prompt_dir
+from ..storage import storage
+
+if TYPE_CHECKING:
+ _: Any
+
+
+def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]:
+ """
+ Asks the user to select one or multiple devices
+
+ :return: List of selected devices
+ :rtype: list
+ """
+
+ def _preview_device_selection(selection: disk._DeviceInfo) -> Optional[str]:
+ dev = disk.device_handler.get_device(selection.path)
+ if dev and dev.partition_infos:
+ return FormattedOutput.as_table(dev.partition_infos)
+ return None
+
+ if preset is None:
+ preset = []
+
+ title = str(_('Select one or more devices to use and configure'))
+ warning = str(_('If you reset the device selection this will also reset the current disk layout. Are you sure?'))
+
+ devices = disk.device_handler.devices
+ options = [d.device_info for d in devices]
+ preset_value = [p.device_info for p in preset]
+
+ choice = TableMenu(
+ title,
+ data=options,
+ multi=True,
+ preset=preset_value,
+ preview_command=_preview_device_selection,
+ preview_title=str(_('Existing Partitions')),
+ preview_size=0.2,
+ allow_reset=True,
+ allow_reset_warning_msg=warning
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Reset: return []
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection:
+ selected_device_info: List[disk._DeviceInfo] = choice.single_value
+ selected_devices = []
+
+ for device in devices:
+ if device.device_info in selected_device_info:
+ selected_devices.append(device)
+
+ return selected_devices
+
+
+def get_default_partition_layout(
+ devices: List[disk.BDevice],
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ advanced_option: bool = False
+) -> List[disk.DeviceModification]:
+ if len(devices) == 1:
+ device_modification = suggest_single_disk_layout(
+ devices[0],
+ filesystem_type=filesystem_type,
+ advanced_options=advanced_option
+ )
+ return [device_modification]
+ else:
+ return suggest_multi_disk_layout(
+ devices,
+ filesystem_type=filesystem_type,
+ advanced_options=advanced_option
+ )
+
+
+def _manual_partitioning(
+ preset: List[disk.DeviceModification],
+ devices: List[disk.BDevice]
+) -> List[disk.DeviceModification]:
+ modifications = []
+ for device in devices:
+ mod = next(filter(lambda x: x.device == device, preset), None)
+ if not mod:
+ mod = disk.DeviceModification(device, wipe=False)
+
+ if partitions := disk.manual_partitioning(device, preset=mod.partitions):
+ mod.partitions = partitions
+ modifications.append(mod)
+
+ return modifications
+
+
+def select_disk_config(
+ preset: Optional[disk.DiskLayoutConfiguration] = None,
+ advanced_option: bool = False
+) -> Optional[disk.DiskLayoutConfiguration]:
+ default_layout = disk.DiskLayoutType.Default.display_msg()
+ manual_mode = disk.DiskLayoutType.Manual.display_msg()
+ pre_mount_mode = disk.DiskLayoutType.Pre_mount.display_msg()
+
+ options = [default_layout, manual_mode, pre_mount_mode]
+ preset_value = preset.config_type.display_msg() if preset else None
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ choice = Menu(
+ _('Select a partitioning option'),
+ options,
+ allow_reset=True,
+ allow_reset_warning_msg=warning,
+ sort=False,
+ preview_size=0.2,
+ preset_values=preset_value
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Reset: return None
+ case MenuSelectionType.Selection:
+ if choice.single_value == pre_mount_mode:
+ output = 'You will use whatever drive-setup is mounted at the specified directory\n'
+ output += "WARNING: Archinstall won't check the suitability of this setup\n"
+
+ try:
+ path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output)
+ except (KeyboardInterrupt, EOFError):
+ return preset
+ mods = disk.device_handler.detect_pre_mounted_mods(path)
+
+ storage['MOUNT_POINT'] = Path(path)
+
+ return disk.DiskLayoutConfiguration(
+ config_type=disk.DiskLayoutType.Pre_mount,
+ device_modifications=mods,
+ mountpoint=path
+ )
+
+ preset_devices = [mod.device for mod in preset.device_modifications] if preset else []
+ devices = select_devices(preset_devices)
+
+ if not devices:
+ return None
+
+ if choice.value == default_layout:
+ modifications = get_default_partition_layout(devices, advanced_option=advanced_option)
+ if modifications:
+ return disk.DiskLayoutConfiguration(
+ config_type=disk.DiskLayoutType.Default,
+ device_modifications=modifications
+ )
+ elif choice.value == manual_mode:
+ preset_mods = preset.device_modifications if preset else []
+ modifications = _manual_partitioning(preset_mods, devices)
+
+ if modifications:
+ return disk.DiskLayoutConfiguration(
+ config_type=disk.DiskLayoutType.Manual,
+ device_modifications=modifications
+ )
+
+ return None
+
+
+def select_lvm_config(
+ disk_config: disk.DiskLayoutConfiguration,
+ preset: Optional[disk.LvmConfiguration] = None,
+) -> Optional[disk.LvmConfiguration]:
+ default_mode = disk.LvmLayoutType.Default.display_msg()
+
+ options = [default_mode]
+
+ preset_value = preset.config_type.display_msg() if preset else None
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ choice = Menu(
+ _('Select a LVM option'),
+ options,
+ allow_reset=True,
+ allow_reset_warning_msg=warning,
+ sort=False,
+ preview_size=0.2,
+ preset_values=preset_value
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Reset: return None
+ case MenuSelectionType.Selection:
+ if choice.single_value == default_mode:
+ return suggest_lvm_layout(disk_config)
+ return preset
+
+
+def _boot_partition(sector_size: disk.SectorSize, using_gpt: bool) -> disk.PartitionModification:
+ flags = [disk.PartitionFlag.Boot]
+ if using_gpt:
+ start = disk.Size(1, disk.Unit.MiB, sector_size)
+ size = disk.Size(1, disk.Unit.GiB, sector_size)
+ flags.append(disk.PartitionFlag.ESP)
+ else:
+ start = disk.Size(3, disk.Unit.MiB, sector_size)
+ size = disk.Size(203, disk.Unit.MiB, sector_size)
+
+ # boot partition
+ return disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=start,
+ length=size,
+ mountpoint=Path('/boot'),
+ fs_type=disk.FilesystemType.Fat32,
+ flags=flags
+ )
+
+
+def select_main_filesystem_format(advanced_options: bool = False) -> disk.FilesystemType:
+ options = {
+ 'btrfs': disk.FilesystemType.Btrfs,
+ 'ext4': disk.FilesystemType.Ext4,
+ 'xfs': disk.FilesystemType.Xfs,
+ 'f2fs': disk.FilesystemType.F2fs
+ }
+
+ if advanced_options:
+ options.update({'ntfs': disk.FilesystemType.Ntfs})
+
+ prompt = _('Select which filesystem your main partition should use')
+ choice = Menu(prompt, options, skip=False, sort=False).run()
+ return options[choice.single_value]
+
+
+def select_mount_options() -> List[str]:
+ prompt = str(_('Would you like to use compression or disable CoW?'))
+ options = [str(_('Use compression')), str(_('Disable Copy-on-Write'))]
+ choice = Menu(prompt, options, sort=False).run()
+
+ if choice.type_ == MenuSelectionType.Selection:
+ if choice.single_value == options[0]:
+ return [BtrfsMountOption.compress.value]
+ else:
+ return [BtrfsMountOption.nodatacow.value]
+
+ return []
+
+
+def process_root_partition_size(available_space: disk.Size, sector_size: disk.SectorSize) -> disk.Size:
+ # root partition size processing
+ total_device_size = available_space.convert(disk.Unit.GiB)
+ if total_device_size.value > 500:
+ # maximum size
+ return disk.Size(value=50, unit=disk.Unit.GiB, sector_size=sector_size)
+ elif total_device_size.value < 200:
+ # minimum size
+ return disk.Size(value=20, unit=disk.Unit.GiB, sector_size=sector_size)
+ else:
+ # 10% of total size
+ length = total_device_size.value // 10
+ return disk.Size(value=length, unit=disk.Unit.GiB, sector_size=sector_size)
+
+
+def suggest_single_disk_layout(
+ device: disk.BDevice,
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ advanced_options: bool = False,
+ separate_home: Optional[bool] = None
+) -> disk.DeviceModification:
+ if not filesystem_type:
+ filesystem_type = select_main_filesystem_format(advanced_options)
+
+ sector_size = device.device_info.sector_size
+ total_size = device.device_info.total_size
+ min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB, sector_size)
+ root_partition_size = process_root_partition_size(available_space=total_size, sector_size=sector_size)
+ using_subvolumes = False
+ using_home_partition = False
+ mount_options = []
+ device_size_gib = device.device_info.total_size
+
+ if filesystem_type == disk.FilesystemType.Btrfs:
+ prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?'))
+ choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
+ using_subvolumes = choice.value == Menu.yes()
+ mount_options = select_mount_options()
+
+ device_modification = disk.DeviceModification(device, wipe=True)
+
+ using_gpt = SysInfo.has_uefi()
+
+ # Used for reference: https://wiki.archlinux.org/title/partitioning
+ # 2 MiB is unallocated for GRUB on BIOS. Potentially unneeded for other bootloaders?
+
+ # TODO: On BIOS, /boot partition is only needed if the drive will
+ # be encrypted, otherwise it is not recommended. We should probably
+ # add a check for whether the drive will be encrypted or not.
+
+ # Increase the UEFI partition if UEFI is detected.
+ # Also re-align the start to 1MiB since we don't need the first sectors
+ # like we do in MBR layouts where the boot loader is installed traditionally.
+
+ boot_partition = _boot_partition(sector_size, using_gpt)
+ device_modification.add_partition(boot_partition)
+
+ if not using_subvolumes:
+ if device_size_gib >= min_size_to_allow_home_part:
+ if separate_home is None:
+ prompt = str(_('Would you like to create a separate partition for /home?'))
+ choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
+ using_home_partition = choice.value == Menu.yes()
+ elif separate_home is True:
+ using_home_partition = True
+ else:
+ using_home_partition = False
+
+ align_buffer = disk.Size(1, disk.Unit.MiB, sector_size)
+
+ # root partition
+ root_start = boot_partition.start + boot_partition.length
+
+ # Set a size for / (/root)
+ if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition:
+ root_length = total_size - root_start
+ else:
+ root_length = min(total_size, root_partition_size)
+
+ if using_gpt and not using_home_partition:
+ root_length -= align_buffer
+
+ root_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=root_start,
+ length=root_length,
+ mountpoint=Path('/') if not using_subvolumes else None,
+ fs_type=filesystem_type,
+ mount_options=mount_options
+ )
+
+ device_modification.add_partition(root_partition)
+
+ if using_subvolumes:
+ # https://btrfs.wiki.kernel.org/index.php/FAQ
+ # https://unix.stackexchange.com/questions/246976/btrfs-subvolume-uuid-clash
+ # https://github.com/classy-giraffe/easy-arch/blob/main/easy-arch.sh
+ subvolumes = [
+ disk.SubvolumeModification(Path('@'), Path('/')),
+ disk.SubvolumeModification(Path('@home'), Path('/home')),
+ disk.SubvolumeModification(Path('@log'), Path('/var/log')),
+ disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')),
+ disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots'))
+ ]
+ root_partition.btrfs_subvols = subvolumes
+ elif using_home_partition:
+ # If we don't want to use subvolumes,
+ # But we want to be able to reuse data between re-installs..
+ # A second partition for /home would be nice if we have the space for it
+ home_start = root_partition.start + root_partition.length
+ home_length = total_size - home_start
+
+ if using_gpt:
+ home_length -= align_buffer
+
+ home_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=home_start,
+ length=home_length,
+ mountpoint=Path('/home'),
+ fs_type=filesystem_type,
+ mount_options=mount_options
+ )
+ device_modification.add_partition(home_partition)
+
+ return device_modification
+
+
+def suggest_multi_disk_layout(
+ devices: List[disk.BDevice],
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ advanced_options: bool = False
+) -> List[disk.DeviceModification]:
+ if not devices:
+ return []
+
+ # Not really a rock solid foundation of information to stand on, but it's a start:
+ # https://www.reddit.com/r/btrfs/comments/m287gp/partition_strategy_for_two_physical_disks/
+ # https://www.reddit.com/r/btrfs/comments/9us4hr/what_is_your_btrfs_partitionsubvolumes_scheme/
+ min_home_partition_size = disk.Size(40, disk.Unit.GiB, disk.SectorSize.default())
+ # rough estimate taking in to account user desktops etc. TODO: Catch user packages to detect size?
+ desired_root_partition_size = disk.Size(20, disk.Unit.GiB, disk.SectorSize.default())
+ mount_options = []
+
+ if not filesystem_type:
+ filesystem_type = select_main_filesystem_format(advanced_options)
+
+ # find proper disk for /home
+ possible_devices = list(filter(lambda x: x.device_info.total_size >= min_home_partition_size, devices))
+ home_device = max(possible_devices, key=lambda d: d.device_info.total_size) if possible_devices else None
+
+ # find proper device for /root
+ devices_delta = {}
+ for device in devices:
+ if device is not home_device:
+ delta = device.device_info.total_size - desired_root_partition_size
+ devices_delta[device] = delta
+
+ sorted_delta: List[Tuple[disk.BDevice, Any]] = sorted(devices_delta.items(), key=lambda x: x[1])
+ root_device: Optional[disk.BDevice] = sorted_delta[0][0]
+
+ if home_device is None or root_device is None:
+ text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n')
+ text += _('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(disk.Unit.GiB))
+ text += _('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(disk.Unit.GiB))
+ Menu(str(text), [str(_('Continue'))], skip=False).run()
+ return []
+
+ if filesystem_type == disk.FilesystemType.Btrfs:
+ mount_options = select_mount_options()
+
+ device_paths = ', '.join([str(d.device_info.path) for d in devices])
+
+ debug(f'Suggesting multi-disk-layout for devices: {device_paths}')
+ debug(f'/root: {root_device.device_info.path}')
+ debug(f'/home: {home_device.device_info.path}')
+
+ root_device_modification = disk.DeviceModification(root_device, wipe=True)
+ home_device_modification = disk.DeviceModification(home_device, wipe=True)
+
+ root_device_sector_size = root_device_modification.device.device_info.sector_size
+ home_device_sector_size = home_device_modification.device.device_info.sector_size
+
+ root_align_buffer = disk.Size(1, disk.Unit.MiB, root_device_sector_size)
+ home_align_buffer = disk.Size(1, disk.Unit.MiB, home_device_sector_size)
+
+ using_gpt = SysInfo.has_uefi()
+
+ # add boot partition to the root device
+ boot_partition = _boot_partition(root_device_sector_size, using_gpt)
+ root_device_modification.add_partition(boot_partition)
+
+ root_start = boot_partition.start + boot_partition.length
+ root_length = root_device.device_info.total_size - root_start
+
+ if using_gpt:
+ root_length -= root_align_buffer
+
+ # add root partition to the root device
+ root_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=root_start,
+ length=root_length,
+ mountpoint=Path('/'),
+ mount_options=mount_options,
+ fs_type=filesystem_type
+ )
+ root_device_modification.add_partition(root_partition)
+
+ home_start = home_align_buffer
+ home_length = home_device.device_info.total_size - home_start
+
+ if using_gpt:
+ home_length -= home_align_buffer
+
+ # add home partition to home device
+ home_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=home_start,
+ length=home_length,
+ mountpoint=Path('/home'),
+ mount_options=mount_options,
+ fs_type=filesystem_type,
+ )
+ home_device_modification.add_partition(home_partition)
+
+ return [root_device_modification, home_device_modification]
+
+
+def suggest_lvm_layout(
+ disk_config: disk.DiskLayoutConfiguration,
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ vg_grp_name: str = 'ArchinstallVg',
+) -> disk.LvmConfiguration:
+ if disk_config.config_type != disk.DiskLayoutType.Default:
+ raise ValueError('LVM suggested volumes are only available for default partitioning')
+
+ using_subvolumes = False
+ btrfs_subvols = []
+ home_volume = True
+ mount_options = []
+
+ if not filesystem_type:
+ filesystem_type = select_main_filesystem_format()
+
+ if filesystem_type == disk.FilesystemType.Btrfs:
+ prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?'))
+ choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
+ using_subvolumes = choice.value == Menu.yes()
+
+ mount_options = select_mount_options()
+
+ if using_subvolumes:
+ btrfs_subvols = [
+ disk.SubvolumeModification(Path('@'), Path('/')),
+ disk.SubvolumeModification(Path('@home'), Path('/home')),
+ disk.SubvolumeModification(Path('@log'), Path('/var/log')),
+ disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')),
+ disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots')),
+ ]
+
+ home_volume = False
+
+ boot_part: Optional[disk.PartitionModification] = None
+ other_part: List[disk.PartitionModification] = []
+
+ for mod in disk_config.device_modifications:
+ for part in mod.partitions:
+ if part.is_boot():
+ boot_part = part
+ else:
+ other_part.append(part)
+
+ if not boot_part:
+ raise ValueError('Unable to find boot partition in partition modifications')
+
+ total_vol_available = sum(
+ [p.length for p in other_part],
+ disk.Size(0, disk.Unit.B, disk.SectorSize.default()),
+ )
+ root_vol_size = disk.Size(20, disk.Unit.GiB, disk.SectorSize.default())
+ home_vol_size = total_vol_available - root_vol_size
+
+ lvm_vol_group = disk.LvmVolumeGroup(vg_grp_name, pvs=other_part, )
+
+ root_vol = disk.LvmVolume(
+ status=disk.LvmVolumeStatus.Create,
+ name='root',
+ fs_type=filesystem_type,
+ length=root_vol_size,
+ mountpoint=Path('/'),
+ btrfs_subvols=btrfs_subvols,
+ mount_options=mount_options
+ )
+
+ lvm_vol_group.volumes.append(root_vol)
+
+ if home_volume:
+ home_vol = disk.LvmVolume(
+ status=disk.LvmVolumeStatus.Create,
+ name='home',
+ fs_type=filesystem_type,
+ length=home_vol_size,
+ mountpoint=Path('/home'),
+ )
+
+ lvm_vol_group.volumes.append(home_vol)
+
+ return disk.LvmConfiguration(disk.LvmLayoutType.Default, [lvm_vol_group])
diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py
new file mode 100644
index 00000000..a879552e
--- /dev/null
+++ b/archinstall/lib/interactions/general_conf.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import pathlib
+from typing import List, Any, Optional, TYPE_CHECKING
+
+from ..locale import list_timezones
+from ..menu import MenuSelectionType, Menu, TextInput
+from ..models.audio_configuration import Audio, AudioConfiguration
+from ..output import warn
+from ..packages.packages import validate_package_list
+from ..storage import storage
+from ..translationhandler import Language
+
+if TYPE_CHECKING:
+ _: Any
+
+
+def ask_ntp(preset: bool = True) -> bool:
+ prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n'))
+ prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki'))
+ if preset:
+ preset_val = Menu.yes()
+ else:
+ preset_val = Menu.no()
+ choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run()
+
+ return False if choice.value == Menu.no() else True
+
+
+def ask_hostname(preset: str = '') -> str:
+ hostname = TextInput(
+ str(_('Desired hostname for the installation: ')),
+ preset
+ ).run().strip()
+
+ if not hostname:
+ return preset
+
+ return hostname
+
+
+def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]:
+ timezones = list_timezones()
+ default = 'UTC'
+
+ choice = Menu(
+ _('Select a timezone'),
+ timezones,
+ preset_values=preset,
+ default_option=default
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return choice.single_value
+
+ return None
+
+
+def ask_for_audio_selection(
+ current: Optional[AudioConfiguration] = None
+) -> Optional[AudioConfiguration]:
+ choices = [
+ Audio.Pipewire.name,
+ Audio.Pulseaudio.name,
+ Audio.no_audio_text()
+ ]
+
+ preset = current.audio.name if current else None
+
+ choice = Menu(
+ _('Choose an audio server'),
+ choices,
+ preset_values=preset
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return current
+ case MenuSelectionType.Selection:
+ value = choice.single_value
+ if value == Audio.no_audio_text():
+ return None
+ else:
+ return AudioConfiguration(Audio[value])
+
+ return None
+
+
+def select_language(preset: Optional[str] = None) -> Optional[str]:
+ from ..locale.locale_menu import select_kb_layout
+
+ # We'll raise an exception in an upcoming version.
+ # from ..exceptions import Deprecated
+ # raise Deprecated("select_language() has been deprecated, use select_kb_layout() instead.")
+
+ # No need to translate this i feel, as it's a short lived message.
+ warn("select_language() is deprecated, use select_kb_layout() instead. select_language() will be removed in a future version")
+
+ return select_kb_layout(preset)
+
+
+def select_archinstall_language(languages: List[Language], preset: Language) -> Language:
+ # these are the displayed language names which can either be
+ # the english name of a language or, if present, the
+ # name of the language in its own language
+ options = {lang.display_name: lang for lang in languages}
+
+ title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n'
+ title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n'
+ title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n'
+
+ choice = Menu(
+ title,
+ list(options.keys()),
+ default_option=preset.display_name,
+ preview_size=0.5
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return options[choice.single_value]
+
+ raise ValueError('Language selection not handled')
+
+
+def ask_additional_packages_to_install(preset: List[str] = []) -> List[str]:
+ # Additional packages (with some light weight error handling for invalid package names)
+ print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.'))
+ print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.'))
+
+ def read_packages(p: List = []) -> list:
+ display = ' '.join(p)
+ input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip()
+ return input_packages.split() if input_packages else []
+
+ preset = preset if preset else []
+ packages = read_packages(preset)
+
+ if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']:
+ while True:
+ if len(packages):
+ # Verify packages that were given
+ print(_("Verifying that additional packages exist (this might take a few seconds)"))
+ valid, invalid = validate_package_list(packages)
+
+ if invalid:
+ warn(f"Some packages could not be found in the repository: {invalid}")
+ packages = read_packages(valid)
+ continue
+ break
+
+ return packages
+
+
+def add_number_of_parallel_downloads(input_number :Optional[int] = None) -> Optional[int]:
+ max_recommended = 5
+ print(_(f"This option enables the number of parallel downloads that can occur during package downloads"))
+ print(_("Enter the number of parallel downloads to be enabled.\n\nNote:\n"))
+ print(str(_(" - Maximum recommended value : {} ( Allows {} parallel downloads at a time )")).format(max_recommended, max_recommended))
+ print(_(" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )\n"))
+
+ while True:
+ try:
+ input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0)
+ if input_number <= 0:
+ input_number = 0
+ break
+ except:
+ print(str(_("Invalid input! Try again with a valid input [or 0 to disable]")).format(max_recommended))
+
+ pacman_conf_path = pathlib.Path("/etc/pacman.conf")
+ with pacman_conf_path.open() as f:
+ pacman_conf = f.read().split("\n")
+
+ with pacman_conf_path.open("w") as fwrite:
+ for line in pacman_conf:
+ if "ParallelDownloads" in line:
+ fwrite.write(f"ParallelDownloads = {input_number}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n")
+ else:
+ fwrite.write(f"{line}\n")
+
+ return input_number
+
+
+def select_additional_repositories(preset: List[str]) -> List[str]:
+ """
+ Allows the user to select additional repositories (multilib, and testing) if desired.
+
+ :return: The string as a selected repository
+ :rtype: string
+ """
+
+ repositories = ["multilib", "testing"]
+
+ choice = Menu(
+ _('Choose which optional additional repositories to enable'),
+ repositories,
+ sort=False,
+ multi=True,
+ preset_values=preset,
+ allow_reset=True
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Reset: return []
+ case MenuSelectionType.Selection: return choice.single_value
+
+ return [] \ No newline at end of file
diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py
new file mode 100644
index 00000000..886f85b6
--- /dev/null
+++ b/archinstall/lib/interactions/manage_users_conf.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import re
+from typing import Any, TYPE_CHECKING, List, Optional
+
+from .utils import get_password
+from ..menu import Menu, ListManager
+from ..models.users import User
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class UserList(ListManager):
+ """
+ subclass of ListManager for the managing of user accounts
+ """
+
+ def __init__(self, prompt: str, lusers: List[User]):
+ self._actions = [
+ str(_('Add a user')),
+ str(_('Change password')),
+ str(_('Promote/Demote user')),
+ str(_('Delete User'))
+ ]
+ super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:])
+
+ def selected_action_display(self, user: User) -> str:
+ return user.username
+
+ def handle_action(self, action: str, entry: Optional[User], data: List[User]) -> List[User]:
+ if action == self._actions[0]: # add
+ new_user = self._add_user()
+ if new_user is not None:
+ # in case a user with the same username as an existing user
+ # was created we'll replace the existing one
+ data = [d for d in data if d.username != new_user.username]
+ data += [new_user]
+ elif action == self._actions[1] and entry: # change password
+ prompt = str(_('Password for user "{}": ').format(entry.username))
+ new_password = get_password(prompt=prompt)
+ if new_password:
+ user = next(filter(lambda x: x == entry, data))
+ user.password = new_password
+ elif action == self._actions[2] and entry: # promote/demote
+ user = next(filter(lambda x: x == entry, data))
+ user.sudo = False if user.sudo else True
+ elif action == self._actions[3] and entry: # delete
+ data = [d for d in data if d != entry]
+
+ return data
+
+ def _check_for_correct_username(self, username: str) -> bool:
+ if re.match(r'^[a-z_][a-z0-9_-]*\$?$', username) and len(username) <= 32:
+ return True
+ return False
+
+ def _add_user(self) -> Optional[User]:
+ prompt = '\n\n' + str(_('Enter username (leave blank to skip): '))
+
+ while True:
+ try:
+ username = input(prompt).strip(' ')
+ except (KeyboardInterrupt, EOFError):
+ return None
+
+ if not username:
+ return None
+ if not self._check_for_correct_username(username):
+ error_prompt = str(_("The username you entered is invalid. Try again"))
+ print(error_prompt)
+ else:
+ break
+
+ password = get_password(prompt=str(_('Password for user "{}": ').format(username)))
+
+ if not password:
+ return None
+
+ choice = Menu(
+ str(_('Should "{}" be a superuser (sudo)?')).format(username), Menu.yes_no(),
+ skip=False,
+ default_option=Menu.yes(),
+ clear_screen=False,
+ show_search_hint=False
+ ).run()
+
+ sudo = True if choice.value == Menu.yes() else False
+ return User(username, password, sudo)
+
+
+def ask_for_additional_users(prompt: str = '', defined_users: List[User] = []) -> List[User]:
+ users = UserList(prompt, defined_users).run()
+ return users
diff --git a/archinstall/lib/interactions/network_menu.py b/archinstall/lib/interactions/network_menu.py
new file mode 100644
index 00000000..14fc5785
--- /dev/null
+++ b/archinstall/lib/interactions/network_menu.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+import ipaddress
+from typing import Any, Optional, TYPE_CHECKING, List, Dict
+
+from ..menu import MenuSelectionType, TextInput
+from ..models.network_configuration import NetworkConfiguration, NicType, Nic
+
+from ..networking import list_interfaces
+from ..output import FormattedOutput, warn
+from ..menu import ListManager, Menu
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class ManualNetworkConfig(ListManager):
+ """
+ subclass of ListManager for the managing of network configurations
+ """
+
+ def __init__(self, prompt: str, preset: List[Nic]):
+ self._actions = [
+ str(_('Add interface')),
+ str(_('Edit interface')),
+ str(_('Delete interface'))
+ ]
+ super().__init__(prompt, preset, [self._actions[0]], self._actions[1:])
+
+ def reformat(self, data: List[Nic]) -> Dict[str, Optional[Nic]]:
+ table = FormattedOutput.as_table(data)
+ rows = table.split('\n')
+
+ # these are the header rows of the table and do not map to any User obviously
+ # we're adding 2 spaces as prefix because the menu selector '> ' will be put before
+ # the selectable rows so the header has to be aligned
+ display_data: Dict[str, Optional[Nic]] = {f' {rows[0]}': None, f' {rows[1]}': None}
+
+ for row, iface in zip(rows[2:], data):
+ row = row.replace('|', '\\|')
+ display_data[row] = iface
+
+ return display_data
+
+ def selected_action_display(self, nic: Nic) -> str:
+ return nic.iface if nic.iface else ''
+
+ def handle_action(self, action: str, entry: Optional[Nic], data: List[Nic]):
+ if action == self._actions[0]: # add
+ iface = self._select_iface(data)
+ if iface:
+ nic = Nic(iface=iface)
+ nic = self._edit_iface(nic)
+ data += [nic]
+ elif entry:
+ if action == self._actions[1]: # edit interface
+ data = [d for d in data if d.iface != entry.iface]
+ data.append(self._edit_iface(entry))
+ elif action == self._actions[2]: # delete
+ data = [d for d in data if d != entry]
+
+ return data
+
+ def _select_iface(self, data: List[Nic]) -> Optional[str]:
+ all_ifaces = list_interfaces().values()
+ existing_ifaces = [d.iface for d in data]
+ available = set(all_ifaces) - set(existing_ifaces)
+ choice = Menu(str(_('Select interface to add')), list(available), skip=True).run()
+
+ if choice.type_ == MenuSelectionType.Skip:
+ return None
+
+ return choice.single_value
+
+ def _edit_iface(self, edit_nic: Nic) -> Nic:
+ iface_name = edit_nic.iface
+ modes = ['DHCP (auto detect)', 'IP (static)']
+ default_mode = 'DHCP (auto detect)'
+
+ prompt = _('Select which mode to configure for "{}" or skip to use default mode "{}"').format(iface_name, default_mode)
+ mode = Menu(prompt, modes, default_option=default_mode, skip=False).run()
+
+ if mode.value == 'IP (static)':
+ while 1:
+ prompt = _('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name)
+ ip = TextInput(prompt, edit_nic.ip).run().strip()
+ # Implemented new check for correct IP/subnet input
+ try:
+ ipaddress.ip_interface(ip)
+ break
+ except ValueError:
+ warn("You need to enter a valid IP in IP-config mode")
+
+ # Implemented new check for correct gateway IP address
+ gateway = None
+
+ while 1:
+ gateway = TextInput(
+ _('Enter your gateway (router) IP address or leave blank for none: '),
+ edit_nic.gateway
+ ).run().strip()
+ try:
+ if len(gateway) > 0:
+ ipaddress.ip_address(gateway)
+ break
+ except ValueError:
+ warn("You need to enter a valid gateway (router) IP address")
+
+ if edit_nic.dns:
+ display_dns = ' '.join(edit_nic.dns)
+ else:
+ display_dns = None
+ dns_input = TextInput(_('Enter your DNS servers (space separated, blank for none): '), display_dns).run().strip()
+
+ dns = []
+ if len(dns_input):
+ dns = dns_input.split(' ')
+
+ return Nic(iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False)
+ else:
+ # this will contain network iface names
+ return Nic(iface=iface_name)
+
+
+def ask_to_configure_network(preset: Optional[NetworkConfiguration]) -> Optional[NetworkConfiguration]:
+ """
+ Configure the network on the newly installed system
+ """
+ options = {n.display_msg(): n for n in NicType}
+ preset_val = preset.type.display_msg() if preset else None
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ choice = Menu(
+ _('Select one network interface to configure'),
+ list(options.keys()),
+ preset_values=preset_val,
+ sort=False,
+ allow_reset=True,
+ allow_reset_warning_msg=warning
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Reset: return None
+ case MenuSelectionType.Selection:
+ nic_type = options[choice.single_value]
+
+ match nic_type:
+ case NicType.ISO:
+ return NetworkConfiguration(NicType.ISO)
+ case NicType.NM:
+ return NetworkConfiguration(NicType.NM)
+ case NicType.MANUAL:
+ preset_nics = preset.nics if preset else []
+ nics = ManualNetworkConfig('Configure interfaces', preset_nics).run()
+ if nics:
+ return NetworkConfiguration(NicType.MANUAL, nics)
+
+ return preset
diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py
new file mode 100644
index 00000000..35ba5a8b
--- /dev/null
+++ b/archinstall/lib/interactions/system_conf.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from typing import List, Any, TYPE_CHECKING, Optional
+
+from ..hardware import SysInfo, GfxDriver
+from ..menu import MenuSelectionType, Menu
+from ..models.bootloader import Bootloader
+
+if TYPE_CHECKING:
+ _: Any
+
+
+def select_kernel(preset: List[str] = []) -> List[str]:
+ """
+ Asks the user to select a kernel for system.
+
+ :return: The string as a selected kernel
+ :rtype: string
+ """
+
+ kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"]
+ default_kernel = "linux"
+
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ choice = Menu(
+ _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel),
+ kernels,
+ sort=True,
+ multi=True,
+ preset_values=preset,
+ allow_reset_warning_msg=warning
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return choice.single_value
+
+ return []
+
+
+def ask_for_bootloader(preset: Bootloader) -> Bootloader:
+ # Systemd is UEFI only
+ if not SysInfo.has_uefi():
+ options = [Bootloader.Grub.value, Bootloader.Limine.value]
+ default = Bootloader.Grub.value
+ else:
+ options = Bootloader.values()
+ default = Bootloader.Systemd.value
+
+ preset_value = preset.value if preset else None
+
+ choice = Menu(
+ _('Choose a bootloader'),
+ options,
+ preset_values=preset_value,
+ sort=False,
+ default_option=default
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return Bootloader(choice.value)
+
+ return preset
+
+
+def ask_for_uki(preset: bool = True) -> bool:
+ if preset:
+ preset_val = Menu.yes()
+ else:
+ preset_val = Menu.no()
+
+ prompt = _('Would you like to use unified kernel images?')
+ choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), preset_values=preset_val).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True
+
+ return preset
+
+
+def select_driver(options: List[GfxDriver] = [], current_value: Optional[GfxDriver] = None) -> Optional[GfxDriver]:
+ """
+ Some what convoluted function, whose job is simple.
+ Select a graphics driver from a pre-defined set of popular options.
+
+ (The template xorg is for beginner users, not advanced, and should
+ there for appeal to the general public first and edge cases later)
+ """
+ if not options:
+ options = [driver for driver in GfxDriver]
+
+ drivers = sorted([o.value for o in options])
+
+ if drivers:
+ title = ''
+ if SysInfo.has_amd_graphics():
+ title += str(_('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')) + '\n'
+ if SysInfo.has_intel_graphics():
+ title += str(_('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n'))
+ if SysInfo.has_nvidia_graphics():
+ title += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n'))
+
+ preset = current_value.value if current_value else None
+
+ choice = Menu(
+ title,
+ drivers,
+ preset_values=preset,
+ default_option=GfxDriver.AllOpenSource.value,
+ preview_command=lambda x: GfxDriver(x).packages_text(),
+ preview_size=0.3
+ ).run()
+
+ if choice.type_ != MenuSelectionType.Selection:
+ return current_value
+
+ return GfxDriver(choice.single_value)
+
+ return current_value
+
+
+def ask_for_swap(preset: bool = True) -> bool:
+ if preset:
+ preset_val = Menu.yes()
+ else:
+ preset_val = Menu.no()
+
+ prompt = _('Would you like to use swap on zram?')
+ choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True
+
+ return preset
diff --git a/archinstall/lib/interactions/utils.py b/archinstall/lib/interactions/utils.py
new file mode 100644
index 00000000..fdbb4625
--- /dev/null
+++ b/archinstall/lib/interactions/utils.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import getpass
+from typing import Any, Optional, TYPE_CHECKING
+
+from ..models import PasswordStrength
+from ..output import log, error
+
+if TYPE_CHECKING:
+ _: Any
+
+# used for signal handler
+SIG_TRIGGER = None
+
+
+def get_password(prompt: str = '') -> Optional[str]:
+ if not prompt:
+ prompt = _("Enter a password: ")
+
+ while True:
+ try:
+ password = getpass.getpass(prompt)
+ except (KeyboardInterrupt, EOFError):
+ break
+
+ if len(password.strip()) <= 0:
+ break
+
+ strength = PasswordStrength.strength(password)
+ log(f'Password strength: {strength.value}', fg=strength.color())
+
+ passwd_verification = getpass.getpass(prompt=_('And one more time for verification: '))
+ if password != passwd_verification:
+ error(' * Passwords did not match * ')
+ continue
+
+ return password
+
+ return None