index : archinstall32 | |
Archlinux32 installer | gitolite user |
summaryrefslogtreecommitdiff |
-rw-r--r-- | archinstall/lib/user_interaction.py | 539 |
diff --git a/archinstall/lib/user_interaction.py b/archinstall/lib/user_interaction.py index df8668af..be01594e 100644 --- a/archinstall/lib/user_interaction.py +++ b/archinstall/lib/user_interaction.py @@ -1,11 +1,14 @@ -import getpass, pathlib, os, shutil, re -import sys, time, signal +import getpass, pathlib, os, shutil, re, time +import sys, time, signal, ipaddress, logging +import termios, tty, select # Used for char by char polling of sys.stdin from .exceptions import * from .profiles import Profile -from .locale_helpers import search_keyboard_layout -from .output import log, LOG_LEVELS +from .locale_helpers import list_keyboard_languages, verify_keyboard_layout, search_keyboard_layout +from .output import log from .storage import storage from .networking import list_interfaces +from .general import sys_command +from .hardware import AVAILABLE_GFX_DRIVERS, hasUEFI ## TODO: Some inconsistencies between the selection processes. ## Some return the keys from the options, some the values? @@ -24,7 +27,7 @@ def check_for_correct_username(username): return True log( "The username you entered is invalid. Try again", - level=LOG_LEVELS.Warning, + level=logging.WARNING, fg='red' ) return False @@ -93,14 +96,181 @@ def print_large_list(options, padding=5, margin_bottom=0, separator=': '): print(f"{str(column): >{highest_index_number_length}}{separator}{options[column]}", end = spaces) print() -def ask_for_superuser_account(prompt='Create a required super-user with sudo privileges: ', forced=False): + return column, row + + +def generic_multi_select(options, text="Select one or more of the options above (leave blank to continue): ", sort=True, default=None, allow_empty=False): + if sort: + options = sorted(options) + + section = MiniCurses(get_terminal_width(), len(options)) + + selected_options = [] + + while True: + if len(selected_options) <= 0 and default and default in options: + selected_options.append(default) + + printed_options = [] + for option in options: + if option in selected_options: + printed_options.append(f'>> {option}') + else: + printed_options.append(f'{option}') + + section.clear(0, get_terminal_height()-section._cursor_y-1) + x, y = print_large_list(printed_options, margin_bottom=2) + section._cursor_y = len(printed_options) + section._cursor_x = 0 + section.write_line(text) + section.input_pos = section._cursor_x + selected_option = section.get_keyboard_input(end=None) + + if selected_option is None: + if len(selected_options) <= 0 and default: + selected_options = [default] + + if len(selected_options) or allow_empty is True: + break + else: + log('* Need to select at least one option!', fg='red') + continue + + elif selected_option.isdigit(): + if (selected_option := int(selected_option)) >= len(options): + log('* Option is out of range, please select another one!', fg='red') + continue + selected_option = options[selected_option] + if selected_option in selected_options: + selected_options.remove(selected_option) + else: + selected_options.append(selected_option) + + return selected_options + + +class MiniCurses(): + def __init__(self, width, height): + self.width = width + self.height = height + + self._cursor_y = 0 + self._cursor_x = 0 + + self.input_pos = 0 + + def write_line(self, text, clear_line=True): + if clear_line: + sys.stdout.flush() + sys.stdout.write("\033[%dG" % 0) + sys.stdout.flush() + sys.stdout.write(" " * (get_terminal_width()-1)) + sys.stdout.flush() + sys.stdout.write("\033[%dG" % 0) + sys.stdout.flush() + sys.stdout.write(text) + sys.stdout.flush() + self._cursor_x += len(text) + + def clear(self, x, y): + if x < 0: x = 0 + if y < 0: y = 0 + + #import time + #sys.stdout.write(f"Clearing from: {x, y}") + #sys.stdout.flush() + #time.sleep(2) + + sys.stdout.flush() + sys.stdout.write('\033[%d;%df' % (y, x)) + for line in range(get_terminal_height()-y-1, y): + sys.stdout.write(" " * (get_terminal_width()-1)) + sys.stdout.flush() + sys.stdout.write('\033[%d;%df' % (y, x)) + sys.stdout.flush() + + def deal_with_control_characters(self, char): + mapper = { + '\x7f' : 'BACKSPACE', + '\r' : 'CR', + '\n' : 'NL' + } + + if (mapped_char := mapper.get(char, None)) == 'BACKSPACE': + if self._cursor_x <= self.input_pos: + # Don't backspace futher back than the cursor start position during input + return True + # Move back to the current known position (BACKSPACE doesn't updated x-pos) + sys.stdout.flush() + sys.stdout.write("\033[%dG" % (self._cursor_x)) + sys.stdout.flush() + + # Write a blank space + sys.stdout.flush() + sys.stdout.write(" ") + sys.stdout.flush() + + # And move back again + sys.stdout.flush() + sys.stdout.write("\033[%dG" % (self._cursor_x)) + sys.stdout.flush() + + self._cursor_x -= 1 + + return True + elif mapped_char in ('CR', 'NL'): + return True + + return None + + def get_keyboard_input(self, strip_rowbreaks=True, end='\n'): + assert end in ['\r', '\n', None] + + poller = select.epoll() + response = '' + + sys_fileno = sys.stdin.fileno() + old_settings = termios.tcgetattr(sys_fileno) + tty.setraw(sys_fileno) + + poller.register(sys.stdin.fileno(), select.EPOLLIN) + + EOF = False + while EOF is False: + for fileno, event in poller.poll(0.025): + char = sys.stdin.read(1) + + #sys.stdout.write(f"{[char]}") + #sys.stdout.flush() + + if (newline := (char in ('\n', '\r'))): + EOF = True + + if not newline or strip_rowbreaks is False: + response += char + + if self.deal_with_control_characters(char) is not True: + self.write_line(response[-1], clear_line=False) + + termios.tcsetattr(sys_fileno, termios.TCSADRAIN, old_settings) + + if end: + sys.stdout.write(end) + sys.stdout.flush() + self._cursor_x = 0 + self._cursor_y += 1 + + if response: + return response + +def ask_for_superuser_account(prompt='Username for required superuser with sudo privileges: ', forced=False): while 1: new_user = input(prompt).strip(' ') if not new_user and forced: # TODO: make this text more generic? # It's only used to create the first sudo user when root is disabled in guided.py - log(' * Since root is disabled, you need to create a least one (super) user!', fg='red') + log(' * Since root is disabled, you need to create a least one superuser!', fg='red') continue elif not new_user and not forced: raise UserError("No superuser was created.") @@ -112,7 +282,7 @@ def ask_for_superuser_account(prompt='Create a required super-user with sudo pri def ask_for_additional_users(prompt='Any additional users to install (leave blank for no users): '): users = {} - super_users = {} + superusers = {} while 1: new_user = input(prompt).strip(' ') @@ -122,26 +292,37 @@ def ask_for_additional_users(prompt='Any additional users to install (leave blan continue password = get_password(prompt=f'Password for user {new_user}: ') - if input("Should this user be a sudo (super) user (y/N): ").strip(' ').lower() in ('y', 'yes'): - super_users[new_user] = {"!password" : password} + if input("Should this user be a superuser (sudoer) [y/N]: ").strip(' ').lower() in ('y', 'yes'): + superusers[new_user] = {"!password" : password} else: users[new_user] = {"!password" : password} - return users, super_users + return users, superusers def ask_for_a_timezone(): - timezone = input('Enter a valid timezone (examples: Europe/Stockholm, US/Eastern) or press enter to use UTC: ').strip() - if timezone == '': - timezone = 'UTC' - if (pathlib.Path("/usr")/"share"/"zoneinfo"/timezone).exists(): - return timezone + while True: + timezone = input('Enter a valid timezone (examples: Europe/Stockholm, US/Eastern) or press enter to use UTC: ').strip().strip('*.') + if timezone == '': + timezone = 'UTC' + if (pathlib.Path("/usr")/"share"/"zoneinfo"/timezone).exists(): + return timezone + else: + log( + f"Specified timezone {timezone} does not exist.", + level=logging.WARNING, + fg='red' + ) + +def ask_for_bootloader() -> str: + bootloader = "systemd-bootctl" + if hasUEFI()==False: + bootloader="grub-install" else: - log( - f"Time zone {timezone} does not exist, continuing with system default.", - level=LOG_LEVELS.Warning, - fg='red' - ) - + bootloader_choice = input("Would you like to use GRUB as a bootloader instead of systemd-boot? [y/N] ").lower() + if bootloader_choice == "y": + bootloader="grub-install" + return bootloader + def ask_for_audio_selection(): audio = "pulseaudio" # Default for most desktop environments pipewire_choice = input("Would you like to install pipewire instead of pulseaudio as the default audio server? [Y/n] ").lower() @@ -154,27 +335,56 @@ def ask_to_configure_network(): # Optionally configure one network interface. #while 1: # {MAC: Ifname} - interfaces = {'ISO-CONFIG' : 'Copy ISO network configuration to installation','NetworkManager':'Use NetworkManager to control and manage your internet connection', **list_interfaces()} + interfaces = { + 'ISO-CONFIG' : 'Copy ISO network configuration to installation', + 'NetworkManager':'Use NetworkManager to control and manage your internet connection', + **list_interfaces() + } - nic = generic_select(interfaces.values(), "Select one network interface to configure (leave blank to skip): ") + nic = generic_select(interfaces, "Select one network interface to configure (leave blank to skip): ") if nic and nic != 'Copy ISO network configuration to installation': if nic == 'Use NetworkManager to control and manage your internet connection': return {'nic': nic,'NetworkManager':True} - mode = generic_select(['DHCP (auto detect)', 'IP (static)'], f"Select which mode to configure for {nic}: ") - if mode == 'IP (static)': + + # Current workaround: + # For selecting modes without entering text within brackets, + # printing out this part separate from options, passed in + # `generic_select` + modes = ['DHCP (auto detect)', 'IP (static)'] + for index, mode in enumerate(modes): + print(f"{index}: {mode}") + + mode = generic_select(['DHCP', 'IP'], f"Select which mode to configure for {nic} or leave blank for DHCP: ", + options_output=False) + if mode == 'IP': while 1: ip = input(f"Enter the IP and subnet for {nic} (example: 192.168.0.5/24): ").strip() - if ip: + # Implemented new check for correct IP/subnet input + try: + ipaddress.ip_interface(ip) break - else: + except ValueError: log( "You need to enter a valid IP in IP-config mode.", - level=LOG_LEVELS.Warning, + level=logging.WARNING, fg='red' ) - if not len(gateway := input('Enter your gateway (router) IP address or leave blank for none: ').strip()): - gateway = None + # Implemented new check for correct gateway IP address + while 1: + gateway = input('Enter your gateway (router) IP address or leave blank for none: ').strip() + try: + if len(gateway) == 0: + gateway = None + else: + ipaddress.ip_address(gateway) + break + except ValueError: + log( + "You need to enter a valid gateway (router) IP address.", + level=logging.WARNING, + fg='red' + ) dns = None if len(dns_input := input('Enter your DNS servers (space separated, blank for none): ').strip()): @@ -190,12 +400,13 @@ def ask_to_configure_network(): def ask_for_disk_layout(): options = { - 'keep-existing' : 'Keep existing partition layout and select which ones to use where.', - 'format-all' : 'Format entire drive and setup a basic partition scheme.', - 'abort' : 'Abort the installation.' + 'keep-existing' : 'Keep existing partition layout and select which ones to use where', + 'format-all' : 'Format entire drive and setup a basic partition scheme', + 'abort' : 'Abort the installation' } - value = generic_select(options.values(), "Found partitions on the selected drive, (select by number) what you want to do: ") + value = generic_select(options, "Found partitions on the selected drive, (select by number) what you want to do: ", + allow_empty_input=False, sort=True) return next((key for key, val in options.items() if val == value), None) def ask_for_main_filesystem_format(): @@ -206,40 +417,71 @@ def ask_for_main_filesystem_format(): 'f2fs' : 'f2fs' } - value = generic_select(options.values(), "Select which filesystem your main partition should use (by number or name): ") + value = generic_select(options, "Select which filesystem your main partition should use (by number or name): ", + allow_empty_input=False) return next((key for key, val in options.items() if val == value), None) -def generic_select(options, input_text="Select one of the above by index or absolute value: ", sort=True): +def generic_select(options, input_text="Select one of the above by index or absolute value: ", allow_empty_input=True, options_output=True, sort=False): """ A generic select function that does not output anything other than the options and their indexes. As an example: generic_select(["first", "second", "third option"]) - 1: first - 2: second - 3: third option + 0: first + 1: second + 2: third option + + When the user has entered the option correctly, + this function returns an item from list, a string, or None """ - if type(options) == dict: options = list(options) - if sort: options = sorted(list(options)) - if len(options) <= 0: raise RequirementError('generic_select() requires at least one option to operate.') + # Checking if options are different from `list` or `dict` + if type(options) not in [list, dict]: + log(f" * Generic select doesn't support ({type(options)}) as type of options * ", fg='red') + log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow') + raise RequirementError("generic_select() requires list or dictionary as options.") + # To allow only `list` and `dict`, converting values of options here. + # Therefore, now we can only provide the dictionary itself + if type(options) == dict: options = list(options.values()) + if sort: options = sorted(options) # As we pass only list and dict (converted to list), we can skip converting to list + if len(options) == 0: + log(f" * Generic select didn't find any options to choose from * ", fg='red') + log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow') + raise RequirementError('generic_select() requires at least one option to proceed.') + - for index, option in enumerate(options): - print(f"{index}: {option}") + # Added ability to disable the output of options items, + # if another function displays something different from this + if options_output: + for index, option in enumerate(options): + print(f"{index}: {option}") + + # The new changes introduce a single while loop for all inputs processed by this function + # Now the try...except block handles validation for invalid input from the user + while True: + try: + selected_option = input(input_text) + if len(selected_option.strip()) == 0: + # `allow_empty_input` parameter handles return of None on empty input, if necessary + # Otherwise raise `RequirementError` + if allow_empty_input: + return None + raise RequirementError('Please select an option to continue') + # Replaced `isdigit` with` isnumeric` to discard all negative numbers + elif selected_option.isnumeric(): + selected_option = int(selected_option) + if selected_option >= len(options): + raise RequirementError(f'Selected option "{selected_option}" is out of range') + selected_option = options[selected_option] + break + elif selected_option in options: + break # We gave a correct absolute value + else: + raise RequirementError(f'Selected option "{selected_option}" does not exist in available options') + except RequirementError as err: + log(f" * {err} * ", fg='red') + continue - selected_option = input(input_text) - if len(selected_option.strip()) <= 0: - return None - elif selected_option.isdigit(): - selected_option = int(selected_option) - if selected_option > len(options): - raise RequirementError(f'Selected option "{selected_option}" is out of range') - selected_option = options[selected_option] - elif selected_option in options: - pass # We gave a correct absolute value - else: - raise RequirementError(f'Selected option "{selected_option}" does not exist in available options: {options}') - return selected_option def select_disk(dict_o_disks): @@ -257,18 +499,14 @@ def select_disk(dict_o_disks): if len(drives) >= 1: for index, drive in enumerate(drives): print(f"{index}: {drive} ({dict_o_disks[drive]['size'], dict_o_disks[drive].device, dict_o_disks[drive]['label']})") - drive = input('Select one of the above disks (by number or full path) or write /mnt to skip partitioning: ') - if drive.strip() == '/mnt': - return None - elif drive.isdigit(): - drive = int(drive) - if drive >= len(drives): - raise DiskError(f'Selected option "{drive}" is out of range') - drive = dict_o_disks[drives[drive]] - elif drive in dict_o_disks: - drive = dict_o_disks[drive] - else: - raise DiskError(f'Selected drive does not exist: "{drive}"') + + log(f"You can skip selecting a drive and partitioning and use whatever drive-setup is mounted at /mnt (experimental)", fg="yellow") + drive = generic_select(drives, 'Select one of the above disks (by name or number) or leave blank to use /mnt: ', + options_output=False) + if not drive: + return drive + + drive = dict_o_disks[drive] return drive raise DiskError('select_disk() requires a non-empty dictionary of disks to select from.') @@ -293,29 +531,21 @@ def select_profile(options): print(' -- The above list is a set of pre-programmed profiles. --') print(' -- They might make it easier to install things like desktop environments. --') print(' -- (Leave blank and hit enter to skip this step and continue) --') - selected_profile = input('Enter a pre-programmed profile name if you want to install one: ') - - if len(selected_profile.strip()) <= 0: - return None - - if selected_profile.isdigit() and (pos := int(selected_profile)) <= len(profiles)-1: - selected_profile = profiles[pos] - elif selected_profile in options: - selected_profile = options[options.index(selected_profile)] - else: - RequirementError("Selected profile does not exist.") - return Profile(None, selected_profile) - - raise RequirementError("Selecting profiles require a least one profile to be given as an option.") + selected_profile = generic_select(profiles, 'Enter a pre-programmed profile name if you want to install one: ', + options_output=False) + if selected_profile: + return Profile(None, selected_profile) + else: + raise RequirementError("Selecting profiles require a least one profile to be given as an option.") def select_language(options, show_only_country_codes=True): """ Asks the user to select a language from the `options` dictionary parameter. Usually this is combined with :ref:`archinstall.list_keyboard_languages`. - :param options: A `dict` where keys are the language name, value should be a dict containing language information. - :type options: dict + :param options: A `generator` or `list` where keys are the language name, value should be a dict containing language information. + :type options: generator or list :param show_only_country_codes: Filters out languages that are not len(lang) == 2. This to limit the number of results from stuff like dvorak and x-latin1 alternatives. :type show_only_country_codes: bool @@ -334,35 +564,37 @@ def select_language(options, show_only_country_codes=True): for index, language in enumerate(languages): print(f"{index}: {language}") - print(' -- You can enter ? or help to search for more languages, or skip to use US layout --') - selected_language = input('Select one of the above keyboard languages (by number or full name): ') - - if len(selected_language.strip()) == 0: - return DEFAULT_KEYBOARD_LANGUAGE - elif selected_language.lower() in ('?', 'help'): - while True: - filter_string = input('Search for layout containing (example: "sv-"): ') - new_options = list(search_keyboard_layout(filter_string)) - - if len(new_options) <= 0: - log(f"Search string '{filter_string}' yielded no results, please try another search or Ctrl+D to abort.", fg='yellow') - continue + print(" -- You can choose a layout that isn't in this list, but whose name you know --") + print(" -- Also, you can enter '?' or 'help' to search for more languages, or skip to use US layout --") - return select_language(new_options, show_only_country_codes=False) + while True: + selected_language = input('Select one of the above keyboard languages (by name or full name): ') + if not selected_language: + return DEFAULT_KEYBOARD_LANGUAGE + elif selected_language.lower() in ('?', 'help'): + while True: + filter_string = input("Search for layout containing (example: \"sv-\") or enter 'exit' to exit from search: ") - elif selected_language.isdigit() and (pos := int(selected_language)) <= len(languages)-1: - selected_language = languages[pos] - return selected_language - # I'm leaving "options" on purpose here. - # Since languages possibly contains a filtered version of - # all possible language layouts, and we might want to write - # for instance sv-latin1 (if we know that exists) without having to - # go through the search step. - elif selected_language in options: - selected_language = options[options.index(selected_language)] - return selected_language - else: - raise RequirementError("Selected language does not exist.") + if filter_string.lower() == 'exit': + return select_language(list_keyboard_languages()) + + new_options = list(search_keyboard_layout(filter_string)) + + if len(new_options) <= 0: + log(f"Search string '{filter_string}' yielded no results, please try another search.", fg='yellow') + continue + + return select_language(new_options, show_only_country_codes=False) + elif selected_language.isnumeric(): + selected_language = int(selected_language) + if selected_language >= len(languages): + log(' * Selected option is out of range * ', fg='red') + continue + return languages[selected_language] + elif verify_keyboard_layout(selected_language): + return selected_language + else: + log(" * Given language wasn't found * ", fg='red') raise RequirementError("Selecting languages require a least one language to be given as an option.") @@ -389,23 +621,76 @@ def select_mirror_regions(mirrors, show_top_mirrors=True): print_large_list(regions, margin_bottom=4) print(' -- You can skip this step by leaving the option blank --') - selected_mirror = input('Select one of the above regions to download packages from (by number or full name): ') - if len(selected_mirror.strip()) == 0: + selected_mirror = generic_select(regions, 'Select one of the above regions to download packages from (by number or full name): ', + options_output=False) + if not selected_mirror: # Returning back empty options which can be both used to # do "if x:" logic as well as do `x.get('mirror', {}).get('sub', None)` chaining return {} - elif selected_mirror.isdigit() and int(selected_mirror) <= len(regions)-1: - # I'm leaving "mirrors" on purpose here. - # Since region possibly contains a known region of - # all possible regions, and we might want to write - # for instance Sweden (if we know that exists) without having to - # go through the search step. - region = regions[int(selected_mirror)] - selected_mirrors[region] = mirrors[region] - elif selected_mirror in mirrors: - selected_mirrors[selected_mirror] = mirrors[selected_mirror] - else: - raise RequirementError("Selected region does not exist.") + # I'm leaving "mirrors" on purpose here. + # Since region possibly contains a known region of + # all possible regions, and we might want to write + # for instance Sweden (if we know that exists) without having to + # go through the search step. + selected_mirrors[selected_mirror] = mirrors[selected_mirror] return selected_mirrors + + raise RequirementError("Selecting mirror region require a least one region to be given as an option.") + +def select_driver(options=AVAILABLE_GFX_DRIVERS): + """ + Some what convoluted function, which's 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) + """ + + drivers = sorted(list(options)) + + if drivers: + lspci = sys_command(f'/usr/bin/lspci') + for line in lspci.trace_log.split(b'\r\n'): + if b' vga ' in line.lower(): + if b'nvidia' in line.lower(): + print(' ** nvidia card detected, suggested driver: nvidia **') + elif b'amd' in line.lower(): + print(' ** AMD card detected, suggested driver: AMD / ATI **') + + initial_option = generic_select(drivers, input_text="Select your graphics card driver: ") + selected_driver = options[initial_option] + + if type(selected_driver) == dict: + driver_options = sorted(list(selected_driver)) + + driver_package_group = generic_select(driver_options, f'Which driver-type do you want for {initial_option}: ', + allow_empty_input=False) + driver_package_group = selected_driver[driver_package_group] + + return driver_package_group + + return selected_driver + + raise RequirementError("Selecting drivers require a least one profile to be given as an option.") + +def select_kernel(options): + """ + Asks the user to select a kernel for system. + + :param options: A `list` with kernel options + :type options: list + + :return: The string as a selected kernel + :rtype: string + """ + + DEFAULT_KERNEL = "linux" + + kernels = sorted(list(options)) + + if kernels: + return generic_multi_select(kernels, f"Choose which kernel to use (leave blank for default: {DEFAULT_KERNEL}): ", default=DEFAULT_KERNEL) + + raise RequirementError("Selecting kernels require a least one kernel to be given as an option.") |