From f216517bb01266ccc59a18deeccf9d6bf49e9db6 Mon Sep 17 00:00:00 2001 From: Michael Stapelberg Date: Wed, 28 Dec 2011 20:28:18 +0100 Subject: [PATCH] Implement a visual unlock indicator --- Makefile | 1 + debian/control | 2 +- i3lock.1 | 7 + i3lock.c | 444 +++++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 387 insertions(+), 67 deletions(-) diff --git a/Makefile b/Makefile index 1d82af9..37bd046 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ CFLAGS += $(shell pkg-config --cflags xcb-keysyms xcb-dpms) LIBS += $(shell pkg-config --libs xcb-keysyms xcb-dpms xcb-image) endif LIBS += -lpam +LIBS += -lev FILES:=$(wildcard *.c) FILES:=$(FILES:.c=.o) diff --git a/debian/control b/debian/control index 81b9201..7a59d3f 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Section: utils Priority: optional Maintainer: Michael Stapelberg DM-Upload-Allowed: yes -Build-Depends: debhelper (>= 7.0.50~), libx11-dev, libpam0g-dev, libcairo2-dev, libxcb1-dev, libxcb-dpms0-dev, libxcb-keysyms1-dev, libxcb-image0-dev, pkg-config +Build-Depends: debhelper (>= 7.0.50~), libx11-dev, libpam0g-dev, libcairo2-dev, libxcb1-dev, libxcb-dpms0-dev, libxcb-keysyms1-dev, libxcb-image0-dev, pkg-config, libev-dev Standards-Version: 3.9.2 Homepage: http://i3wm.org/i3lock/ diff --git a/i3lock.1 b/i3lock.1 index fb517f6..b4d18e2 100644 --- a/i3lock.1 +++ b/i3lock.1 @@ -69,6 +69,13 @@ Enable turning off your screen using DPMS. Note that, when you do not specify th option, DPMS will turn off your screen after 15 minutes of inactivity anyways (if you did not disable this in your X server). +.TP +.B \-u, \-\-no-unlock-indicator +Disables the unlock indicator. i3lock will by default show an unlock indicator +after pressing keys. This will give feedback for every keypress and it will +show you the current PAM state (whether your password is currently being +verified or whether it is wrong). + .TP .BI \-i\ path \fR,\ \fB\-\-image= path Display the given PNG image instead of a blank screen. diff --git a/i3lock.c b/i3lock.c index 2a43750..af93a1e 100644 --- a/i3lock.c +++ b/i3lock.c @@ -22,6 +22,9 @@ #include #include #include +#include +#include + #ifndef NOLIBCAIRO #include @@ -33,7 +36,16 @@ #include "xcb.h" #include "cursors.h" +#define BUTTON_RADIUS 90 +#define BUTTON_SPACE (BUTTON_RADIUS + 5) +#define BUTTON_CENTER (BUTTON_RADIUS + 5) +#define BUTTON_DIAMETER (5 * BUTTON_SPACE) + +static char color[7] = "ffffff"; +static uint32_t last_resolution[2]; static xcb_connection_t *conn; +static xcb_window_t win; +static xcb_visualtype_t *vistype; static xcb_cursor_t cursor; static xcb_key_symbols_t *symbols; static xcb_screen_t *scr; @@ -47,6 +59,26 @@ static int modeswitchmask; static int numlockmask; static bool beep = false; static bool debug_mode = false; +static bool dpms = false; +static bool unlock_indicator = true; +static struct ev_loop *main_loop; +static struct ev_timer *clear_pam_wrong_timeout; +static struct ev_timer *clear_indicator_timeout; + +static enum { + STATE_STARTED = 0, /* default state */ + STATE_KEY_PRESSED = 1, /* key was pressed, show unlock indicator */ + STATE_KEY_ACTIVE = 2, /* a key was pressed recently, highlight part + of the unlock indicator. */ + STATE_BACKSPACE_ACTIVE = 3 /* backspace was pressed recently, highlight + part of the unlock indicator in red. */ +} unlock_state; + +static enum { + STATE_PAM_IDLE = 0, /* no PAM interaction at the moment */ + STATE_PAM_VERIFY = 1, /* currently verifying the password via PAM */ + STATE_PAM_WRONG = 2 /* the password was wrong */ +} pam_state; #define DEBUG(fmt, ...) do { \ if (debug_mode) \ @@ -63,43 +95,234 @@ static bool tile = false; * resolution and returns it. * */ -xcb_pixmap_t draw_image(xcb_visualtype_t *vistype, u_int32_t* resolution, char* color) { +static xcb_pixmap_t draw_image(xcb_visualtype_t *vistype, u_int32_t* resolution) { xcb_pixmap_t bg_pixmap = XCB_NONE; #ifndef NOLIBCAIRO - if (!img) - return bg_pixmap; - bg_pixmap = create_bg_pixmap(conn, scr, resolution, color); /* Initialize cairo */ cairo_surface_t *output; output = cairo_xcb_surface_create(conn, bg_pixmap, vistype, resolution[0], resolution[1]); cairo_t *ctx = cairo_create(output); - if (!tile) { - cairo_set_source_surface(ctx, img, 0, 0); - cairo_paint(ctx); - } else { - /* create a pattern and fill a rectangle as big as the screen */ - cairo_pattern_t *pattern; - pattern = cairo_pattern_create_for_surface(img); - cairo_set_source(ctx, pattern); - cairo_pattern_set_extend(pattern, CAIRO_EXTEND_REPEAT); - cairo_rectangle(ctx, 0, 0, resolution[0], resolution[1]); - cairo_fill(ctx); - cairo_pattern_destroy(pattern); + if (img) { + if (!tile) { + cairo_set_source_surface(ctx, img, 0, 0); + cairo_paint(ctx); + } else { + /* create a pattern and fill a rectangle as big as the screen */ + cairo_pattern_t *pattern; + pattern = cairo_pattern_create_for_surface(img); + cairo_set_source(ctx, pattern); + cairo_pattern_set_extend(pattern, CAIRO_EXTEND_REPEAT); + cairo_rectangle(ctx, 0, 0, resolution[0], resolution[1]); + cairo_fill(ctx); + cairo_pattern_destroy(pattern); + } + } + + if (unlock_state >= STATE_KEY_PRESSED && unlock_indicator) { + cairo_pattern_t *outer_pat = NULL; + + outer_pat = cairo_pattern_create_linear(0, 0, 0, BUTTON_DIAMETER); + switch (pam_state) { + case STATE_PAM_VERIFY: + cairo_pattern_add_color_stop_rgb(outer_pat, 0, 139.0/255, 0, 250.0/255); + cairo_pattern_add_color_stop_rgb(outer_pat, 1, 51.0/255, 0, 250.0/255); + break; + case STATE_PAM_WRONG: + cairo_pattern_add_color_stop_rgb(outer_pat, 0, 255.0/250, 139.0/255, 0); + cairo_pattern_add_color_stop_rgb(outer_pat, 1, 125.0/255, 51.0/255, 0); + break; + case STATE_PAM_IDLE: + cairo_pattern_add_color_stop_rgb(outer_pat, 0, 139.0/255, 125.0/255, 0); + cairo_pattern_add_color_stop_rgb(outer_pat, 1, 51.0/255, 125.0/255, 0); + break; + } + + /* Draw a (centered) circle with transparent background. */ + cairo_set_line_width(ctx, 10.0); + cairo_arc(ctx, + (resolution[0] / 2) /* x */, + (resolution[1] / 2) /* y */, + BUTTON_RADIUS /* radius */, + 0 /* start */, + 2 * M_PI /* end */); + + /* Use the appropriate color for the different PAM states + * (currently verifying, wrong password, or default) */ + switch (pam_state) { + case STATE_PAM_VERIFY: + cairo_set_source_rgba(ctx, 0, 114.0/255, 255.0/255, 0.75); + break; + case STATE_PAM_WRONG: + cairo_set_source_rgba(ctx, 250.0/255, 0, 0, 0.75); + break; + default: + cairo_set_source_rgba(ctx, 0, 0, 0, 0.75); + break; + } + cairo_fill_preserve(ctx); + cairo_set_source(ctx, outer_pat); + cairo_stroke(ctx); + + /* Draw an inner seperator line. */ + cairo_set_source_rgb(ctx, 0, 0, 0); + cairo_set_line_width(ctx, 2.0); + cairo_arc(ctx, + (resolution[0] / 2) /* x */, + (resolution[1] / 2) /* y */, + BUTTON_RADIUS - 5 /* radius */, + 0, + 2 * M_PI); + cairo_stroke(ctx); + + cairo_set_line_width(ctx, 10.0); + + /* Display a (centered) text of the current PAM state. */ + char *text = NULL; + switch (pam_state) { + case STATE_PAM_VERIFY: + text = "verifying…"; + break; + case STATE_PAM_WRONG: + text = "wrong!"; + break; + default: + break; + } + + if (text) { + cairo_text_extents_t extents; + double x, y; + + cairo_set_source_rgb(ctx, 0, 0, 0); + cairo_set_font_size(ctx, 28.0); + + cairo_text_extents(ctx, text, &extents); + x = (resolution[0] / 2.0) - ((extents.width / 2) + extents.x_bearing); + y = (resolution[1] / 2.0) - ((extents.height / 2) + extents.y_bearing); + + cairo_move_to(ctx, x, y); + cairo_show_text(ctx, text); + cairo_close_path(ctx); + } + + /* After the user pressed any valid key or the backspace key, we + * highlight a random part of the unlock indicator to confirm this + * keypress. */ + if (unlock_state == STATE_KEY_ACTIVE || + unlock_state == STATE_BACKSPACE_ACTIVE) { + cairo_new_sub_path(ctx); + double highlight_start = (rand() % (int)(2 * M_PI * 100)) / 100.0; + DEBUG("Highlighting part %.2f\n", highlight_start); + cairo_arc(ctx, resolution[0] / 2 /* x */, resolution[1] / 2 /* y */, + BUTTON_RADIUS /* radius */, highlight_start, + highlight_start + (M_PI / 3.0)); + if (unlock_state == STATE_KEY_ACTIVE) { + /* For normal keys, we use a lighter green. */ + outer_pat = cairo_pattern_create_linear(0, 0, 0, BUTTON_DIAMETER); + cairo_pattern_add_color_stop_rgb(outer_pat, 0, 139.0/255, 219.0/255, 0); + cairo_pattern_add_color_stop_rgb(outer_pat, 1, 51.0/255, 219.0/255, 0); + } else { + /* For backspace, we use red. */ + outer_pat = cairo_pattern_create_linear(0, 0, 0, BUTTON_DIAMETER); + cairo_pattern_add_color_stop_rgb(outer_pat, 0, 219.0/255, 139.0/255, 0); + cairo_pattern_add_color_stop_rgb(outer_pat, 1, 219.0/255, 51.0/255, 0); + } + cairo_set_source(ctx, outer_pat); + cairo_stroke(ctx); + + /* Draw two little separators for the highlighted part of the + * unlock indicator. */ + cairo_set_source_rgb(ctx, 0, 0, 0); + cairo_arc(ctx, + (resolution[0] / 2) /* x */, + (resolution[1] / 2) /* y */, + BUTTON_RADIUS /* radius */, + highlight_start /* start */, + highlight_start + (M_PI / 128.0) /* end */); + cairo_stroke(ctx); + cairo_arc(ctx, + (resolution[0] / 2) /* x */, + (resolution[1] / 2) /* y */, + BUTTON_RADIUS /* radius */, + highlight_start + (M_PI / 3.0) /* start */, + (highlight_start + (M_PI / 3.0)) + (M_PI / 128.0) /* end */); + cairo_stroke(ctx); + } } + cairo_surface_destroy(output); cairo_destroy(ctx); #endif return bg_pixmap; } +/* + * Calls draw_image on a new pixmap and swaps that with the current pixmap + * + */ +static void redraw_screen() { + xcb_pixmap_t bg_pixmap = draw_image(vistype, last_resolution); + xcb_change_window_attributes(conn, win, XCB_CW_BACK_PIXMAP, (uint32_t[1]){ bg_pixmap }); + /* XXX: Possible optimization: Only update the area in the middle of the + * screen instead of the whole screen. */ + xcb_clear_area(conn, 0, win, 0, 0, scr->width_in_pixels, scr->height_in_pixels); + xcb_flush(conn); +} + +/* + * Resets pam_state to STATE_PAM_IDLE 2 seconds after an unsuccesful + * authentication event. + * + */ +static void clear_pam_wrong(EV_P_ ev_timer *w, int revents) { + pam_state = STATE_PAM_IDLE; + unlock_state = STATE_STARTED; + redraw_screen(); +} + +/* + * Hides the unlock indicator completely when there is no content in the + * password buffer. + * + */ +static void clear_indicator(EV_P_ ev_timer *w, int revents) { + DEBUG("Clear indicator\n"); + unlock_state = STATE_STARTED; + redraw_screen(); +} + +/* + * (Re-)starts the clear_indicator timeout. Called after pressing backspace or + * after an unsuccessful authentication attempt. + * + */ +static void start_clear_indicator_timeout() { + if (clear_indicator_timeout) { + ev_timer_stop(main_loop, clear_indicator_timeout); + ev_timer_set(clear_indicator_timeout, 1.0, 0.); + ev_timer_start(main_loop, clear_indicator_timeout); + } else { + clear_indicator_timeout = calloc(sizeof(struct ev_timer), 1); + ev_timer_init(clear_indicator_timeout, clear_indicator, 1.0, 0.); + ev_timer_start(main_loop, clear_indicator_timeout); + } +} + static void input_done() { if (input_position == 0) return; - /* TODO: change cursor during authentication? */ + if (clear_pam_wrong_timeout) { + ev_timer_stop(main_loop, clear_pam_wrong_timeout); + clear_pam_wrong_timeout = NULL; + } + + pam_state = STATE_PAM_VERIFY; + redraw_screen(); + if (pam_authenticate(pam_handle, 0) == PAM_SUCCESS) { printf("successfully authenticated\n"); exit(0); @@ -107,6 +330,16 @@ static void input_done() { fprintf(stderr, "Authentication failure\n"); + pam_state = STATE_PAM_WRONG; + redraw_screen(); + + /* Clear this state after 2 seconds (unless the user enters another + * password during that time). */ + ev_now_update(main_loop); + clear_pam_wrong_timeout = calloc(sizeof(struct ev_timer), 1); + ev_timer_init(clear_pam_wrong_timeout, clear_pam_wrong, 2.0, 0.); + ev_timer_start(main_loop, clear_pam_wrong_timeout); + /* beep on authentication failure, if enabled */ if (beep) { xcb_bell(conn, 100); @@ -138,6 +371,10 @@ static void handle_key_release(xcb_key_release_event_t *event) { modeswitch_active, iso_level3_shift_active); } +static void redraw_timeout(EV_P_ ev_timer *w, int revents) { + redraw_screen(); +} + /* * Handle key presses. Fixes state, then looks up the key symbol for the * given keycode, then looks up the key symbol (as UCS-2), converts it to @@ -211,6 +448,13 @@ static void handle_key_press(xcb_key_press_event_t *event) { /* decrement input_position to point to the previous glyph */ u8_dec(password, &input_position); password[input_position] = '\0'; + + /* Clear this state after 2 seconds (unless the user enters another + * password during that time). */ + start_clear_indicator_timeout(); + unlock_state = STATE_BACKSPACE_ACTIVE; + redraw_screen(); + unlock_state = STATE_KEY_PRESSED; //printf("new input position = %d, new password = %s\n", input_position, password); return; } @@ -260,6 +504,19 @@ static void handle_key_press(xcb_key_press_event_t *event) { input_position += convert_ucs_to_utf8((char*)inp, password + input_position); password[input_position] = '\0'; DEBUG("current password = %s\n", password); + + unlock_state = STATE_KEY_ACTIVE; + redraw_screen(); + unlock_state = STATE_KEY_PRESSED; + + struct ev_timer *timeout = calloc(sizeof(struct ev_timer), 1); + ev_timer_init(timeout, redraw_timeout, 0.25, 0.); + ev_timer_start(main_loop, timeout); + + if (clear_indicator_timeout) { + ev_timer_stop(main_loop, clear_indicator_timeout); + clear_indicator_timeout = NULL; + } } /* @@ -296,7 +553,7 @@ static void handle_mapping_notify(xcb_mapping_notify_event_t *event) { * and also redraw the image, if any. * */ -void handle_screen_resize(xcb_visualtype_t *vistype, xcb_window_t win, uint32_t* last_resolution, char* color) { +void handle_screen_resize(xcb_visualtype_t *vistype, xcb_window_t win, uint32_t* last_resolution) { xcb_get_geometry_cookie_t geomc; xcb_get_geometry_reply_t *geom; geomc = xcb_get_geometry(conn, scr->root); @@ -312,7 +569,7 @@ void handle_screen_resize(xcb_visualtype_t *vistype, xcb_window_t win, uint32_t* #ifndef NOLIBCAIRO if (img) { - xcb_pixmap_t bg_pixmap = draw_image(vistype, last_resolution, color); + xcb_pixmap_t bg_pixmap = draw_image(vistype, last_resolution); xcb_change_window_attributes(conn, win, XCB_CW_BACK_PIXMAP, (uint32_t[1]){ bg_pixmap }); } #endif @@ -354,10 +611,82 @@ static int conv_callback(int num_msg, const struct pam_message **msg, return 0; } +/* + * This callback is only a dummy, see xcb_prepare_cb and xcb_check_cb. + * See also man libev(3): "ev_prepare" and "ev_check" - customise your event loop + * + */ +static void xcb_got_event(EV_P_ struct ev_io *w, int revents) { + /* empty, because xcb_prepare_cb and xcb_check_cb are used */ +} + +/* + * Flush before blocking (and waiting for new events) + * + */ +static void xcb_prepare_cb(EV_P_ ev_prepare *w, int revents) { + xcb_flush(conn); +} + +/* + * Instead of polling the X connection socket we leave this to + * xcb_poll_for_event() which knows better than we can ever know. + * + */ +static void xcb_check_cb(EV_P_ ev_check *w, int revents) { + xcb_generic_event_t *event; + + while ((event = xcb_poll_for_event(conn)) != NULL) { + if (event->response_type == 0) { + xcb_generic_error_t *error = (xcb_generic_error_t*)event; + fprintf(stderr, "X11 Error received! sequence 0x%x, error_code = %d\n", + error->sequence, error->error_code); + free(event); + continue; + } + + /* Strip off the highest bit (set if the event is generated) */ + int type = (event->response_type & 0x7F); + + if (type == XCB_KEY_PRESS) { + handle_key_press((xcb_key_press_event_t*)event); + continue; + } + + if (type == XCB_KEY_RELEASE) { + handle_key_release((xcb_key_release_event_t*)event); + + /* If this was the backspace or escape key we are back at an + * empty input, so turn off the screen if DPMS is enabled */ + if (dpms && input_position == 0) + dpms_turn_off_screen(conn); + + continue; + } + + if (type == XCB_VISIBILITY_NOTIFY) { + handle_visibility_notify((xcb_visibility_notify_event_t*)event); + continue; + } + + if (type == XCB_MAPPING_NOTIFY) { + handle_mapping_notify((xcb_mapping_notify_event_t*)event); + continue; + } + + if (type == XCB_CONFIGURE_NOTIFY) { + handle_screen_resize(vistype, win, last_resolution); + continue; + } + + printf("WARNING: unhandled event of type %d\n", type); + + free(event); + } +} + int main(int argc, char *argv[]) { bool dont_fork = false; - bool dpms = false; - char color[7] = "ffffff"; char *username; #ifndef NOLIBCAIRO char *image_path = NULL; @@ -365,9 +694,6 @@ int main(int argc, char *argv[]) { int ret; struct pam_conv conv = {conv_callback, NULL}; int screen; - xcb_visualtype_t *vistype; - xcb_generic_event_t *event; - xcb_window_t win; int curs_choice = CURS_NONE; char o; int optind = 0; @@ -380,6 +706,7 @@ int main(int argc, char *argv[]) { {"pointer", required_argument, NULL , 'p'}, {"debug", no_argument, NULL, 0}, {"help", no_argument, NULL, 'h'}, + {"no-unlock-indicator", no_argument, NULL, 'u'}, #ifndef NOLIBCAIRO {"image", required_argument, NULL, 'i'}, {"tiling", no_argument, NULL, 't'}, @@ -390,7 +717,7 @@ int main(int argc, char *argv[]) { if ((username = getenv("USER")) == NULL) errx(1, "USER environment variable not set, please set it.\n"); - while ((o = getopt_long(argc, argv, "hvnbdc:p:" + while ((o = getopt_long(argc, argv, "hvnbdc:p:u" #ifndef NOLIBCAIRO "i:t" #endif @@ -419,6 +746,9 @@ int main(int argc, char *argv[]) { break; } + case 'u': + unlock_indicator = false; + break; #ifndef NOLIBCAIRO case 'i': image_path = strdup(optarg); @@ -441,7 +771,7 @@ int main(int argc, char *argv[]) { debug_mode = true; break; default: - errx(1, "Syntax: i3lock [-v] [-n] [-b] [-d] [-c color] [-p win|default]" + errx(1, "Syntax: i3lock [-v] [-n] [-b] [-d] [-c color] [-u] [-p win|default]" #ifndef NOLIBCAIRO " [-i image.png] [-t]" #else @@ -451,6 +781,10 @@ int main(int argc, char *argv[]) { } } + /* We need (relatively) random numbers for highlighting a random part of + * the unlock indicator upon keypresses. */ + srand(time(NULL)); + /* Initialize PAM */ ret = pam_start("i3lock", username, &conv, &pam_handle); if (ret != PAM_SUCCESS) @@ -480,8 +814,8 @@ int main(int argc, char *argv[]) { scr = xcb_setup_roots_iterator(xcb_get_setup(conn)).data; vistype = get_root_visual_type(scr); - uint32_t last_resolution[2] = {scr->width_in_pixels, scr->height_in_pixels}; - + last_resolution[0] = scr->width_in_pixels; + last_resolution[1] = scr->height_in_pixels; #ifndef NOLIBCAIRO @@ -492,7 +826,7 @@ int main(int argc, char *argv[]) { #endif /* Pixmap on which the image is rendered to (if any) */ - xcb_pixmap_t bg_pixmap = draw_image(vistype, last_resolution, color); + xcb_pixmap_t bg_pixmap = draw_image(vistype, last_resolution); /* open the fullscreen window, already with the correct pixmap in place */ win = open_fullscreen_window(conn, scr, color, bg_pixmap); @@ -508,46 +842,24 @@ int main(int argc, char *argv[]) { if (dpms) dpms_turn_off_screen(conn); - while ((event = xcb_wait_for_event(conn))) { - if (event->response_type == 0) - errx(1, "XCB: Invalid event received"); - - /* Strip off the highest bit (set if the event is generated) */ - int type = (event->response_type & 0x7F); - - if (type == XCB_KEY_PRESS) { - handle_key_press((xcb_key_press_event_t*)event); - continue; - } - - if (type == XCB_KEY_RELEASE) { - handle_key_release((xcb_key_release_event_t*)event); - - /* If this was the backspace or escape key we are back at an - * empty input, so turn off the screen if DPMS is enabled */ - if (dpms && input_position == 0) - dpms_turn_off_screen(conn); - - continue; - } + /* Initialize the libev event loop. */ + main_loop = EV_DEFAULT; + if (main_loop == NULL) + errx(EXIT_FAILURE, "Could not initialize libev. Bad LIBEV_FLAGS?\n"); - if (type == XCB_VISIBILITY_NOTIFY) { - handle_visibility_notify((xcb_visibility_notify_event_t*)event); - continue; - } + struct ev_io *xcb_watcher = calloc(sizeof(struct ev_io), 1); + struct ev_check *xcb_check = calloc(sizeof(struct ev_check), 1); + struct ev_prepare *xcb_prepare = calloc(sizeof(struct ev_prepare), 1); - if (type == XCB_MAPPING_NOTIFY) { - handle_mapping_notify((xcb_mapping_notify_event_t*)event); - continue; - } + ev_io_init(xcb_watcher, xcb_got_event, xcb_get_file_descriptor(conn), EV_READ); + ev_io_start(main_loop, xcb_watcher); - if (type == XCB_CONFIGURE_NOTIFY) { - handle_screen_resize(vistype, win, last_resolution, color); - continue; - } + ev_check_init(xcb_check, xcb_check_cb); + ev_check_start(main_loop, xcb_check); - printf("WARNING: unhandled event of type %d\n", type); - } + ev_prepare_init(xcb_prepare, xcb_prepare_cb); + ev_prepare_start(main_loop, xcb_prepare); - return 0; + xcb_flush(conn); + ev_loop(main_loop, 0); } -- 2.39.5