From: Lennart Poettering Date: Wed, 5 Feb 2025 09:55:48 +0000 (+0100) Subject: firstboot: add auto-completion to various fields X-Git-Tag: v258-rc1~1322^2~3 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=94a2b1cd25c93870a7a4ac904f6c0f2e4f902038;p=thirdparty%2Fsystemd.git firstboot: add auto-completion to various fields This adds TAB-based auto-completion to various fields we query from the user, such as locale, keymap, timezone, group membership. It makes it a lot easier to quickly iterate through firstboot without typing too much. --- diff --git a/src/basic/terminal-util.c b/src/basic/terminal-util.c index 53437e690f1..f9e2a280246 100644 --- a/src/basic/terminal-util.c +++ b/src/basic/terminal-util.c @@ -26,6 +26,7 @@ #include "constants.h" #include "devnum-util.h" #include "env-util.h" +#include "errno-list.h" #include "fd-util.h" #include "fileio.h" #include "fs-util.h" @@ -231,31 +232,246 @@ int ask_char(char *ret, const char *replies, const char *fmt, ...) { } } -int ask_string(char **ret, const char *text, ...) { - _cleanup_free_ char *line = NULL; +typedef enum CompletionResult{ + COMPLETION_ALREADY, /* the input string is already complete */ + COMPLETION_FULL, /* completed the input string to be complete now */ + COMPLETION_PARTIAL, /* completed the input string so that is still incomplete */ + COMPLETION_NONE, /* found no matching completion */ + _COMPLETION_RESULT_MAX, + _COMPLETION_RESULT_INVALID = -EINVAL, + _COMPLETION_RESULT_ERRNO_MAX = -ERRNO_MAX, +} CompletionResult; + +static CompletionResult pick_completion(const char *string, char *const*completions, char **ret) { + _cleanup_free_ char *found = NULL; + bool partial = false; + + string = strempty(string); + + STRV_FOREACH(c, completions) { + + /* Ignore entries that are not actually completions */ + if (!startswith(*c, string)) + continue; + + /* Store first completion that matches */ + if (!found) { + found = strdup(*c); + if (!found) + return -ENOMEM; + + continue; + } + + /* If there's another completion that works truncate the one we already found by common + * prefix */ + size_t n = str_common_prefix(found, *c); + if (n == SIZE_MAX) + continue; + + found[n] = 0; + partial = true; + } + + *ret = TAKE_PTR(found); + + if (!*ret) + return COMPLETION_NONE; + if (partial) + return COMPLETION_PARTIAL; + + return streq(string, *ret) ? COMPLETION_ALREADY : COMPLETION_FULL; +} + +static void clear_by_backspace(size_t n) { + /* Erase the specified number of character cells backwards on the terminal */ + for (size_t i = 0; i < n; i++) + fputs("\b \b", stdout); +} + +int ask_string_full( + char **ret, + GetCompletionsCallback get_completions, + void *userdata, + const char *text, ...) { + va_list ap; int r; assert(ret); assert(text); + /* Output the prompt */ fputs(ansi_highlight(), stdout); - va_start(ap, text); vprintf(text, ap); va_end(ap); - fputs(ansi_normal(), stdout); - fflush(stdout); - r = read_line(stdin, LONG_LINE_MAX, &line); + _cleanup_free_ char *string = NULL; + size_t n = 0; + + /* Do interactive logic only if stdin + stdout are connected to the same place. And yes, we could use + * STDIN_FILENO and STDOUT_FILENO here, but let's be overly correct for once, after all libc allows + * swapping out stdin/stdout. */ + int fd_input = fileno(stdin); + int fd_output = fileno(stdout); + if (fd_input < 0 || fd_output < 0 || same_fd(fd_input, fd_output) <= 0) + goto fallback; + + /* Try to disable echo, which also tells us if this even is a terminal */ + struct termios old_termios; + if (tcgetattr(fd_input, &old_termios) < 0) + goto fallback; + + struct termios new_termios = old_termios; + termios_disable_echo(&new_termios); + if (tcsetattr(fd_input, TCSADRAIN, &new_termios) < 0) + return -errno; + + for (;;) { + int c = fgetc(stdin); + + /* On EOF or NUL, end the request, don't output anything anymore */ + if (IN_SET(c, EOF, 0)) + break; + + /* On Return also end the request, but make this visible */ + if (IN_SET(c, '\n', '\r')) { + fputc('\n', stdout); + break; + } + + if (c == '\t') { + /* Tab */ + + _cleanup_strv_free_ char **completions = NULL; + if (get_completions) { + r = get_completions(string, &completions, userdata); + if (r < 0) + goto fail; + } + + _cleanup_free_ char *new_string = NULL; + CompletionResult cr = pick_completion(string, completions, &new_string); + if (cr < 0) { + r = cr; + goto fail; + } + if (IN_SET(cr, COMPLETION_PARTIAL, COMPLETION_FULL)) { + /* Output the new suffix we learned */ + fputs(ASSERT_PTR(startswith(new_string, strempty(string))), stdout); + + /* And update the whole string */ + free_and_replace(string, new_string); + n = strlen(string); + } + if (cr == COMPLETION_NONE) + fputc('\a', stdout); /* BEL */ + + if (IN_SET(cr, COMPLETION_PARTIAL, COMPLETION_ALREADY)) { + /* If this worked only partially, or if the user hit TAB even though we were + * complete already, then show the remaining options (in the latter case just + * the one). */ + fputc('\n', stdout); + + _cleanup_strv_free_ char **filtered = strv_filter_prefix(completions, string); + if (!filtered) { + r = -ENOMEM; + goto fail; + } + + r = show_menu(filtered, + /* n_columns= */ SIZE_MAX, + /* column_width= */ SIZE_MAX, + /* ellipsize_percentage= */ 0, + /* grey_prefix=*/ string, + /* with_numbers= */ false); + if (r < 0) + goto fail; + + /* Show the prompt again */ + fputs(ansi_highlight(), stdout); + va_start(ap, text); + vprintf(text, ap); + va_end(ap); + fputs(ansi_normal(), stdout); + fputs(string, stdout); + } + + } else if (IN_SET(c, '\b', 127)) { + /* Backspace */ + + if (n == 0) + fputc('\a', stdout); /* BEL */ + else { + size_t m = utf8_last_length(string, n); + + char *e = string + n - m; + clear_by_backspace(utf8_console_width(e)); + + *e = 0; + n -= m; + } + + } else if (c == 21) { + /* Ctrl-u → erase all input */ + + clear_by_backspace(utf8_console_width(string)); + string[n = 0] = 0; + + } else if (c == 4) { + /* Ctrl-d → cancel this field input */ + + r = -ECANCELED; + goto fail; + + } else if (char_is_cc(c) || n >= LINE_MAX) + /* refuse control characters and too long strings */ + fputc('\a', stdout); /* BEL */ + else { + /* Regular char */ + + if (!GREEDY_REALLOC(string, n+2)) { + r = -ENOMEM; + goto fail; + } + + string[n++] = (char) c; + string[n] = 0; + + fputc(c, stdout); + } + + fflush(stdout); + } + + if (tcsetattr(fd_input, TCSADRAIN, &old_termios) < 0) + return -errno; + + if (!string) { + string = strdup(""); + if (!string) + return -ENOMEM; + } + + *ret = TAKE_PTR(string); + return 0; + +fail: + (void) tcsetattr(fd_input, TCSADRAIN, &old_termios); + return r; + +fallback: + /* A simple fallback without TTY magic */ + r = read_line(stdin, LONG_LINE_MAX, &string); if (r < 0) return r; if (r == 0) return -EIO; - *ret = TAKE_PTR(line); + *ret = TAKE_PTR(string); return 0; } diff --git a/src/basic/terminal-util.h b/src/basic/terminal-util.h index c4ee1b32434..698838e63f9 100644 --- a/src/basic/terminal-util.h +++ b/src/basic/terminal-util.h @@ -82,7 +82,11 @@ int chvt(int vt); int read_one_char(FILE *f, char *ret, usec_t timeout, bool echo, bool *need_nl); int ask_char(char *ret, const char *replies, const char *text, ...) _printf_(3, 4); -int ask_string(char **ret, const char *text, ...) _printf_(2, 3); + +typedef int (*GetCompletionsCallback)(const char *key, char ***ret_list, void *userdata); +int ask_string_full(char **ret, GetCompletionsCallback cb, void *userdata, const char *text, ...) _printf_(4, 5); +#define ask_string(ret, text, ...) ask_string_full(ret, NULL, NULL, text, ##__VA_ARGS__) + bool any_key_to_proceed(void); int show_menu(char **x, size_t n_columns, size_t column_width, unsigned ellipsize_percentage, const char *grey_prefix, bool with_numbers); diff --git a/src/firstboot/firstboot.c b/src/firstboot/firstboot.c index 2ee231e2d00..99e75b34224 100644 --- a/src/firstboot/firstboot.c +++ b/src/firstboot/firstboot.c @@ -135,6 +135,30 @@ static void print_welcome(int rfd) { done = true; } +static int get_completions( + const char *key, + char ***ret_list, + void *userdata) { + + int r; + + if (!userdata) { + *ret_list = NULL; + return 0; + } + + _cleanup_strv_free_ char **copy = strv_copy(userdata); + if (!copy) + return -ENOMEM; + + r = strv_extend(©, "list"); + if (r < 0) + return r; + + *ret_list = TAKE_PTR(copy); + return 0; +} + static int prompt_loop( int rfd, const char *text, @@ -142,6 +166,7 @@ static int prompt_loop( unsigned ellipsize_percentage, bool (*is_valid)(int rfd, const char *name), char **ret) { + int r; assert(text); @@ -151,9 +176,13 @@ static int prompt_loop( for (;;) { _cleanup_free_ char *p = NULL; - r = ask_string(&p, strv_isempty(l) ? "%s %s (empty to skip): " - : "%s %s (empty to skip, \"list\" to list options): ", - special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), text); + r = ask_string_full( + &p, + get_completions, + l, + strv_isempty(l) ? "%s %s (empty to skip): " + : "%s %s (empty to skip, \"list\" to list options): ", + special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), text); if (r < 0) return log_error_errno(r, "Failed to query user: %m"); diff --git a/src/home/homectl.c b/src/home/homectl.c index e1611b7bfb4..04457ebedae 100644 --- a/src/home/homectl.c +++ b/src/home/homectl.c @@ -2476,6 +2476,28 @@ static int acquire_group_list(char ***ret) { return !!*ret; } +static int group_completion_callback(const char *key, char ***ret_list, void *userdata) { + char ***available = userdata; + int r; + + if (!*available) { + r = acquire_group_list(available); + if (r < 0) + log_debug_errno(r, "Failed to enumerate available groups, ignoring: %m"); + } + + _cleanup_strv_free_ char **l = strv_copy(*available); + if (!l) + return -ENOMEM; + + r = strv_extend(&l, "list"); + if (r < 0) + return r; + + *ret_list = TAKE_PTR(l); + return 0; +} + static int create_interactively(void) { _cleanup_free_ char *username = NULL; int r; @@ -2531,7 +2553,8 @@ static int create_interactively(void) { for (;;) { _cleanup_free_ char *s = NULL; - r = ask_string(&s, + r = ask_string_full(&s, + group_completion_callback, &available, "%s Please enter an auxiliary group for user %s (empty to continue, \"list\" to list available groups): ", special_glyph(SPECIAL_GLYPH_TRIANGULAR_BULLET), username); if (r < 0)