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.
This commit is contained in:
Andrew Hamilton 2026-09-03 07:39:25 +10:00
parent 4ea5df0e62
commit 143a1053c2
4 changed files with 437 additions and 2 deletions

233
kick.c
View file

@ -99,6 +99,7 @@
#include <wlr/types/wlr_relative_pointer_v1.h>
#include <wlr/types/wlr_scene.h>
#include <wlr/types/wlr_seat.h>
#include <wlr/types/wlr_session_lock_v1.h>
#include <wlr/types/wlr_touch.h>
#include <wlr/types/wlr_shm.h>
#include <wlr/types/wlr_subcompositor.h>
@ -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. */

View file

@ -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 --

View file

@ -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()

View file

@ -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 <file>]: 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, &registry_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) {