summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-13 16:05:28 +0900
committerAdam Malczewski <[email protected]>2026-06-13 16:05:28 +0900
commit9ee2d3ceee93ff0d0afdb47f594ea4fee95455fc (patch)
tree4a1b73d8f140c88775d0487434ba23e807fc441f
parent11812b0374d5de395e2c17532c6bf89a903ee043 (diff)
downloadunbox-9ee2d3ceee93ff0d0afdb47f594ea4fee95455fc.tar.gz
unbox-9ee2d3ceee93ff0d0afdb47f594ea4fee95455fc.zip
Kernel: Ctrl+Alt+F1..F12 VT switching (session escape hatch)
Intercept the XF86Switch_VT_1..12 keysyms before the keybinding filter and call wlr_session_change_vt, so the user can always switch consoles while unbox runs. Clean no-op without a session (headless/nested). Pure vt_for_keysym helper + doctest; wlroots reached via the wlr.hpp wrapper. Real-seat verified on the CF-AX3.
-rw-r--r--packages/kernel/include/unbox/kernel/wlr.hpp9
-rw-r--r--packages/kernel/src/input.cpp19
-rw-r--r--packages/kernel/src/server.cpp5
-rw-r--r--packages/kernel/src/server_impl.hpp4
-rw-r--r--packages/kernel/src/vt_core.hpp33
-rw-r--r--packages/kernel/tests/test_kernel.cpp32
6 files changed, 101 insertions, 1 deletions
diff --git a/packages/kernel/include/unbox/kernel/wlr.hpp b/packages/kernel/include/unbox/kernel/wlr.hpp
index 496d737..c890774 100644
--- a/packages/kernel/include/unbox/kernel/wlr.hpp
+++ b/packages/kernel/include/unbox/kernel/wlr.hpp
@@ -27,6 +27,15 @@ extern "C" {
// protocol/wlr headers (only `namespace` collides in the current set).
#define namespace _namespace
#include <wlr/backend.h>
+// Session escape-hatch (kernel VT switching, Ctrl+Alt+Fn): wlr_session +
+// wlr_session_change_vt. In wlroots 0.20 the session is NOT fetched from the
+// backend (there is no wlr_backend_get_session); it is the out-param of
+// wlr_backend_autocreate, which the kernel captures at init. NULL under the
+// headless/nested backends (no libseat session) — the glue no-ops then.
+// Static-blanking re-audit: this header is plain declarations only (no
+// header-inline function with a function-local static, no `[static N]`
+// array-param), so the surrounding `#define static` is inert across it.
+#include <wlr/backend/session.h>
#include <wlr/render/allocator.h>
// Slice-3 spike (RMLUi -> wlr_scene bridge): EGL/dmabuf, the GLES2 renderer's
// EGL accessors, buffer (dmabuf + data-ptr access for the shm fallback),
diff --git a/packages/kernel/src/input.cpp b/packages/kernel/src/input.cpp
index 336947e..6056c53 100644
--- a/packages/kernel/src/input.cpp
+++ b/packages/kernel/src/input.cpp
@@ -1,5 +1,7 @@
#include "server_impl.hpp"
+#include "vt_core.hpp"
+
#include <xkbcommon/xkbcommon.h>
namespace unbox::kernel {
@@ -75,6 +77,23 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
const std::uint32_t modifiers = wlr_keyboard_get_modifiers(keyboard->keyboard);
const bool pressed = event->state == WL_KEYBOARD_KEY_STATE_PRESSED;
+ // SESSION ESCAPE HATCH: Ctrl+Alt+Fn (XF86Switch_VT_1..12) switches the
+ // Linux VT. Handled HERE, kernel-hardwired, BEFORE the key_filter, so no
+ // extension can intercept, consume, or block the only escape from the
+ // real DRM seat (user decision: not config-driven, not rebindable). On
+ // PRESS we change the VT; we CONSUME both press and release (the matching
+ // release carries the same keysym) so the filter never runs and the key
+ // never reaches the focused client. No session (headless/nested =>
+ // session is NULL) is a clean no-op — never crash.
+ for (int i = 0; i < nsyms; ++i) {
+ if (const std::optional<unsigned> vt = vt_for_keysym(syms[i])) {
+ if (pressed && session != nullptr) {
+ wlr_session_change_vt(session, *vt);
+ }
+ return; // consume: no filter, no client forward (press or release)
+ }
+ }
+
// Thread each resolved keysym through the key_filter; a filter link may
// CONSUME the key (set handled=true) — that is how extensions implement
// compositor shortcuts. If any resolution is consumed, suppress the
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index a0627f4..a5f8365 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -145,7 +145,10 @@ void Server::Impl::init() {
wlr_log_init(WLR_INFO, nullptr);
display = require(wl_display_create(), "wl_display");
- backend = require(wlr_backend_autocreate(wl_display_get_event_loop(display), nullptr),
+ // Capture the session out-param: on the real DRM seat it is the libseat
+ // session the VT-switch escape hatch (input.cpp) drives; NULL under
+ // headless/nested (no real seat), where VT switching no-ops cleanly.
+ backend = require(wlr_backend_autocreate(wl_display_get_event_loop(display), &session),
"wlr_backend");
renderer = require(wlr_renderer_autocreate(backend), "wlr_renderer");
wlr_renderer_init_wl_display(renderer, display);
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index 3407883..ef4a479 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -69,6 +69,10 @@ struct Server::Impl : detail::DisableSink {
wl_display* display = nullptr;
wlr_backend* backend = nullptr;
+ // The libseat/logind session, captured from wlr_backend_autocreate's
+ // out-param at init. NULL under headless/nested backends (no real seat) —
+ // the VT-switch escape hatch no-ops cleanly then. Owned by the backend.
+ wlr_session* session = nullptr;
wlr_renderer* renderer = nullptr;
wlr_allocator* allocator = nullptr;
wlr_scene* scene = nullptr;
diff --git a/packages/kernel/src/vt_core.hpp b/packages/kernel/src/vt_core.hpp
new file mode 100644
index 0000000..d08837d
--- /dev/null
+++ b/packages/kernel/src/vt_core.hpp
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <optional>
+
+#include <xkbcommon/xkbcommon.h>
+
+// Pure decision core for the kernel's VT-switch escape hatch — NO wlroots / GL,
+// so it is doctest-able with nothing running (AGENTS.md: effects at the edges,
+// pure cores tested hard). The keyboard glue (input.cpp) injects the effect
+// (wlr_session_change_vt) around this.
+//
+// xkbcommon is allowed here: it is a pure value library (a keysym is an
+// integer), not a wlroots/GL/wayland effect surface — the wlroots-include rule
+// targets <wlr/...> + <wayland-server*.h>, and input.cpp already includes
+// <xkbcommon/xkbcommon.h> for keysym resolution.
+
+namespace unbox::kernel {
+
+// Map a resolved keysym to the Linux VT it requests, or nullopt if it is not a
+// VT-switch keysym. Ctrl+Alt+Fn is delivered by xkb as the contiguous range
+// XKB_KEY_XF86Switch_VT_1 (0x1008FE01) .. XKB_KEY_XF86Switch_VT_12
+// (0x1008FE0C); the returned value is the 1-based VT number (1..12). These
+// keysyms are ONLY produced with Ctrl+Alt held, so matching the keysym is
+// sufficient — no separate modifier-mask check. Plain F1..F12 resolve to the
+// ordinary XKB_KEY_F1.. keysyms and are NOT in this range, so they pass through.
+[[nodiscard]] constexpr auto vt_for_keysym(xkb_keysym_t keysym) -> std::optional<unsigned> {
+ if (keysym >= XKB_KEY_XF86Switch_VT_1 && keysym <= XKB_KEY_XF86Switch_VT_12) {
+ return static_cast<unsigned>(keysym - XKB_KEY_XF86Switch_VT_1) + 1U;
+ }
+ return std::nullopt;
+}
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index dbb5c02..912d35e 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -13,9 +13,12 @@
// state machine, implicit-grab ownership, hit-test geometry) are doctest-ed
// directly, no wlroots.
#include "../src/ui_core.hpp"
+// The VT-switch escape hatch's pure core (keysym -> VT number), no wlroots.
+#include "../src/vt_core.hpp"
#include <cstdlib>
#include <memory>
+#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
@@ -349,6 +352,35 @@ TEST_CASE("substrate: a click over a ui surface is CONSUMED (no click-through)")
}
// ============================================================================
+// VT-switch escape hatch — PURE CORE (no wlroots): keysym -> VT number. The
+// glue (input.cpp) calls wlr_session_change_vt on a hit and consumes; this
+// helper decides the hit. Ctrl+Alt+Fn arrives as XF86Switch_VT_1..12.
+// ============================================================================
+
+TEST_CASE("vt_for_keysym: maps the XF86Switch_VT range to 1..12") {
+ using unbox::kernel::vt_for_keysym;
+
+ // Both endpoints of the range.
+ CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_1) == 1U);
+ CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_12) == 12U);
+ // A representative interior value.
+ CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_2) == 2U);
+ CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_7) == 7U);
+
+ // Just outside the range on both sides => nullopt (no VT-switch).
+ CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_1 - 1) == std::nullopt);
+ CHECK(vt_for_keysym(XKB_KEY_XF86Switch_VT_12 + 1) == std::nullopt);
+
+ // Plain F1..F12 (no Ctrl+Alt) resolve to ordinary keysyms, NOT the
+ // XF86Switch_VT range — they must pass through untouched.
+ CHECK(vt_for_keysym(XKB_KEY_F1) == std::nullopt);
+ CHECK(vt_for_keysym(XKB_KEY_F12) == std::nullopt);
+
+ // An unrelated keysym.
+ CHECK(vt_for_keysym(XKB_KEY_a) == std::nullopt);
+}
+
+// ============================================================================
// The typed bus — PURE CORE (strict; zero mocks of unbox modules, no wlroots
// running). A test DisableSink stands in for the kernel's isolation registry.
// ============================================================================