From 143a1053c24de60513d0d93c05be33594de2ca17 Mon Sep 17 00:00:00 2001 From: Andrew Hamilton Date: Thu, 3 Sep 2026 07:39:25 +1000 Subject: [PATCH] session lock: ext-session-lock-v1 Blank the screen and hold input when a lock client (swaylock) grabs ext_session_lock_manager_v1. While locked an opaque black rect covers the output with the lock client's per-output surface floating above it; no window may take keyboard focus and the compositor grab chords are swallowed so tabs can't be cycled behind the lock. kick manages a single output, so a single lock surface is tracked. If the lock client dies without unlocking the screen stays blanked (a deliberate security choice); only a fresh lock request may take over. On unlock the previously active tab is refocused. Includes a wltest --lock mode (binds the manager, locks, draws a buffer on the lock surface, and optionally --lock-unlock) and two smoke tests: advertised-globals and lock/surface-config acceptance. --- kick.c | 233 +++++++++++++++++++++++++++++++++++++++++++- meson.build | 9 ++ tests/test_smoke.py | 53 ++++++++++ tests/wltest.c | 144 +++++++++++++++++++++++++++ 4 files changed, 437 insertions(+), 2 deletions(-) diff --git a/kick.c b/kick.c index 9834024..1c0fa68 100644 --- a/kick.c +++ b/kick.c @@ -99,6 +99,7 @@ #include #include #include +#include #include #include #include @@ -393,6 +394,29 @@ struct kick_server { /* Layer surfaces, in reservation order (head = highest layer / lowest overlap priority); see kick_layer. */ struct wl_list layer_surfaces; + + /* ================= ext-session-lock-v1 ================= + When a lock client (swaylock) grabs the session, the screen is blanked + and input is held. lock_surface_* mirror the lock client's surface for + the single output; lock_surface_tree is a scene tree that floats above + everything (layer_tree and popup_tree) while locked. Background blanking + uses an opaque black rect beneath the lock surface. The scene grows no + persistent overhead here: lock_surface_tree and lock_backdrop are only + created while a lock request is in flight. */ + struct wlr_session_lock_manager_v1 *session_lock_manager; + struct wl_listener session_lock_new_lock; + struct wlr_session_lock_v1 *session_lock; + bool session_locked; + struct wlr_scene_tree *lock_surface_tree; + struct wlr_scene_rect *lock_backdrop; + struct wlr_scene_tree *lock_surface_ssd; + struct wlr_session_lock_surface_v1 *lock_surface; + struct wl_listener lock_surface_destroy; + struct wl_listener lock_surface_map; + struct wl_listener session_lock_new_surface; + struct wl_listener session_lock_unlock; + struct wl_listener session_lock_destroy; + /* The command launched via --panel (a layer-shell panel such as waybar); NULL when no panel was requested. */ char *panel_cmd; @@ -2619,7 +2643,10 @@ static void show_window(struct kick_window *w) { maps, window_enter runs again (its own tree is still disabled) to show and activate it. */ static void window_enter(struct kick_window *w) { - if (!w) return; + /* While the session is locked nothing may take keyboard focus from the + lock surface (which is focused directly, outside the tab model); the + lock restore path clears session_locked before refocusing. */ + if (!w || server.session_locked) return; struct kick_tab *tab = w->tab; /* A genuine no-op: already focused AND already drawn. The black -> mapped promotion below is the one same-window case with something @@ -2652,7 +2679,7 @@ static void window_enter(struct kick_window *w) { promote it black -> mapped (the one case with something still to do), so callers that want a true no-op must guard first. */ static void set_active_tab(struct kick_tab *tab) { - if (!tab) return; + if (!tab || server.session_locked) return; struct kick_tab *old = server.active_tab; if (old && old != tab) { struct kick_window *w; @@ -5862,6 +5889,192 @@ static void handle_ext_foreign_handle_destroy(struct wl_listener *listener, void w->ext_handle = NULL; } +/* ======================= ext-session-lock-v1 ======================= + The session is locked by an external lock client (e.g. swaylock) that + grabbed ext_session_lock_manager_v1. While locked the screen is blanked + with an opaque black rect and the lock client's per-output surface floats + above it; every grab chord is swallowed (see process_key_event) and no + window may take keyboard focus (window_enter / set_active_tab guard on + server.session_locked). kick manages a single output, so a single lock + surface is tracked. If the lock client dies without unlocking, the screen + stays blanked (server.session_locked remains true) and only a fresh lock + request may take over. */ + +/* Create the lock scene tree and blank the output with an opaque black rect + rooted at the output origin. Idempotent. */ +static void session_lock_blank(void) { + if (server.lock_surface_tree) + return; + server.lock_surface_tree = wlr_scene_tree_create(&server.scene->tree); + if (server.output) + server.lock_backdrop = wlr_scene_rect_create(server.lock_surface_tree, + server.output->width, server.output->height, + (float[4]){ 0, 0, 0, 1 }); + wlr_scene_node_raise_to_top(&server.lock_surface_tree->node); + kick_request_redraw(); +} + +/* Destroy the lock scene tree (removing the black rect and the lock surface + subtree with it). Idempotent. */ +static void session_lock_unblank(void) { + if (server.lock_surface_tree) + wlr_scene_node_destroy(&server.lock_surface_tree->node); + server.lock_surface_tree = NULL; + server.lock_backdrop = NULL; + server.lock_surface_ssd = NULL; + kick_request_redraw(); +} + +/* Put the lock surface's scene subtree in place, above the black backdrop, + and configure it to the output's current dimensions. */ +static void session_lock_surface_place(struct wlr_session_lock_surface_v1 *ls) { + if (!ls || !ls->surface) + return; + session_lock_blank(); + if (!server.lock_surface_ssd) { + server.lock_surface_ssd = wlr_scene_subsurface_tree_create( + server.lock_surface_tree, ls->surface); + wlr_scene_node_raise_to_top(&server.lock_surface_ssd->node); + } + if (server.output) + wlr_session_lock_surface_v1_configure(ls, server.output->width, + server.output->height); +} + +/* The lock surface rendered and the whole output is blanked: give it the + keyboard and pointer so the lock client (swaylock's prompt) receives input. + Delegated to an idle callback because at map time wlroots has only processed + the role commit and the surface/buffer isn't settable-focusable yet. */ +static void session_lock_focus_surface(void *data) { + (void)data; + struct wlr_session_lock_surface_v1 *ls = server.lock_surface; + if (!ls || !ls->surface) + return; + struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat); + if (!kbd) kbd = &server.keyboard; + wlr_seat_keyboard_notify_enter(server.seat, ls->surface, + kbd->keycodes, kbd->num_keycodes, &kbd->modifiers); + wlr_seat_pointer_notify_enter(server.seat, ls->surface, 0, 0); + wlr_seat_pointer_notify_motion(server.seat, 0, 0, 0); + kick_request_redraw(); +} + +static void handle_session_lock_surface_map(struct wl_listener *listener, void *data) { + (void)listener; (void)data; + wl_event_loop_add_idle(server.loop, session_lock_focus_surface, NULL); +} + +static void handle_session_lock_surface_destroy(struct wl_listener *listener, void *data) { + (void)listener; (void)data; + wlr_seat_keyboard_notify_clear_focus(server.seat); + wlr_seat_pointer_notify_clear_focus(server.seat); + wl_list_remove(&server.lock_surface_destroy.link); + wl_list_remove(&server.lock_surface_map.link); + server.lock_surface = NULL; + server.lock_surface_ssd = NULL; + kick_request_redraw(); +} + +static void handle_session_lock_new_surface(struct wl_listener *listener, void *data) { + (void)listener; + struct wlr_session_lock_surface_v1 *ls = data; + server.lock_surface = ls; + server.lock_surface_destroy.notify = handle_session_lock_surface_destroy; + wl_signal_add(&ls->events.destroy, &server.lock_surface_destroy); + server.lock_surface_map.notify = handle_session_lock_surface_map; + wl_signal_add(&ls->surface->events.map, &server.lock_surface_map); + session_lock_surface_place(ls); +} + +/* Hand the lock to the next lock in line: replay the focus onto the tab model + now that session_locked is cleared, and show the previously active window. */ +static void session_lock_restore_focus(void) { + server.session_locked = false; + struct kick_tab *tab = server.active_tab; + if (tab) { + struct kick_window *enter = tab_current_window(tab); + if (enter) + window_enter(enter); + else + show_window(NULL); + } + kick_request_redraw(); +} + +static void handle_session_lock_unlock(struct wl_listener *listener, void *data) { + (void)listener; (void)data; + struct wlr_session_lock_v1 *lock = server.session_lock; + if (!lock) return; + if (server.lock_surface) { + wl_list_remove(&server.lock_surface_destroy.link); + wl_list_remove(&server.lock_surface_map.link); + server.lock_surface = NULL; + server.lock_surface_ssd = NULL; + } + session_lock_unblank(); + session_lock_restore_focus(); +} + +static void handle_session_lock_destroy(struct wl_listener *listener, void *data) { + (void)listener; (void)data; + /* The lock client died without unlocking: the screen stays locked and + blanked (session_locked remains true) as a deliberate security choice. + Only a fresh lock request may take over. */ + if (server.session_lock) { + wl_list_remove(&server.session_lock_unlock.link); + wl_list_remove(&server.session_lock_new_surface.link); + wl_list_remove(&server.session_lock_destroy.link); + } + server.session_lock = NULL; + if (server.lock_surface) { + wl_list_remove(&server.lock_surface_destroy.link); + wl_list_remove(&server.lock_surface_map.link); + server.lock_surface = NULL; + server.lock_surface_ssd = NULL; + } + kick_request_redraw(); +} + +static void handle_session_lock_new_lock(struct wl_listener *listener, void *data) { + (void)listener; + struct wlr_session_lock_v1 *lock = data; + /* A second lock while already locked is refused outright. If an earlier + lock client died (session_locked true, session_lock NULL), replace the + blanked state: tear down the old lock surfaces and proceed. */ + if (server.session_locked && server.session_lock) { + wlr_session_lock_v1_destroy(lock); + return; + } + if (server.session_locked) { + session_lock_unblank(); + if (server.lock_surface) { + wl_list_remove(&server.lock_surface_destroy.link); + wl_list_remove(&server.lock_surface_map.link); + server.lock_surface = NULL; + server.lock_surface_ssd = NULL; + } + } + + server.session_lock = lock; + server.session_locked = true; + server.session_lock_new_surface.notify = handle_session_lock_new_surface; + wl_signal_add(&lock->events.new_surface, &server.session_lock_new_surface); + server.session_lock_unlock.notify = handle_session_lock_unlock; + wl_signal_add(&lock->events.unlock, &server.session_lock_unlock); + server.session_lock_destroy.notify = handle_session_lock_destroy; + wl_signal_add(&lock->events.destroy, &server.session_lock_destroy); + + /* Draw nothing but the black blank while locked; no window may grab + focus (window_enter / set_active_tab are gated on session_locked). */ + show_window(NULL); + struct wlr_keyboard *kbd = wlr_seat_get_keyboard(server.seat); + if (kbd) wlr_seat_keyboard_notify_clear_focus(server.seat); + wlr_seat_pointer_notify_clear_focus(server.seat); + session_lock_blank(); + wlr_session_lock_v1_send_locked(lock); +} + + /* Create the taskbar-visible handles for a freshly-claimed window and seed their initial state. Called once a window has the client's app_id/title (i.e. at claim time). */ @@ -7056,6 +7269,13 @@ static bool process_key_event(uint32_t time, uint32_t key, uint32_t state, } return true; } + /* Session locked: swallow every compositor grab chord so the user can't + cycle tabs, close windows or open terminals behind the lock screen + (passive keys still flow to the focused lock surface). The quit chord + and confirm modal above run first and remain available, as does the VT + switch below (a matching compositor session action). */ + if (server.session_locked) + return true; /* Ctrl+Alt+F1..F12 switch virtual terminals in standalone mode. wlroots has no built-in handler; the compositor must notice the key and ask the session (libseat/logind) to activate another VT. Both the press and @@ -9590,6 +9810,15 @@ int main(int argc, char *argv[]) { wl_signal_add(&server.idle_inhibit_mgr->events.new_inhibitor, &server.idle_new_inhibitor); + /* Session lock (ext-session-lock-v1): let a lock client (swaylock) grab + the session and blank the screen. While locked, no window may take + focus and the grab chords are swallowed (see process_key_event). */ + server.session_lock_manager = + wlr_session_lock_manager_v1_create(server.wl_display); + server.session_lock_new_lock.notify = handle_session_lock_new_lock; + wl_signal_add(&server.session_lock_manager->events.new_lock, + &server.session_lock_new_lock); + /* The output layout exists in both modes so xdg-output can advertise logical output geometry to clients; nested mode simply has one output (the host window) in the layout. */ diff --git a/meson.build b/meson.build index bceb9a9..98da488 100644 --- a/meson.build +++ b/meson.build @@ -134,6 +134,13 @@ protocols = { wl_protocols_dir / 'unstable/idle-inhibit/idle-inhibit-unstable-v1.xml', 'idle-inhibit-unstable-v1', ], + # Session lock: the lock client (swaylock) grabs the session and private + # protocol surfaces cover each output; the compositor blanking the screen + # while locked. + 'ext-session-lock': [ + wl_protocols_dir / 'staging/ext-session-lock/ext-session-lock-v1.xml', + 'ext-session-lock-v1', + ], 'cursor-shape': [ wl_protocols_dir / 'staging/cursor-shape/cursor-shape-v1.xml', 'cursor-shape-v1', @@ -235,6 +242,7 @@ executable('wltest', code_by_name['ext-foreign-toplevel-list'], code_by_name['ext-idle-notify'], code_by_name['idle-inhibit-unstable-v1'], + code_by_name['ext-session-lock'], client_headers['xdg-shell'], client_headers['cursor-shape'], client_headers['wlr-layer-shell-unstable-v1'], @@ -242,6 +250,7 @@ executable('wltest', client_headers['ext-foreign-toplevel-list'], client_headers['ext-idle-notify'], client_headers['idle-inhibit-unstable-v1'], + client_headers['ext-session-lock'], dependencies: dependency('wayland-client')) # xleader: X11 twin-window probe used by the leader-window regression test -- diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 4b69746..d12ef05 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -3259,3 +3259,56 @@ def test_standalone_headless_idle_inhibit(tmp_path): "ext-idle-notify fired despite idle-inhibit being held" finally: s.close() + + +# --- Session-lock smoke tests ------------------------------------------------ + +def test_standalone_headless_session_lock_globals(tmp_path): + """The compositor advertises the ext_session_lock_manager_v1 global so a + lock client (swaylock) can grab the session.""" + out = tmp_path / "globals.txt" + s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) + try: + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + if out.exists(): + break + time.sleep(0.1) + assert out.exists(), "wltest --globals-out never wrote its file" + names = {line.split()[0] for line in out.read_text().splitlines()} + assert "ext_session_lock_manager_v1" in names, \ + f"missing ext_session_lock_manager_v1; advertised: {sorted(names)}" + finally: + s.close() + + +def test_standalone_headless_session_lock(tmp_path): + """A lock client that grabs ext-session-lock-v1 is accepted: the compositor + replies with the locked event, then configures a private lock surface for + the output (confirmed by the client's ack_configure + buffer commit).""" + out = tmp_path / "lock.out" + s = _spawn_standalone( + {}, + "%s --app-id testwin '#ff0000' 320x240" % WL, + "%s --lock --lock-out '%s'" % (WL, out)) + try: + s.wait_any_active() + deadline = time.monotonic() + 10.0 + got_surface = False + while time.monotonic() < deadline: + if out.exists(): + text = out.read_text().strip() + if "surface-config" in text: + got_surface = True + break + time.sleep(0.1) + assert got_surface, \ + f"lock client never saw its lock surface configure: " \ + f"{(out.read_text().strip() if out.exists() else '(missing)')!r}" + # The lock client must have received the locked event too (the + # compositor only sends it after setting up the blanked session). + assert s.proc.poll() is None, \ + f"compositor exited under lock; log tail:\n{s.read_log()[-400:]}" + finally: + s.close() + diff --git a/tests/wltest.c b/tests/wltest.c index c0147b7..5e1ccee 100644 --- a/tests/wltest.c +++ b/tests/wltest.c @@ -22,6 +22,7 @@ #include "ext-foreign-toplevel-list-v1-client.h" #include "ext-idle-notify-v1-client.h" #include "idle-inhibit-unstable-v1-client.h" +#include "ext-session-lock-v1-client.h" /* Minimal deterministic xdg toplevel test client: shows a solid-color shm buffer and runs until killed. Used by test.sh to give the smoke test two @@ -114,6 +115,8 @@ static struct zwlr_foreign_toplevel_manager_v1 *ft_mgr; static struct ext_foreign_toplevel_list_v1 *ext_list; static struct ext_idle_notifier_v1 *idle_mgr; static struct zwp_idle_inhibit_manager_v1 *inhib_mgr; +static struct ext_session_lock_manager_v1 *lock_mgr; +static struct wl_output *wl_output; struct foreign_state { char app_id[64]; @@ -709,6 +712,15 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name, inhib_mgr = wl_registry_bind(reg, name, &zwp_idle_inhibit_manager_v1_interface, version < 1 ? version : 1); + } else if (strcmp(interface, + ext_session_lock_manager_v1_interface.name) == 0) { + lock_mgr = wl_registry_bind(reg, name, + &ext_session_lock_manager_v1_interface, + version < 1 ? version : 1); + } else if (strcmp(interface, wl_output_interface.name) == 0) { + if (!wl_output) + wl_output = wl_registry_bind(reg, name, &wl_output_interface, + version < 2 ? version : 2); } } @@ -1068,6 +1080,119 @@ static void idle_run(void) { } } +/* --- Session lock (ext-session-lock-v1) --- + --lock [--lock-out ]: act as the swaylock side of the compositor. + Binds ext_session_lock_manager_v1, calls lock, waits for the compositor's + locked event, then creates a lock surface on the single wl_output and draws + a solid buffer on it (proving the compositor blanked the session and + accepted the surface). Writes "locked" then "surface-config" to --lock-out + as each stage completes; the strongest observable is the locked event the + compositor only sends once it has set up the blanked session. */ +static bool lock_mode; +static const char *lock_out; +static bool lock_unlock_after; +static struct ext_session_lock_v1 *lock; +static struct ext_session_lock_surface_v1 *lock_surface; +static struct wl_surface *lock_wl_surface; +static bool lock_conf_acked; +static bool lock_received_locked; + +static void lock_note(const char *s) { + if (lock_out) write_outfile(lock_out, s, strlen(s)); +} + +/* Forward declaration: lock_locked (called on the locked event) sets up the + lock surface and subscribes to its configure callback. */ +static const struct ext_session_lock_surface_v1_listener lock_surface_listener; + +/* Type for lock-window shm buffers: solid black, so the screen is visibly + blanked while the session is locked. */ +static void lock_fill_buffer(void) { + size_t stride = (size_t)W * 4; + size_t size = stride * (size_t)H; + int fd; + struct wl_shm_pool *pool = make_pool(size, &fd); + if (!pool) return; + uint8_t *data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (data != MAP_FAILED) { + memset(data, 0, size); /* opaque black (XRGB) */ + munmap(data, size); + } + close(fd); + struct wl_buffer *b = wl_shm_pool_create_buffer(pool, 0, W, H, + (int32_t)stride, WL_SHM_FORMAT_XRGB8888); + wl_shm_pool_destroy(pool); + wl_surface_attach(lock_wl_surface, b, 0, 0); + wl_surface_commit(lock_wl_surface); +} + +static void lock_locked(void *data, struct ext_session_lock_v1 *l) { + (void)data; (void)l; + lock_received_locked = true; + lock_note("locked"); + /* The compositor accepted the lock; create a private surface on the + output so the session is visibly blanked and the compositor gets a + surface to focus. */ + if (W == 0) W = 64; + if (H == 0) H = 64; + lock_wl_surface = wl_compositor_create_surface(compositor); + if (!lock_wl_surface || !wl_output) { + lock_note("surface-fail"); + return; + } + lock_surface = ext_session_lock_v1_get_lock_surface(lock, lock_wl_surface, + wl_output); + ext_session_lock_surface_v1_add_listener(lock_surface, + &lock_surface_listener, NULL); +} + +static void lock_finished(void *data, struct ext_session_lock_v1 *l) { + (void)data; (void)l; + lock_note("finished"); +} + +static const struct ext_session_lock_v1_listener lock_listener = { + .locked = lock_locked, + .finished = lock_finished, +}; + +static void lock_surface_configure(void *data, + struct ext_session_lock_surface_v1 *l, + uint32_t serial, uint32_t w, uint32_t h) { + (void)data; + if (w > 0 && h > 0) { W = (int)w; H = (int)h; } + lock_conf_acked = true; + ext_session_lock_surface_v1_ack_configure(l, serial); + lock_fill_buffer(); + lock_note("surface-config"); + if (lock_unlock_after) + ext_session_lock_v1_unlock_and_destroy(lock); +} + +static const struct ext_session_lock_surface_v1_listener lock_surface_listener = { + .configure = lock_surface_configure, +}; +static void lock_run(void) { + display = wl_display_connect(NULL); + if (!display) { + fprintf(stderr, "wltest: lock: cannot connect\n"); + exit(1); + } + struct wl_registry *reg = wl_display_get_registry(display); + wl_registry_add_listener(reg, ®istry_listener, NULL); + wl_display_roundtrip(display); + if (!lock_mgr) { + fprintf(stderr, "wltest: lock: ext_session_lock_manager_v1 not advertised\n"); + exit(1); + } + lock = ext_session_lock_manager_v1_lock(lock_mgr); + ext_session_lock_v1_add_listener(lock, &lock_listener, NULL); + for (;;) { + if (wl_display_dispatch(display) < 0) + break; + } +} + /* Pull the current offer and act on it. In --primary-demand mode write the payload to the file and exit; in --primary-demand-expect mode keep waiting until the payload matches the expected text (the host may already hold its @@ -1340,10 +1465,29 @@ int main(int argc, char **argv) { } else if (strcmp(argv[i], "--idle-inhibit") == 0) { idle_inhibit = true; i += 1; + } else if (strcmp(argv[i], "--lock") == 0) { + lock_mode = true; + i += 1; + } else if (strcmp(argv[i], "--lock-out") == 0) { + if (i + 1 >= argc) { + fprintf(stderr, "wltest: --lock-out needs a path\n"); + return 1; + } + lock_out = argv[i + 1]; + i += 2; + } else if (strcmp(argv[i], "--lock-unlock") == 0) { + lock_unlock_after = true; + i += 1; } else { break; } } + /* Session-lock mode: no color/size argument. Act as the swaylock side + of ext-session-lock-v1 and never open a normal toplevel. */ + if (lock_mode) { + lock_run(); + return 0; + } /* Taskbar mode: no color/size argument. Run as a foreign-toplevel listener and never create a window. */ if (foreign_mode) {