summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 22:44:16 +0900
committerAdam Malczewski <[email protected]>2026-06-12 22:44:16 +0900
commitc102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7 (patch)
treef6dea2875b939c0f661292d8bfa0d79a96fe67d7 /packages/kernel
parent6949c3582ed1e480e70aabfcfa3a11b78007cc12 (diff)
downloadunbox-c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7.tar.gz
unbox-c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7.zip
Slice 4: extension host + typed bus; xdg-shell/layer-shell extracted to core extensions
The kernel now names NO concrete feature. It owns: the extension host (install/topological activate, missing-dep/cycle = startup error), the typed Event/Filter bus (error-isolated: a throwing extension is disabled, never the session; RAII Subscriptions), the Host API (per-extension facade: borrows, scene layers, event catalogue, typed services), the public RAII Listener, and a typed surface→scene-tree registry (Host::host_surface/scene_tree_for) that replaced the untyped wlr_surface.data convention both extensions flagged. - ext-xdg-shell (core): toplevel/popup lifecycle, focus-on-map, click/tap-to-focus, pointer/touch routing incl. button+axis (the kernel only moves the cursor and emits — a contract-doc lie caught by user hands-on), interactive move/resize via pure GrabMachine (fixes the request-arrives-after-release race: grab requires request ∧ button-down, release always ends it), Alt+F1 cycle, Ctrl+Alt+Backspace terminate (labwc's default A-Escape=Exit killed the dev session once — never again; see nested-run skill). - ext-layer-shell (core): wlr-layer-shell v1 (proto v5) for external clients; pure doctest-hard arrangement core; fuzzel verified visually nested (fix: seed outputs from output_layout at activate — events-only tracking missed pre-activation outputs; plus a scene-node double-free). - First protocol codegen: vendored wlr-layer-shell XML + wayland-scanner server-header propagated through kernel_dep; wlr.hpp grew a namespace→_namespace keyword fix for the generated header. - Glossary: 'scene layer' (user-approved). New rules earned: parallel-wave-builds, contract-docs. - User hands-on verified: typing, click-to-focus, drag-select, scroll, titlebar drag-move (slow + flick), Alt+F1, fuzzel + arrows, touch tap, Ctrl+Alt+Backspace. 68 doctest cases green, ASan/UBSan clean (our code), idle RSS ≈73 MiB.
Diffstat (limited to 'packages/kernel')
-rw-r--r--packages/kernel/include/unbox/kernel/extension.hpp69
-rw-r--r--packages/kernel/include/unbox/kernel/hooks.hpp334
-rw-r--r--packages/kernel/include/unbox/kernel/host.hpp274
-rw-r--r--packages/kernel/include/unbox/kernel/listener.hpp72
-rw-r--r--packages/kernel/include/unbox/kernel/server.hpp33
-rw-r--r--packages/kernel/include/unbox/kernel/surface_registry.hpp134
-rw-r--r--packages/kernel/include/unbox/kernel/wlr.hpp22
-rw-r--r--packages/kernel/kernel.md99
-rw-r--r--packages/kernel/meson.build48
-rw-r--r--packages/kernel/src/input.cpp241
-rw-r--r--packages/kernel/src/listener.hpp59
-rw-r--r--packages/kernel/src/server.cpp191
-rw-r--r--packages/kernel/src/server_impl.hpp226
-rw-r--r--packages/kernel/src/toplevel.cpp190
-rw-r--r--packages/kernel/tests/test_kernel.cpp383
15 files changed, 1826 insertions, 549 deletions
diff --git a/packages/kernel/include/unbox/kernel/extension.hpp b/packages/kernel/include/unbox/kernel/extension.hpp
new file mode 100644
index 0000000..d47a46c
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/extension.hpp
@@ -0,0 +1,69 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+// What an extension IS, to the kernel. An extension is an in-process unit that
+// contributes capabilities (hooks, services, ui surfaces, protocol glue)
+// through the Host API it receives in activate(). The kernel names no concrete
+// extension; host-bin (the composition root) names them all.
+//
+// Lifetime / deactivation: there is NO teardown method by design. An
+// extension's lifetime IS the session's; deactivation = destruction. Hold
+// every resource (Subscriptions, service registrations, scene nodes, wlroots
+// Listeners) as a member; they release in REVERSE declaration order when the
+// extension object is destroyed. So declare members in dependency order
+// (things that depend on the Host's borrows last). The kernel destroys
+// extensions in reverse activation (topological) order at shutdown.
+//
+// Everything runs on the single wl_event_loop thread.
+
+namespace unbox::kernel {
+
+class Host;
+
+// The activation tier. Lower tiers activate first and may be depended upon by
+// higher tiers; an extension may also depend on same-tier extensions by id
+// (resolved topologically within the install set). See GLOSSARY "core"/
+// "standard". The kernel itself is below all tiers and is always present.
+enum class Tier {
+ core, // minimum usable session: xdg-shell, layer-shell, keybindings…
+ standard, // on-by-default features: taskbar, launcher, tiling, OSK…
+};
+
+// An extension's self-declaration. `id` is for ACTIVATION ORDERING and
+// DIAGNOSTICS ONLY — capabilities are NEVER looked up by string (AGENTS.md:
+// string-keyed lookups are forbidden; cross-extension coupling goes through
+// exported typed hook/service symbols). Two installed extensions sharing an id
+// is a startup error; a depends_on naming an id not in the install set is a
+// startup error; a dependency cycle is a startup error.
+struct Manifest {
+ std::string id; // unique, stable, e.g. "ext-xdg-shell"
+ Tier tier = Tier::standard; // activation-order tier
+ std::vector<std::string> depends_on; // ids that must activate before this
+};
+
+// The base every extension implements. Construction is cheap and side-effect
+// free; ALL wiring happens in activate(). activate() is called once, in
+// topological order, before Server::run(). The Host& borrow is valid for the
+// whole session (until this extension is destroyed at shutdown) — but it is a
+// per-extension facade and must NOT be handed to another extension.
+class Extension {
+public:
+ virtual ~Extension() = default;
+
+ // Static identity; must return the same value every call (the Server reads
+ // it once at install time). No side effects.
+ [[nodiscard]] virtual auto manifest() const -> const Manifest& = 0;
+
+ // Wire up: subscribe to hooks, register services, create ui surfaces and
+ // scene nodes, attach wlroots Listeners — storing every returned RAII
+ // handle as a member of `this`. May throw to signal a fatal activation
+ // failure; the Server aborts startup with that error (activation failures
+ // are not isolated — a core extension that can't start is a broken
+ // session, surfaced to host-bin, not silently disabled). Runtime callback
+ // throws ARE isolated (hooks.hpp).
+ virtual void activate(Host& host) = 0;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/hooks.hpp b/packages/kernel/include/unbox/kernel/hooks.hpp
new file mode 100644
index 0000000..a3e1dbb
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/hooks.hpp
@@ -0,0 +1,334 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <functional>
+#include <utility>
+#include <vector>
+
+// The typed extension bus — the architecture itself (AGENTS.md: "Cross-
+// extension coupling anchors to exported TYPED symbols"). Two hook kinds:
+//
+// Event<Args...> fire-and-forget, N listeners, error-isolated. The kernel
+// (or an extension) emits; every subscriber is invoked in
+// subscription order. A listener that throws is caught at
+// this boundary and its OWNING extension is disabled (all
+// its subscriptions dropped) — the emit still completes and
+// the remaining listeners still fire. The session never dies.
+//
+// Filter<T> an ordered value-in -> value-out chain. apply(v) threads v
+// through each link in subscription order; each link returns
+// the (possibly modified) value for the next. A throwing link
+// is skipped and its extension disabled; the chain continues
+// with the value as it stood before that link.
+//
+// Every subscribe() returns a move-only RAII Subscription whose destruction
+// unsubscribes (listener-lifetime.md). A callback MAY drop its own
+// Subscription, and an extension may be disabled mid-dispatch: removal is
+// deferred so the in-flight iteration stays valid (the compaction happens
+// after the dispatch unwinds).
+//
+// PURE CORE: no wlroots, no GL, no RMLUi types appear here. The bus is fully
+// exercisable with nothing running (tests/test_kernel.cpp). Everything runs on
+// the single wl_event_loop thread; no internal locking.
+
+namespace unbox::kernel {
+
+// Opaque per-extension identity, assigned by the Server at install time and
+// carried by each extension's Host. Hooks tag every subscription with the id
+// of the subscribing extension so a throwing callback disables the RIGHT one.
+// id 0 is reserved for "the kernel itself" (kernel-emitted, kernel-owned
+// subscriptions are never auto-disabled).
+enum class ExtensionId : std::uint32_t {};
+
+inline constexpr ExtensionId kernel_extension_id{0};
+
+namespace detail {
+
+// Shared sink the hooks call when a callback throws: it disables the owning
+// extension (dropping every subscription it holds across ALL hooks) and logs.
+// Implemented by the Server; injected into each hook at construction so the
+// bus core carries no kernel dependency. A null sink (default) means "no
+// isolation registry" — a throw is swallowed and logged-by-caller; used only
+// in pure-core tests that assert fan-out, where no extension registry exists.
+class DisableSink {
+public:
+ virtual ~DisableSink() = default;
+ // Called from inside a dispatch when `who`'s callback threw. MUST be
+ // re-entrant-safe w.r.t. the hook currently dispatching (the hook defers
+ // its own compaction); the sink marks the extension dead and requests
+ // each registered hook to purge that id once its dispatch unwinds.
+ virtual void disable(ExtensionId who) noexcept = 0;
+};
+
+} // namespace detail
+
+class Subscription;
+
+namespace detail {
+
+// Non-template base every hook derives from, so a single registry can purge an
+// extension's subscriptions across heterogeneous Event<...>/Filter<...>
+// instances without knowing their payload types.
+class HookBase {
+public:
+ virtual ~HookBase() = default;
+
+ // Drop every subscription owned by `who`. Safe to call during this hook's
+ // own dispatch: entries are tombstoned now and physically erased when the
+ // outermost dispatch finishes.
+ virtual void purge(ExtensionId who) noexcept = 0;
+
+ // Bind this hook to the isolation registry's sink (so a throwing callback
+ // here disables its owning extension everywhere). Called by Host::adopt()
+ // when an extension exports a hook it default-constructed as a member.
+ virtual void set_sink(DisableSink* sink) noexcept = 0;
+
+protected:
+ // Token identifying one subscription slot within a hook; handed to the
+ // Subscription so it can ask the hook to remove exactly that slot.
+ using Token = std::uint64_t;
+ static constexpr Token invalid_token = 0;
+
+ virtual void unsubscribe(Token token) noexcept = 0;
+
+ friend class unbox::kernel::Subscription;
+};
+
+} // namespace detail
+
+// Move-only RAII handle for one subscription. Destruction (or reset()/release-
+// by-move) unsubscribes. Holding it as a member of the extension (or of one of
+// the extension's RAII members) is the contract: when the extension is
+// destroyed, the member dies and the subscription drops. Never store a raw
+// hook reference or a bare callback across a unit boundary instead of this.
+class Subscription {
+public:
+ Subscription() = default;
+ Subscription(detail::HookBase* hook, std::uint64_t token) : hook_(hook), token_(token) {}
+
+ Subscription(Subscription&& other) noexcept
+ : hook_(other.hook_), token_(other.token_) {
+ other.hook_ = nullptr;
+ other.token_ = detail::HookBase::invalid_token;
+ }
+ auto operator=(Subscription&& other) noexcept -> Subscription& {
+ if (this != &other) {
+ reset();
+ hook_ = other.hook_;
+ token_ = other.token_;
+ other.hook_ = nullptr;
+ other.token_ = detail::HookBase::invalid_token;
+ }
+ return *this;
+ }
+ Subscription(const Subscription&) = delete;
+ auto operator=(const Subscription&) -> Subscription& = delete;
+
+ ~Subscription() { reset(); }
+
+ // Explicitly unsubscribe early. Idempotent. Safe to call from within the
+ // subscribed callback (the hook defers physical removal).
+ void reset() noexcept {
+ if (hook_ != nullptr) {
+ hook_->unsubscribe(token_);
+ hook_ = nullptr;
+ token_ = detail::HookBase::invalid_token;
+ }
+ }
+
+ [[nodiscard]] auto active() const noexcept -> bool { return hook_ != nullptr; }
+
+private:
+ detail::HookBase* hook_ = nullptr;
+ std::uint64_t token_ = detail::HookBase::invalid_token;
+};
+
+// ---- Event<Args...> : fire-and-forget, N listeners, error-isolated ----------
+
+template <typename... Args>
+class Event final : public detail::HookBase {
+public:
+ using Callback = std::function<void(Args...)>;
+
+ Event() = default;
+ explicit Event(detail::DisableSink* sink) : sink_(sink) {}
+ Event(const Event&) = delete;
+ auto operator=(const Event&) -> Event& = delete;
+
+ // Subscribe `cb`, owned by extension `who`. Returns the RAII handle; let it
+ // die to unsubscribe. Listeners fire in subscription order on emit().
+ [[nodiscard]] auto subscribe(ExtensionId who, Callback cb) -> Subscription {
+ const Token token = ++next_token_;
+ entries_.push_back(Entry{token, who, std::move(cb), false});
+ return Subscription(this, token);
+ }
+
+ // Fire the event: invoke every live listener in subscription order with a
+ // copy of the args (Args are passed by value semantics of std::function;
+ // pass borrows as raw pointers/refs in Args to avoid copies — see the
+ // kernel catalogue in host.hpp). A listener that throws is caught here and
+ // its extension disabled; the emit still completes. Re-entrant emit() and
+ // unsubscribe-during-emit are safe.
+ void emit(Args... args) {
+ ++depth_;
+ const std::size_t n = entries_.size(); // new subscriptions during emit don't fire
+ for (std::size_t i = 0; i < n; ++i) {
+ Entry& e = entries_[i];
+ if (e.dead) {
+ continue;
+ }
+ try {
+ e.cb(args...);
+ } catch (...) {
+ disable_owner(e.who);
+ }
+ }
+ --depth_;
+ compact_if_idle();
+ }
+
+ void purge(ExtensionId who) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.who == who) {
+ e.dead = true;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void set_sink(detail::DisableSink* sink) noexcept override { sink_ = sink; }
+
+private:
+ struct Entry {
+ Token token;
+ ExtensionId who;
+ Callback cb;
+ bool dead;
+ };
+
+ void unsubscribe(Token token) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.token == token) {
+ e.dead = true;
+ break;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void disable_owner(ExtensionId who) noexcept {
+ if (sink_ != nullptr && who != kernel_extension_id) {
+ sink_->disable(who); // routes back through purge() on every hook
+ } else {
+ purge(who);
+ }
+ }
+
+ void compact_if_idle() noexcept {
+ if (depth_ != 0) {
+ return; // a dispatch is in flight; erasing now would invalidate it
+ }
+ std::erase_if(entries_, [](const Entry& e) { return e.dead; });
+ }
+
+ std::vector<Entry> entries_;
+ detail::DisableSink* sink_ = nullptr;
+ Token next_token_ = 0;
+ int depth_ = 0;
+};
+
+// ---- Filter<T> : ordered value-in -> value-out chain -------------------------
+
+template <typename T>
+class Filter final : public detail::HookBase {
+public:
+ // Each link receives the current value and returns the value for the next
+ // link. Take T by value and return T (the chain threads by value).
+ using Link = std::function<T(T)>;
+
+ Filter() = default;
+ explicit Filter(detail::DisableSink* sink) : sink_(sink) {}
+ Filter(const Filter&) = delete;
+ auto operator=(const Filter&) -> Filter& = delete;
+
+ // Append `link`, owned by extension `who`. Links run in subscription order.
+ [[nodiscard]] auto subscribe(ExtensionId who, Link link) -> Subscription {
+ const Token token = ++next_token_;
+ entries_.push_back(Entry{token, who, std::move(link), false});
+ return Subscription(this, token);
+ }
+
+ // Thread `value` through the chain and return the result. A link that
+ // throws is skipped (and its extension disabled); the chain continues with
+ // the value as it stood BEFORE that link. With no links, returns `value`.
+ [[nodiscard]] auto apply(T value) -> T {
+ ++depth_;
+ const std::size_t n = entries_.size();
+ for (std::size_t i = 0; i < n; ++i) {
+ Entry& e = entries_[i];
+ if (e.dead) {
+ continue;
+ }
+ try {
+ value = e.link(value);
+ } catch (...) {
+ disable_owner(e.who);
+ }
+ }
+ --depth_;
+ compact_if_idle();
+ return value;
+ }
+
+ void purge(ExtensionId who) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.who == who) {
+ e.dead = true;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void set_sink(detail::DisableSink* sink) noexcept override { sink_ = sink; }
+
+private:
+ struct Entry {
+ Token token;
+ ExtensionId who;
+ Link link;
+ bool dead;
+ };
+
+ void unsubscribe(Token token) noexcept override {
+ for (Entry& e : entries_) {
+ if (e.token == token) {
+ e.dead = true;
+ break;
+ }
+ }
+ compact_if_idle();
+ }
+
+ void disable_owner(ExtensionId who) noexcept {
+ if (sink_ != nullptr && who != kernel_extension_id) {
+ sink_->disable(who);
+ } else {
+ purge(who);
+ }
+ }
+
+ void compact_if_idle() noexcept {
+ if (depth_ != 0) {
+ return;
+ }
+ std::erase_if(entries_, [](const Entry& e) { return e.dead; });
+ }
+
+ std::vector<Entry> entries_;
+ detail::DisableSink* sink_ = nullptr;
+ Token next_token_ = 0;
+ int depth_ = 0;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/host.hpp b/packages/kernel/include/unbox/kernel/host.hpp
new file mode 100644
index 0000000..7fb0eea
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/host.hpp
@@ -0,0 +1,274 @@
+#pragma once
+
+#include <unbox/kernel/hooks.hpp>
+#include <unbox/kernel/surface_registry.hpp>
+#include <unbox/kernel/wlr.hpp>
+
+#include <cstdint>
+#include <typeindex>
+
+// The Host API: the typed facade an Extension receives in activate(). It is
+// PER-EXTENSION — the kernel hands each extension its own Host so that when a
+// hook callback throws, the bus knows which extension to disable. Never pass
+// your Host to another extension.
+//
+// Everything a Host exposes is valid for the SESSION lifetime (until your
+// extension is destroyed at shutdown), unless noted. The wlroots borrows
+// (display/scene/seat/cursor/output_layout, and the scene-layer trees) are
+// non-owning: the kernel owns them; you may attach nodes/listeners but never
+// destroy them. wlroots types arrive via wlr.hpp (a public header), so an
+// extension's own glue is first-class.
+//
+// Single wl_event_loop thread throughout.
+
+namespace unbox::kernel {
+
+// ---- Kernel event payloads --------------------------------------------------
+//
+// The kernel emits these for the generic glue it owns. Payloads carry the
+// data an extension needs to implement policy WITHOUT reading kernel src.
+// Pointers inside a payload are BORROWS valid only for the duration of the
+// emit call — never store them; store the wlr object's own stable handle if
+// you must track it, and drop it on its destroy event.
+
+// An output was added (after init_render + enable + scene wiring) or is about
+// to be removed (emitted from its destroy handler; the wlr_output is still
+// valid for the call, gone after). Distinguish via the two separate events.
+struct OutputEvent {
+ wlr_output* output; // borrow, valid for the call only
+};
+
+// Pointer motion already applied to the cursor; layout coords are the cursor's
+// post-move position. Emitted for both relative and absolute motion.
+struct PointerMotionEvent {
+ double lx; // cursor layout x AFTER the move
+ double ly; // cursor layout y AFTER the move
+ std::uint32_t time_msec;
+};
+
+// A pointer button. The kernel does NOT forward it to any client — it only
+// moves the cursor and emits this. The single pointer-routing extension is
+// responsible for calling wlr_seat_pointer_notify_button (exactly like
+// enter/motion/frame): that is what lets an interactive move/resize grab
+// suppress the forward by simply not notifying. Carries the cursor layout
+// position so a listener can hit-test the scene (click-to-focus, begin
+// interactive grab). `pressed` == button down.
+struct PointerButtonEvent {
+ std::uint32_t button; // linux/input-event-codes BTN_*
+ bool pressed;
+ double lx; // cursor layout x at the event
+ double ly; // cursor layout y at the event
+ std::uint32_t time_msec;
+};
+
+// A pointer axis (scroll) event. The kernel does NOT forward it to any client
+// — the single pointer-routing extension calls wlr_seat_pointer_notify_axis
+// (exactly like button/enter/motion/frame). Kernel only emits.
+struct PointerAxisEvent {
+ wl_pointer_axis orientation;
+ double delta;
+ std::int32_t delta_discrete;
+ wl_pointer_axis_source source;
+ std::uint32_t time_msec;
+};
+
+// A keyboard key, BEFORE it is forwarded to the focused client. Threaded
+// through the key_filter (see Host::key_filter): set `handled = true` to
+// CONSUME it (the kernel will not forward it to the client) — this is how
+// ext-keybindings/ext-xdg-shell implement compositor shortcuts. `keysym` is
+// the resolved xkb keysym of the (modified) press; `modifiers` is the active
+// modifier mask (WLR_MODIFIER_*).
+struct KeyEvent {
+ std::uint32_t keysym; // xkb_keysym_t
+ std::uint32_t keycode; // raw libinput keycode (for notify_key passthrough)
+ std::uint32_t modifiers; // WLR_MODIFIER_* mask
+ bool pressed; // true on press, false on release
+ std::uint32_t time_msec;
+ bool handled = false; // set true to consume (suppress client forward)
+};
+
+// A touch point went down / moved / up. `lx`/`ly` are layout coords (the
+// cursor path's absolute-to-layout mapping); the extension hit-tests the scene
+// itself to find the target surface (the kernel routes nothing to clients).
+struct TouchDownEvent {
+ std::int32_t touch_id;
+ double lx;
+ double ly;
+ std::uint32_t time_msec;
+};
+struct TouchMotionEvent {
+ std::int32_t touch_id;
+ double lx;
+ double ly;
+ std::uint32_t time_msec;
+};
+struct TouchUpEvent {
+ std::int32_t touch_id;
+ std::uint32_t time_msec;
+};
+struct TouchCancelEvent {
+ std::int32_t touch_id;
+};
+
+// ---- Scene layers -----------------------------------------------------------
+//
+// Ordered z-bands of the scene, so extensions never fight over node order.
+// Names follow wlr-layer-shell (background/bottom/top/overlay) plus `normal`
+// for application toplevels, which layer-shell lacks. Stacking is strictly
+// background < bottom < normal < top < overlay. An extension attaches its
+// nodes under the tree it gets from Host::scene_layer(); raising/lowering
+// WITHIN a layer is the extension's business, crossing layers is not.
+//
+// GLOSSARY: "scene layer" / SceneLayer is a NEW term (flagged for sign-off in
+// reports/kernel.md). It reuses wlr-layer-shell's band names verbatim.
+enum class SceneLayer {
+ background,
+ bottom,
+ normal,
+ top,
+ overlay,
+};
+
+// ---- Service registry keying (typed, never string) --------------------------
+//
+// A service is a single-responder request/response capability: one extension
+// registers an implementation of an abstract interface I; others fetch it by
+// the TYPE I. The public API is the templated provide_service<I>/service<I>
+// below; the type identity is the only key (no strings — a missing provider is
+// a nullptr the caller checks, and the INTERFACE TYPE is a compile/link
+// dependency on the providing unit's public header). Re-registering a type
+// replaces the previous provider.
+
+class Host {
+public:
+ virtual ~Host() = default;
+ Host(const Host&) = delete;
+ auto operator=(const Host&) -> Host& = delete;
+
+ // ---- Session borrows (kernel-owned; never destroy) ----
+ [[nodiscard]] virtual auto display() -> wl_display* = 0;
+ [[nodiscard]] virtual auto scene() -> wlr_scene* = 0;
+ [[nodiscard]] virtual auto seat() -> wlr_seat* = 0;
+ [[nodiscard]] virtual auto cursor() -> wlr_cursor* = 0;
+ // The kernel's shared xcursor theme manager (kernel-owned; never destroy).
+ // For an extension that sets the cursor image itself — e.g. ext-xdg-shell
+ // drawing the default cursor on passthrough and resize/move cursors during
+ // an interactive grab (wlr_cursor_set_xcursor(cursor(), cursor_manager(),
+ // name)). Loaded at 24px; the kernel only touches it on seat focus-clear.
+ [[nodiscard]] virtual auto cursor_manager() -> wlr_xcursor_manager* = 0;
+ [[nodiscard]] virtual auto output_layout() -> wlr_output_layout* = 0;
+
+ // The scene-tree for a z-band. Attach your nodes here. Stable for the
+ // session; never destroy it. The trees are created once in stacking order.
+ [[nodiscard]] virtual auto scene_layer(SceneLayer layer) -> wlr_scene_tree* = 0;
+
+ // ---- Typed surface -> scene-tree association ----
+ // Register that `surface` is hosted in `tree` (which YOUR extension owns).
+ // Returns a move-only RAII SurfaceRegistration; keep it as a member of the
+ // hosting entity so the association dies with the node. Re-registering the
+ // same surface replaces the mapping (the older handle becomes a no-op).
+ // This is the typed replacement for the old wlr_surface.data convention —
+ // cross-unit surface->tree coupling routes through here, never .data.
+ [[nodiscard]] auto host_surface(wlr_surface* surface, wlr_scene_tree* tree)
+ -> SurfaceRegistration {
+ const auto token = surface_store().set(surface, tree);
+ return SurfaceRegistration(&surface_store(), surface, token);
+ }
+ // Resolve `surface` to the scene tree it is hosted in, or null if no
+ // extension has registered it. The returned tree is a BORROW owned by the
+ // registering extension, valid only while that registration handle lives —
+ // never cache it across events; re-resolve each time.
+ [[nodiscard]] auto scene_tree_for(wlr_surface* surface) -> wlr_scene_tree* {
+ return static_cast<wlr_scene_tree*>(surface_store().get(surface));
+ }
+
+ // ---- Kernel event catalogue ----
+ // Subscribe through these to react to kernel-owned input/output. Each
+ // returns an Event/Filter you subscribe to with YOUR extension id (the
+ // Host supplies it; see subscribe helpers below). The kernel emits; you
+ // never emit on these.
+ [[nodiscard]] virtual auto on_output_added() -> Event<const OutputEvent&>& = 0;
+ [[nodiscard]] virtual auto on_output_removed() -> Event<const OutputEvent&>& = 0;
+ [[nodiscard]] virtual auto on_pointer_motion() -> Event<const PointerMotionEvent&>& = 0;
+ [[nodiscard]] virtual auto on_pointer_button() -> Event<const PointerButtonEvent&>& = 0;
+ [[nodiscard]] virtual auto on_pointer_axis() -> Event<const PointerAxisEvent&>& = 0;
+ // Pointer frame: emitted once per input frame after motion/button/axis;
+ // the extension routing pointer events to clients calls
+ // wlr_seat_pointer_notify_frame here. No payload.
+ [[nodiscard]] virtual auto on_pointer_frame() -> Event<>& = 0;
+ [[nodiscard]] virtual auto on_touch_down() -> Event<const TouchDownEvent&>& = 0;
+ [[nodiscard]] virtual auto on_touch_motion() -> Event<const TouchMotionEvent&>& = 0;
+ [[nodiscard]] virtual auto on_touch_up() -> Event<const TouchUpEvent&>& = 0;
+ [[nodiscard]] virtual auto on_touch_cancel() -> Event<const TouchCancelEvent&>& = 0;
+ // Touch frame: emitted once per touch input frame; route via
+ // wlr_seat_touch_notify_frame. No payload.
+ [[nodiscard]] virtual auto on_touch_frame() -> Event<>& = 0;
+
+ // Key handling is a FILTER, not an Event: links run in order and may set
+ // KeyEvent::handled to CONSUME the key before the kernel forwards it to the
+ // focused client. The kernel applies this filter on every key, then
+ // forwards to the client only if the result is not handled. (This is the
+ // consume-or-pass channel the brief calls for; ext-keybindings lives here.)
+ [[nodiscard]] virtual auto key_filter() -> Filter<KeyEvent>& = 0;
+
+ // ---- Subscription helpers (tag with THIS extension's id) ----
+ // Prefer these over event.subscribe(id, cb): the Host injects your id so a
+ // throwing callback disables YOU and not someone else.
+ template <typename... Args, typename Fn>
+ [[nodiscard]] auto subscribe(Event<Args...>& ev, Fn&& fn) -> Subscription {
+ return ev.subscribe(extension_id(), std::forward<Fn>(fn));
+ }
+ template <typename T, typename Fn>
+ [[nodiscard]] auto subscribe(Filter<T>& flt, Fn&& fn) -> Subscription {
+ return flt.subscribe(extension_id(), std::forward<Fn>(fn));
+ }
+
+ // ---- Exporting your own hooks (cross-extension coupling) ----
+ // To expose a hook to OTHER extensions, declare an Event/Filter as a member
+ // of your extension and adopt() it in activate(): adoption binds it to the
+ // kernel's error-isolation registry (so a throwing subscriber on your hook
+ // disables the SUBSCRIBER's extension, not yours) and tracks it for purge.
+ // The hook is pinned (non-movable); keep it as a stable member and expose a
+ // reference via your public header or a service. Subscribers pass their own
+ // id (their Host::subscribe injects it). Adopt before anyone subscribes.
+ void adopt(detail::HookBase& hook) { adopt_hook(hook); }
+
+ // ---- Services (typed single-responder) ----
+ // Register `impl` as the provider of interface I for the session. `impl`
+ // is a NON-OWNING borrow: it must outlive every consumer — store it as a
+ // member of your extension (so it dies last, in reverse-activation order).
+ // Returns false if a provider for I was already registered (it is still
+ // replaced; the bool lets a provider detect a collision). No strings.
+ template <typename I>
+ auto provide_service(I* impl) -> bool {
+ return register_service(std::type_index(typeid(I)), static_cast<void*>(impl));
+ }
+ // Fetch the provider of interface I, or nullptr if none is registered yet.
+ // Do not cache across activation: fetch in activate() AFTER the provider's
+ // extension (declare it in your depends_on so it activates first).
+ template <typename I>
+ [[nodiscard]] auto service() -> I* {
+ return static_cast<I*>(lookup_service(std::type_index(typeid(I))));
+ }
+
+protected:
+ Host() = default;
+
+ // The id the kernel assigned to THIS extension; subscriptions are tagged
+ // with it for error isolation. Kernel-internal Hosts return
+ // kernel_extension_id.
+ [[nodiscard]] virtual auto extension_id() const -> ExtensionId = 0;
+
+ // Non-template service core (the templates above are thin type-key shims).
+ virtual auto register_service(std::type_index type, void* impl) -> bool = 0;
+ [[nodiscard]] virtual auto lookup_service(std::type_index type) -> void* = 0;
+
+ // Bind an extension-exported hook to the isolation registry (see adopt()).
+ virtual void adopt_hook(detail::HookBase& hook) = 0;
+
+ // The kernel-owned, session-wide surface->tree association store (shared by
+ // ALL extensions; the host_surface/scene_tree_for shims above route here).
+ [[nodiscard]] virtual auto surface_store() -> detail::PointerAssoc& = 0;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/listener.hpp b/packages/kernel/include/unbox/kernel/listener.hpp
new file mode 100644
index 0000000..4115f66
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/listener.hpp
@@ -0,0 +1,72 @@
+#pragma once
+
+#include <unbox/kernel/wlr.hpp>
+
+#include <functional>
+#include <utility>
+
+// RAII wrapper over a wl_listener. An extension that does its own wlroots glue
+// (ext-xdg-shell binds xdg_shell signals; ext-layer-shell binds layer-shell
+// signals) MUST NOT hold a bare wl_listener across the boundary
+// (listener-lifetime.md, AGENTS.md): a wl_listener still linked into a signal
+// after its owner dies corrupts the signal's list on the next emit. This type
+// makes the link's lifetime equal to the wrapper's: connect() subscribes,
+// destruction (or disconnect()) unsubscribes. Hold it as a member; it dies
+// with you.
+//
+// Borrows received in a handler (the void* data, any wlroots pointer reached
+// through it) are valid ONLY for that call — never store them.
+//
+// A handler MAY destroy its own Listener — the destroy-event pattern: a
+// handler erases the entity that owns the Listener (and the Listener with it).
+// This is safe because the thunk touches NOTHING after handler_() returns. But
+// the handler itself must not touch its captures after triggering its own
+// destruction: make the erase/delete the handler's LAST action. (The bus's
+// Subscription formalizes this for hook callbacks; this type is for raw
+// wlroots signals an extension must bind directly.)
+//
+// Single wl_event_loop thread; no internal synchronization.
+
+namespace unbox::kernel {
+
+class Listener {
+public:
+ Listener() {
+ node_.self = this;
+ node_.listener.notify = &Listener::thunk;
+ wl_list_init(&node_.listener.link);
+ }
+ ~Listener() { disconnect(); }
+ Listener(const Listener&) = delete;
+ auto operator=(const Listener&) -> Listener& = delete;
+
+ // Subscribe to `signal`; `handler` receives the signal's data pointer
+ // (cast it to the documented event type). Re-connecting first disconnects.
+ void connect(wl_signal& signal, std::function<void(void*)> handler) {
+ disconnect();
+ handler_ = std::move(handler);
+ wl_signal_add(&signal, &node_.listener);
+ }
+
+ // Unsubscribe. Idempotent; called automatically on destruction.
+ void disconnect() {
+ wl_list_remove(&node_.listener.link);
+ wl_list_init(&node_.listener.link);
+ }
+
+private:
+ struct Node {
+ wl_listener listener; // MUST stay first: thunk casts wl_listener* -> Node*
+ Listener* self;
+ };
+
+ static void thunk(wl_listener* listener, void* data) {
+ auto* node = reinterpret_cast<Node*>(listener);
+ node->self->handler_(data);
+ }
+
+ Node node_{};
+ std::function<void(void*)> handler_;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp
index 8389999..f8963e1 100644
--- a/packages/kernel/include/unbox/kernel/server.hpp
+++ b/packages/kernel/include/unbox/kernel/server.hpp
@@ -1,14 +1,19 @@
#pragma once
+#include <unbox/kernel/extension.hpp>
+
#include <memory>
#include <string>
-// The compositor core. Slice-2 shape: a faithful tinywl port (plus touch)
-// living wholly inside the kernel; slice 4 splits shell policy out into
-// extensions behind typed contracts.
+// The compositor core. Slice-4 shape: the kernel names NO concrete feature and
+// boots featureless. It owns the generic plumbing (compositor, subcompositor,
+// data-device, output/scene glue, cursor + seat, the kernel-internal ui
+// spike) and the extension host + typed bus. ALL shell policy (xdg-shell
+// toplevels, focus, cycling, interactive move/resize, keybindings) lives in
+// extensions installed via install() before run().
//
// Calling context: single wl_event_loop thread. run() blocks; terminate()
-// is safe to call from event handlers (e.g. a keybinding).
+// is safe to call from event handlers (e.g. a keybinding extension).
namespace unbox::kernel {
@@ -41,7 +46,25 @@ public:
// The WAYLAND_DISPLAY name clients connect with (e.g. "wayland-1").
[[nodiscard]] auto socket_name() const -> std::string;
- // Runs the event loop until terminate() (default binding: Alt+Escape).
+ // Install an extension (ownership transfer). Call after create(), before
+ // activate_extensions()/run(). Order of install() calls does NOT determine
+ // activation order — that is computed topologically from each Manifest's
+ // depends_on at activate_extensions() time. Installing two extensions with
+ // the same Manifest id throws std::runtime_error here (duplicate id).
+ void install(std::unique_ptr<Extension> extension);
+
+ // Activate every installed extension exactly once, in topological order by
+ // Manifest depends_on (ties broken by tier then install order). Throws
+ // std::runtime_error on a missing dependency, a dependency cycle, or a
+ // duplicate id; the offending ids are named in what(). An exception thrown
+ // by an extension's own activate() propagates out (activation failure is
+ // fatal — a core extension that cannot start is a broken session, not an
+ // isolated one). Idempotent: a second call is a no-op. run() calls this
+ // first if it was not called already.
+ void activate_extensions();
+
+ // Runs the event loop until terminate(). Calls activate_extensions() first
+ // if not already done.
void run();
// One event-loop turn (≤ timeout_ms); for tests and embedders.
diff --git a/packages/kernel/include/unbox/kernel/surface_registry.hpp b/packages/kernel/include/unbox/kernel/surface_registry.hpp
new file mode 100644
index 0000000..ac895d2
--- /dev/null
+++ b/packages/kernel/include/unbox/kernel/surface_registry.hpp
@@ -0,0 +1,134 @@
+#pragma once
+
+#include <unbox/kernel/wlr.hpp>
+
+#include <cstdint>
+#include <unordered_map>
+
+// Typed surface -> scene-tree association (the kernel-owned replacement for the
+// old untyped `wlr_surface.data` / `wlr_xdg_surface.data` cross-extension
+// convention). That `void*` agreement had zero compile/link enforcement and
+// violated "cross-extension coupling anchors to exported TYPED symbols"
+// (AGENTS.md); this is the typed contract.
+//
+// Ownership model: an extension that HOSTS a surface in a scene tree (xdg
+// toplevels in ext-xdg-shell, layer surfaces in ext-layer-shell, future
+// xwayland) registers the association via Host::host_surface() and keeps the
+// returned move-only RAII handle as a member. The MAP lives in the kernel; the
+// kernel stores an opaque association and names no feature — it does not know
+// what a toplevel or a layer surface is. Any other extension resolves a
+// surface to its host tree via Host::scene_tree_for() — e.g. ext-xdg-shell
+// resolving a popup's parent surface to the parent's scene tree.
+//
+// This does NOT forbid a unit using `.data` PRIVATELY within itself (e.g.
+// stashing its own per-node back-pointer) — only the CROSS-UNIT agreement
+// dies. Cross-unit, route through this typed contract.
+//
+// Single wl_event_loop thread throughout; no internal locking.
+
+namespace unbox::kernel {
+
+namespace detail {
+
+// Pure pointer-keyed association with token-defended RAII semantics. No
+// wlroots semantics — it stores pointer identities, which is exactly why it is
+// unit-testable with no compositor running. The kernel embeds ONE instance and
+// the typed SurfaceRegistration / Host methods are thin shims over it.
+//
+// Double-register of the same key REPLACES the value and bumps the key's token;
+// the previous holder's handle therefore becomes a no-op on destruction (its
+// token no longer matches), so it can never tear down the newer association.
+class PointerAssoc {
+public:
+ using Token = std::uint64_t;
+ static constexpr Token invalid_token = 0;
+
+ struct Slot {
+ void* value;
+ Token token;
+ };
+
+ // Returns the token now owning `key`. Replaces any existing mapping.
+ auto set(void* key, void* value) -> Token {
+ const Token token = ++next_token_;
+ map_[key] = Slot{value, token};
+ return token;
+ }
+
+ // Erase `key` ONLY if `token` is still the owning token (defends against a
+ // stale handle unregistering a newer registration of the same key).
+ void clear(void* key, Token token) noexcept {
+ auto it = map_.find(key);
+ if (it != map_.end() && it->second.token == token) {
+ map_.erase(it);
+ }
+ }
+
+ [[nodiscard]] auto get(void* key) const -> void* {
+ auto it = map_.find(key);
+ return it == map_.end() ? nullptr : it->second.value;
+ }
+
+ [[nodiscard]] auto size() const -> std::size_t { return map_.size(); }
+
+private:
+ std::unordered_map<void*, Slot> map_;
+ Token next_token_ = 0;
+};
+
+} // namespace detail
+
+// Move-only RAII handle for one surface->tree association. Destruction (or
+// reset()/move-out) unregisters — but only if this handle still owns the
+// surface's current mapping (re-hosting the same surface elsewhere supersedes
+// this handle, whose destruction then becomes a safe no-op). Hold it as a
+// member of the hosting entity so the association's lifetime equals the node's.
+class SurfaceRegistration {
+public:
+ SurfaceRegistration() = default;
+ SurfaceRegistration(detail::PointerAssoc* store, void* key, detail::PointerAssoc::Token token)
+ : store_(store), key_(key), token_(token) {}
+
+ SurfaceRegistration(SurfaceRegistration&& other) noexcept
+ : store_(other.store_), key_(other.key_), token_(other.token_) {
+ other.store_ = nullptr;
+ other.key_ = nullptr;
+ other.token_ = detail::PointerAssoc::invalid_token;
+ }
+ auto operator=(SurfaceRegistration&& other) noexcept -> SurfaceRegistration& {
+ if (this != &other) {
+ reset();
+ store_ = other.store_;
+ key_ = other.key_;
+ token_ = other.token_;
+ other.store_ = nullptr;
+ other.key_ = nullptr;
+ other.token_ = detail::PointerAssoc::invalid_token;
+ }
+ return *this;
+ }
+ SurfaceRegistration(const SurfaceRegistration&) = delete;
+ auto operator=(const SurfaceRegistration&) -> SurfaceRegistration& = delete;
+
+ ~SurfaceRegistration() { reset(); }
+
+ // Unregister early. Idempotent. No-op if this handle was superseded by a
+ // later registration of the same surface.
+ void reset() noexcept {
+ if (store_ != nullptr) {
+ store_->clear(key_, token_);
+ store_ = nullptr;
+ key_ = nullptr;
+ token_ = detail::PointerAssoc::invalid_token;
+ }
+ }
+
+ [[nodiscard]] auto active() const noexcept -> bool { return store_ != nullptr; }
+
+private:
+ detail::PointerAssoc* store_ = nullptr;
+ void* key_ = nullptr;
+ detail::PointerAssoc::Token token_ = detail::PointerAssoc::invalid_token;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/include/unbox/kernel/wlr.hpp b/packages/kernel/include/unbox/kernel/wlr.hpp
index 51dd6fb..496d737 100644
--- a/packages/kernel/include/unbox/kernel/wlr.hpp
+++ b/packages/kernel/include/unbox/kernel/wlr.hpp
@@ -17,6 +17,15 @@ extern "C" {
// helpers become plain `inline`, which C++ ODR-merges safely. Keep the
// #define scoped to EXACTLY these includes.
#define static
+// wlr-layer-shell (and its generated protocol header) name a struct field /
+// request argument `namespace` — a valid C identifier but a C++ KEYWORD, which
+// `extern "C"` does NOT exempt (it changes linkage, not lexing). Rename it to
+// `_namespace` across the wlr includes, same scoped-macro discipline as
+// `static` above (the Hyprland-proven fix). CONSEQUENCE that leaks through this
+// public wrapper: code reaching wlr_layer_surface_v1::namespace must spell it
+// `->_namespace`. Re-audit for further C++-keyword identifiers when adding
+// protocol/wlr headers (only `namespace` collides in the current set).
+#define namespace _namespace
#include <wlr/backend.h>
#include <wlr/render/allocator.h>
// Slice-3 spike (RMLUi -> wlr_scene bridge): EGL/dmabuf, the GLES2 renderer's
@@ -37,6 +46,18 @@ extern "C" {
#include <wlr/types/wlr_data_device.h>
#include <wlr/types/wlr_input_device.h>
#include <wlr/types/wlr_keyboard.h>
+// wlr-layer-shell for ext-layer-shell. This header #includes the generated
+// "wlr-layer-shell-unstable-v1-protocol.h" — produced by the wayland-scanner
+// custom_target in packages/kernel/meson.build from the vendored
+// protocol/wlr-layer-shell-unstable-v1.xml and propagated (include path +
+// build order) through kernel_dep. Static-blanking re-audit: neither this
+// wlroots header nor the generated protocol header contains a `static` storage
+// keyword on a header-inline function with a function-local static (the
+// generated header has only extern interface declarations; no array-param
+// `[static N]` either), so the surrounding `#define static` is inert across
+// both. The scene helper wlr_scene_layer_surface_v1 lives in wlr_scene.h
+// (included below) — no second include needed.
+#include <wlr/types/wlr_layer_shell_v1.h>
#include <wlr/types/wlr_output.h>
#include <wlr/types/wlr_output_layout.h>
#include <wlr/types/wlr_pointer.h>
@@ -49,5 +70,6 @@ extern "C" {
#include <wlr/types/wlr_xdg_shell.h>
#include <wlr/util/log.h>
#include <wlr/version.h>
+#undef namespace
#undef static
}
diff --git a/packages/kernel/kernel.md b/packages/kernel/kernel.md
index 24a37c9..6ed9b5e 100644
--- a/packages/kernel/kernel.md
+++ b/packages/kernel/kernel.md
@@ -1,45 +1,84 @@
# kernel — package notes
-Slice-2 state: a working tinywl port (+ touch, which tinywl lacks) wholly
-inside the kernel: backend/output/scene glue, xdg-shell toplevels + popups,
-click/tap-to-focus, Alt-drag-free interactive move/resize (client-requested
-only), keyboard/pointer/touch via one wlr_cursor path. Slice-2 keybindings:
-Alt+Escape = terminate, Alt+F1 = cycle. Slice 4 splits shell policy out
-into extensions.
+Slice-4 state: the kernel **names no concrete feature** and boots
+featureless. It owns generic plumbing (compositor/subcompositor/data-device,
+output+scene glue, cursor + xcursor-mgr + seat, the kernel-private ui spike)
+plus the **extension host + typed bus**. ALL shell policy (xdg-shell
+toplevels/popups, focus, alt-cycle, terminate, interactive move/resize,
+keybindings) was EXTRACTED — `src/toplevel.cpp` is deleted; ext-xdg-shell /
+ext-layer-shell recreate it from the contract alone.
-Slice-3 state: THE SPIKE landed on **Plan A** (RMLUi -> dmabuf-backed
-wlr_buffer -> wlr_scene_buffer), with Plan B (FBO + glReadPixels into a
-data-ptr wlr_buffer) as a verified runtime fallback. All bridge state is
-private in `src/ui_spike.{hpp,cpp}` + the adapted GLES3 renderer
-`src/rmlui_renderer_gl3.{h,cpp}`. Public surface delta: `Options::ui_spike`
-+ `Server::ui_spike_frame_count()` (both TEMPORARY, replaced by the real ui
-substrate in slice 4+). Driven from the output frame handler; renders only
-when `ui_spike != nullptr`. Host-bin does NOT yet wire the Option.
+Public contract (the ABI): `hooks.hpp` (typed `Event<Args...>` /
+`Filter<T>` + RAII `Subscription`), `extension.hpp` (`Tier`, `Manifest`,
+`Extension`), `host.hpp` (`Host` facade: borrows + event catalogue + scene
+layers + services + typed surface→tree association), `listener.hpp` (the RAII
+`wl_listener` wrapper, now public), `surface_registry.hpp` (`SurfaceRegistration`
+RAII handle + the pure `detail::PointerAssoc` core), `server.hpp` (`install` +
+`activate_extensions`).
+
+Side-effect graph (who emits / who routes):
+- The kernel EMITS typed Events for its glue (output add/remove; pointer
+ motion/button/axis/frame; touch down/motion/up/cancel/frame) and applies
+ `key_filter` to every key. It moves the cursor, runs seat-capability and
+ seat-protocol glue (request_set_cursor/selection, focus_change default
+ cursor), and forwards a key to the focused client ONLY if no filter link
+ set `handled`. It routes NOTHING else to client surfaces and makes NO
+ focus decision — extensions do that via the bus + the seat borrow.
+- `Server::install()` transfers ownership; `activate_extensions()` (called
+ by `run()`, or earlier by host-bin/tests) topo-sorts by `Manifest
+ depends_on` (ties: tier then install order), then calls each `activate`.
+ Missing dep / cycle / duplicate id = `std::runtime_error` at startup. An
+ `activate()` throw is FATAL (propagates) — a core ext that can't start is
+ a broken session, not an isolated one. RUNTIME callback throws ARE
+ isolated (see below).
+- Scene z-bands live in `Impl::scene_layers[]` (SceneLayer order, created
+ over `scene->tree` background→overlay so stacking is correct). The ui
+ spike now sits in the `overlay` band. Extensions attach via
+ `Host::scene_layer()`.
Gotchas the headers can't express:
+- **Error isolation = deferred purge.** A hook callback that throws is
+ caught at the bus boundary; `Server::Impl` (a `detail::DisableSink`) marks
+ the owning extension disabled and `purge()`s its subscriptions from EVERY
+ registered hook (`all_hooks`). Purge during a live dispatch only
+ tombstones (`dead=true`); physical erase happens when that hook's dispatch
+ depth returns to 0 (`compact_if_idle`). So disabling an extension from
+ inside its own callback, and an ext subscribed to multiple hooks, are both
+ safe. Hooks are PINNED (Subscriptions hold a raw `HookBase*`): never move
+ an `Event`/`Filter`; hold them as stable members.
+- **Extensions are destroyed FIRST in `shutdown()`**, reverse of install
+ order, so their RAII members (Subscriptions, Listeners, scene nodes)
+ release while the wlr objects they borrow are still alive. Then the spike,
+ then clients, then server-level Listeners, then wlr objects.
- **`wlr.hpp` blanks `static` around the wlr includes.** wlroots headers
use C99 array-parameter syntax (`float color[static 4]`), invalid in
C++. With `static` blanked, `static inline` helpers become `inline`
- (ODR-merged, safe). Cost: a function-local `static` inside a header
- inline would silently lose persistence — none exist in our include set;
- re-audit when ADDING includes to the wrapper.
+ (ODR-merged, safe). Re-audit when ADDING includes to the wrapper.
- **RMLUi is kernel-private.** `rmlui_dep` is deliberately absent from
- `kernel_dep` propagation (see meson.build): extensions contribute RML
- documents + data bindings via the ui substrate, never RMLUi API calls.
- Do not "fix" a missing-RMLUi-header error downstream by propagating it.
-- **Shutdown order is load-bearing** (`Impl::shutdown()`): destroy clients
- → disconnect ALL server-level Listeners → scene/cursor/allocator/
- renderer/backend/display. A Listener outliving the wlr object owning its
+ `kernel_dep` propagation: extensions contribute RML documents + data
+ bindings via the (future) ui substrate, never RMLUi API calls. Do not
+ "fix" a missing-RMLUi-header error downstream by propagating it.
+- **Server-level Listener disconnect order is load-bearing**
+ (`Impl::shutdown()`): a Listener outliving the wlr object owning its
signal is a use-after-free (`wl_list_remove` touches neighbor links).
- Entity-level Listeners are exempt: their destroy events fire (and erase
- the entities) during `wl_display_destroy_clients` / backend destroy.
+ Entity-level Listeners (Output/Keyboard/TouchDevice) are exempt: their
+ destroy events fire during `wl_display_destroy_clients` / backend destroy.
- **A Listener handler may destroy its own Listener** (the destroy-event
- pattern) but the erase/delete must be the handler's LAST action — see
- listener.hpp. The slice-4 bus formalizes this.
-- **Touch points record their down-surface's layout origin** to derive
- surface-local motion coords; a surface moving mid-touch (interactive
- grab) skews them. Acceptable until slice 5's input routing.
+ pattern) but the erase/delete must be the handler's LAST action.
+- **No cross-unit `wlr_surface.data`.** The surface→scene-tree association is
+ a typed kernel contract (`Host::host_surface`/`scene_tree_for`, backed by
+ `Server::Impl::surface_assoc`). The map is kernel-owned but the VALUE tree is
+ an extension's; the returned tree is a borrow valid only while the hosting
+ extension's `SurfaceRegistration` lives. Re-hosting a surface supersedes the
+ old handle (token defense), so a stale handle never tears down the new
+ mapping. Private intra-unit `.data` use is still fine; cross-unit must route
+ through the contract.
+- **Pointer button & axis are NOT forwarded by the kernel** (it only moves the
+ cursor and emits `ev_pointer_button`/`ev_pointer_axis`). The pointer-routing
+ extension forwards them via `wlr_seat_pointer_notify_button/_axis`, same as
+ enter/motion/frame — not notifying during a grab is the suppression mechanism.
+ (The old "kernel forwards button/axis" doc comment was a verified lie; fixed.)
- Everything runs on the single `wl_event_loop` thread.
Slice-3 spike gotchas (EGL/dmabuf — read before touching `ui_spike.cpp`):
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index 2c7b971..0786a60 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -8,6 +8,45 @@ kernel_inc = include_directories('include')
egl_dep = dependency('egl')
glesv2_dep = dependency('glesv2')
+# ---- Wayland protocol codegen (the repo's FIRST; this is the template) -------
+#
+# wlroots' <wlr/types/wlr_layer_shell_v1.h> #includes the generated
+# "wlr-layer-shell-unstable-v1-protocol.h" (a wlr-protocols extra not shipped
+# by the system wayland-protocols package, so the XML is vendored read-only in
+# the repo root protocol/). Because wlr.hpp is a PUBLIC header that pulls that
+# wlroots header in, EVERY consumer of kernel_dep must compile against the
+# generated header AND must not build before codegen runs — see the
+# declare_dependency(sources: ...) propagation below, which carries both the
+# include path and the build-ordering edge.
+#
+# wayland-scanner is located via its own pkg-config variable (the canonical
+# pattern wlroots/sway/labwc use), run on the BUILD machine (native: true).
+wayland_scanner_dep = dependency('wayland-scanner', native: true)
+wayland_scanner = find_program(
+ wayland_scanner_dep.get_variable('wayland_scanner'),
+ native: true,
+)
+
+# Repo-root protocol/ holds the vendored XML (read-only; provisioned by the
+# orchestrator). meson.project_source_root() keeps this robust regardless of
+# this subdir's depth.
+wlr_layer_shell_xml = files(
+ meson.project_source_root() / 'protocol' / 'wlr-layer-shell-unstable-v1.xml',
+)
+
+# server-header only: the OUTPUT NAME must be exactly the string wlroots
+# #includes. The interface symbols (wlr_layer_shell_v1.h's deps) are already
+# exported by libwlroots, so the private-code glue is NOT needed here — proven
+# by a clean link below (no undefined wl_*_interface references). Add a
+# matching 'private-code' custom_target only if a future protocol's symbols are
+# not provided by a linked library.
+wlr_layer_shell_protocol_h = custom_target(
+ 'wlr-layer-shell-unstable-v1-protocol.h',
+ input: wlr_layer_shell_xml,
+ output: 'wlr-layer-shell-unstable-v1-protocol.h',
+ command: [wayland_scanner, 'server-header', '@INPUT@', '@OUTPUT@'],
+)
+
# UNBOX_RMLUI_GLES selects the native GLES 3.2 path in the adapted RmlUi GL3
# renderer (src/rmlui_renderer_gl3.cpp) without poisoning the TU with the
# __ANDROID__ builtin. Scoped to this library only.
@@ -15,10 +54,12 @@ kernel_lib = static_library(
'unbox-kernel',
'src/kernel.cpp',
'src/server.cpp',
- 'src/toplevel.cpp',
'src/input.cpp',
'src/ui_spike.cpp',
'src/rmlui_renderer_gl3.cpp',
+ # Listing the generated header as a source forces codegen before any kernel
+ # TU compiles and puts its build dir on this lib's include path.
+ wlr_layer_shell_protocol_h,
cpp_args: ['-DUNBOX_RMLUI_GLES'],
include_directories: kernel_inc,
dependencies: [wlroots_dep, wayland_server_dep, xkbcommon_dep, rmlui_dep,
@@ -28,8 +69,13 @@ kernel_lib = static_library(
# What consumers get. wlroots/wayland propagate because wlr.hpp is a public
# header; RMLUi does NOT — it is kernel-private (the ui substrate owns it,
# extensions contribute RML documents + data bindings, never RMLUi calls).
+# The generated protocol header rides in `sources:` so every consumer of
+# kernel_dep gets BOTH the include path for it AND a build-ordering edge to the
+# codegen custom_target — a consumer can never compile wlr.hpp before the
+# header exists.
kernel_dep = declare_dependency(
link_with: kernel_lib,
+ sources: [wlr_layer_shell_protocol_h],
include_directories: kernel_inc,
dependencies: [wlroots_dep, wayland_server_dep],
)
diff --git a/packages/kernel/src/input.cpp b/packages/kernel/src/input.cpp
index 7095154..b6c4c0c 100644
--- a/packages/kernel/src/input.cpp
+++ b/packages/kernel/src/input.cpp
@@ -4,6 +4,14 @@
namespace unbox::kernel {
+// Slice 4: the kernel owns generic input PLUMBING only. It moves the cursor,
+// tracks seat capabilities, handles the seat's own protocol requests, runs the
+// key_filter (consume-or-pass), and EMITS typed events. It routes NOTHING to
+// client surfaces and makes NO focus decision — ext-xdg-shell (and others) do
+// that from the bus + borrows. The only client forward the kernel still does
+// is keyboard key passthrough AFTER the filter, because the seat already holds
+// the focus an extension set via wlr_seat_keyboard_notify_enter.
+
// ---- Device hotplug -----------------------------------------------------------
void Server::Impl::handle_new_input(wlr_input_device* device) {
@@ -44,7 +52,7 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
keyboard->keyboard = wlr_kb;
keyboards.push_back(std::move(owned));
- // Default XKB keymap (layout "us" etc.); unbox.toml takes over later.
+ // Default XKB keymap; per-config keymap is a later slice.
xkb_context* context = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
xkb_keymap* keymap = xkb_keymap_new_from_names(context, nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
wlr_keyboard_set_keymap(wlr_kb, keymap);
@@ -53,7 +61,6 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
wlr_keyboard_set_repeat_info(wlr_kb, 25, 600);
keyboard->modifiers.connect(wlr_kb->events.modifiers, [this, keyboard](void*) {
- // The seat exposes one logical keyboard; swap the active device in.
wlr_seat_set_keyboard(seat, keyboard->keyboard);
wlr_seat_keyboard_notify_modifiers(seat, &keyboard->keyboard->modifiers);
});
@@ -65,16 +72,39 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
const xkb_keysym_t* syms = nullptr;
const int nsyms =
xkb_state_key_get_syms(keyboard->keyboard->xkb_state, keycode, &syms);
-
- bool handled = false;
const std::uint32_t modifiers = wlr_keyboard_get_modifiers(keyboard->keyboard);
- if ((modifiers & WLR_MODIFIER_ALT) != 0 &&
- event->state == WL_KEYBOARD_KEY_STATE_PRESSED) {
- for (int i = 0; i < nsyms; i++) {
- handled = handle_keybinding(syms[i]);
- }
- }
- if (!handled) {
+ const bool pressed = event->state == WL_KEYBOARD_KEY_STATE_PRESSED;
+
+ // 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
+ // client forward for this key.
+ bool consumed = false;
+ for (int i = 0; i < nsyms; ++i) {
+ KeyEvent ke{};
+ ke.keysym = syms[i];
+ ke.keycode = event->keycode;
+ ke.modifiers = modifiers;
+ ke.pressed = pressed;
+ ke.time_msec = event->time_msec;
+ ke.handled = false;
+ ke = key_filter.apply(ke);
+ consumed = consumed || ke.handled;
+ }
+ // With no resolved syms (e.g. modifier-only) the filter still runs once
+ // so extensions can observe raw modifier keys if they wish.
+ if (nsyms == 0) {
+ KeyEvent ke{};
+ ke.keysym = XKB_KEY_NoSymbol;
+ ke.keycode = event->keycode;
+ ke.modifiers = modifiers;
+ ke.pressed = pressed;
+ ke.time_msec = event->time_msec;
+ ke = key_filter.apply(ke);
+ consumed = consumed || ke.handled;
+ }
+
+ if (!consumed) {
wlr_seat_set_keyboard(seat, keyboard->keyboard);
wlr_seat_keyboard_notify_key(seat, event->time_msec, event->keycode, event->state);
}
@@ -89,8 +119,8 @@ void Server::Impl::new_keyboard(wlr_input_device* device) {
}
void Server::Impl::new_pointer(wlr_input_device* device) {
- // All pointer handling is proxied through wlr_cursor; per-device
- // libinput config (acceleration, tap…) is a later slice.
+ // All pointer handling is proxied through wlr_cursor; per-device libinput
+ // config is a later slice.
wlr_cursor_attach_input_device(cursor, device);
}
@@ -107,225 +137,104 @@ void Server::Impl::new_touch(wlr_input_device* device) {
touch_devices.remove_if([touch](const auto& owned) { return owned.get() == touch; });
});
- // wlr_cursor aggregates touch devices too and emits layout-mapped
- // touch_* events (handled below).
wlr_cursor_attach_input_device(cursor, device);
}
-// ---- Compositor keybindings ------------------------------------------------------
-
-auto Server::Impl::handle_keybinding(std::uint32_t keysym) -> bool {
- // Slice-2 placeholder bindings (Alt held), replaced by the keybinding
- // filter chain in slice 5.
- switch (keysym) {
- case XKB_KEY_Escape:
- wl_display_terminate(display);
- return true;
- case XKB_KEY_F1:
- if (mapped_toplevels.size() >= 2) {
- focus_toplevel(mapped_toplevels.back());
- }
- return true;
- default:
- return false;
- }
-}
+// ---- Pointer (via wlr_cursor): move cursor + emit, route nothing ------------
-// ---- Pointer (via wlr_cursor) ------------------------------------------------------
-
-void Server::Impl::process_cursor_move() {
- wlr_scene_node_set_position(&grabbed_toplevel->scene_tree->node,
- static_cast<int>(cursor->x - grab_x),
- static_cast<int>(cursor->y - grab_y));
-}
-
-void Server::Impl::process_cursor_resize() {
- // Resizing moves the node when dragging top/left edges; the client is
- // asked for the new size (it commits a matching buffer later).
- Toplevel* toplevel = grabbed_toplevel;
- const double border_x = cursor->x - grab_x;
- const double border_y = cursor->y - grab_y;
- int new_left = grab_geobox.x;
- int new_right = grab_geobox.x + grab_geobox.width;
- int new_top = grab_geobox.y;
- int new_bottom = grab_geobox.y + grab_geobox.height;
-
- if ((resize_edges & WLR_EDGE_TOP) != 0) {
- new_top = static_cast<int>(border_y);
- if (new_top >= new_bottom) {
- new_top = new_bottom - 1;
- }
- } else if ((resize_edges & WLR_EDGE_BOTTOM) != 0) {
- new_bottom = static_cast<int>(border_y);
- if (new_bottom <= new_top) {
- new_bottom = new_top + 1;
- }
- }
- if ((resize_edges & WLR_EDGE_LEFT) != 0) {
- new_left = static_cast<int>(border_x);
- if (new_left >= new_right) {
- new_left = new_right - 1;
- }
- } else if ((resize_edges & WLR_EDGE_RIGHT) != 0) {
- new_right = static_cast<int>(border_x);
- if (new_right <= new_left) {
- new_right = new_left + 1;
- }
- }
-
- wlr_box* geo_box = &toplevel->xdg_toplevel->base->geometry;
- wlr_scene_node_set_position(&toplevel->scene_tree->node, new_left - geo_box->x,
- new_top - geo_box->y);
- wlr_xdg_toplevel_set_size(toplevel->xdg_toplevel, new_right - new_left,
- new_bottom - new_top);
-}
-
-void Server::Impl::process_cursor_motion(std::uint32_t time_msec) {
- if (cursor_mode == CursorMode::Move) {
- process_cursor_move();
- return;
- }
- if (cursor_mode == CursorMode::Resize) {
- process_cursor_resize();
- return;
- }
-
- // Slice-3 spike input proof (NOT the slice-5 routing contract): if the
- // cursor is over the spike node, forward surface-local coords to RmlUi so
- // the document's button reacts to hover. Crude and private.
+void Server::Impl::emit_pointer_motion(std::uint32_t time_msec) {
+ // Slice-3 spike input proof (kernel-internal; NOT a contract): forward
+ // surface-local coords over the spike node so its button hovers.
if (ui_spike != nullptr) {
if (wlr_scene_node* spike = ui_spike->node()) {
int nx = 0;
int ny = 0;
wlr_scene_node_coords(spike, &nx, &ny);
- const double sx = cursor->x - nx;
- const double sy = cursor->y - ny;
- ui_spike->on_pointer_motion(sx, sy);
+ ui_spike->on_pointer_motion(cursor->x - nx, cursor->y - ny);
}
}
- double sx = 0;
- double sy = 0;
- wlr_surface* surface = nullptr;
- Toplevel* toplevel = toplevel_at(cursor->x, cursor->y, &surface, &sx, &sy);
- if (toplevel == nullptr) {
- // Over no toplevel: the compositor draws its own default cursor.
- wlr_cursor_set_xcursor(cursor, cursor_mgr, "default");
- }
- if (surface != nullptr) {
- // Enter gives the surface pointer focus; wlroots dedupes repeats.
- wlr_seat_pointer_notify_enter(seat, surface, sx, sy);
- wlr_seat_pointer_notify_motion(seat, time_msec, sx, sy);
- } else {
- wlr_seat_pointer_clear_focus(seat);
- }
+ const PointerMotionEvent ev{cursor->x, cursor->y, time_msec};
+ ev_pointer_motion.emit(ev);
}
void Server::Impl::attach_cursor_handlers() {
cursor_motion.connect(cursor->events.motion, [this](void* data) {
const auto* event = static_cast<wlr_pointer_motion_event*>(data);
wlr_cursor_move(cursor, &event->pointer->base, event->delta_x, event->delta_y);
- process_cursor_motion(event->time_msec);
+ emit_pointer_motion(event->time_msec);
});
cursor_motion_absolute.connect(cursor->events.motion_absolute, [this](void* data) {
const auto* event = static_cast<wlr_pointer_motion_absolute_event*>(data);
wlr_cursor_warp_absolute(cursor, &event->pointer->base, event->x, event->y);
- process_cursor_motion(event->time_msec);
+ emit_pointer_motion(event->time_msec);
});
cursor_button.connect(cursor->events.button, [this](void* data) {
const auto* event = static_cast<wlr_pointer_button_event*>(data);
- wlr_seat_pointer_notify_button(seat, event->time_msec, event->button, event->state);
+ const bool pressed = event->state == WL_POINTER_BUTTON_STATE_PRESSED;
- // Slice-3 spike input proof: forward clicks over the spike node to
- // RmlUi so its button reacts to press/release. Crude and private.
+ // Slice-3 spike input proof (kernel-internal): forward clicks over the
+ // spike node so its button reacts.
if (ui_spike != nullptr) {
if (wlr_scene_node* spike = ui_spike->node()) {
- int nx = 0;
- int ny = 0;
- wlr_scene_node_coords(spike, &nx, &ny);
if (wlr_scene_node_at(spike, cursor->x, cursor->y, nullptr, nullptr) != nullptr) {
- ui_spike->on_pointer_button(event->state ==
- WL_POINTER_BUTTON_STATE_PRESSED);
+ ui_spike->on_pointer_button(pressed);
}
}
}
- if (event->state == WL_POINTER_BUTTON_STATE_RELEASED) {
- reset_cursor_mode();
- } else {
- // Click-to-focus.
- double sx = 0;
- double sy = 0;
- wlr_surface* surface = nullptr;
- focus_toplevel(toplevel_at(cursor->x, cursor->y, &surface, &sx, &sy));
- }
+ const PointerButtonEvent ev{event->button, pressed, cursor->x, cursor->y,
+ event->time_msec};
+ ev_pointer_button.emit(ev);
});
cursor_axis.connect(cursor->events.axis, [this](void* data) {
const auto* event = static_cast<wlr_pointer_axis_event*>(data);
- wlr_seat_pointer_notify_axis(seat, event->time_msec, event->orientation, event->delta,
- event->delta_discrete, event->source,
- event->relative_direction);
- });
- cursor_frame.connect(cursor->events.frame, [this](void*) {
- wlr_seat_pointer_notify_frame(seat);
+ const PointerAxisEvent ev{event->orientation, event->delta, event->delta_discrete,
+ event->source, event->time_msec};
+ ev_pointer_axis.emit(ev);
});
+ cursor_frame.connect(cursor->events.frame, [this](void*) { ev_pointer_frame.emit(); });
- // ---- Touch (tinywl doesn't have this; the CF-AX3 does) ----
+ // ---- Touch (tinywl lacks this; the CF-AX3 has it). Convert to layout
+ // coords + emit; extensions route to surfaces. ----
cursor_touch_down.connect(cursor->events.touch_down, [this](void* data) {
const auto* event = static_cast<wlr_touch_down_event*>(data);
double lx = 0;
double ly = 0;
wlr_cursor_absolute_to_layout_coords(cursor, &event->touch->base, event->x, event->y,
&lx, &ly);
- double sx = 0;
- double sy = 0;
- wlr_surface* surface = nullptr;
- Toplevel* toplevel = toplevel_at(lx, ly, &surface, &sx, &sy);
- if (toplevel != nullptr) {
- focus_toplevel(toplevel); // tap raises + focuses
- }
- if (surface != nullptr) {
- touch_points.insert_or_assign(event->touch_id,
- TouchPoint{surface, lx - sx, ly - sy});
- wlr_seat_touch_notify_down(seat, surface, event->time_msec, event->touch_id, sx, sy);
- }
+ const TouchDownEvent ev{event->touch_id, lx, ly, event->time_msec};
+ ev_touch_down.emit(ev);
});
cursor_touch_motion.connect(cursor->events.touch_motion, [this](void* data) {
const auto* event = static_cast<wlr_touch_motion_event*>(data);
- auto it = touch_points.find(event->touch_id);
- if (it == touch_points.end()) {
- return; // down landed on no surface; nothing is grabbed
- }
double lx = 0;
double ly = 0;
wlr_cursor_absolute_to_layout_coords(cursor, &event->touch->base, event->x, event->y,
&lx, &ly);
- wlr_seat_touch_notify_motion(seat, event->time_msec, event->touch_id,
- lx - it->second.origin_x, ly - it->second.origin_y);
+ const TouchMotionEvent ev{event->touch_id, lx, ly, event->time_msec};
+ ev_touch_motion.emit(ev);
});
cursor_touch_up.connect(cursor->events.touch_up, [this](void* data) {
const auto* event = static_cast<wlr_touch_up_event*>(data);
- touch_points.erase(event->touch_id);
- wlr_seat_touch_notify_up(seat, event->time_msec, event->touch_id);
+ const TouchUpEvent ev{event->touch_id, event->time_msec};
+ ev_touch_up.emit(ev);
});
cursor_touch_cancel.connect(cursor->events.touch_cancel, [this](void* data) {
const auto* event = static_cast<wlr_touch_cancel_event*>(data);
- if (wlr_touch_point* point = wlr_seat_touch_get_point(seat, event->touch_id)) {
- wlr_seat_touch_notify_cancel(seat, point->client);
- }
- touch_points.erase(event->touch_id);
+ const TouchCancelEvent ev{event->touch_id};
+ ev_touch_cancel.emit(ev);
});
cursor_touch_frame.connect(cursor->events.touch_frame, [this](void*) {
- wlr_seat_touch_notify_frame(seat);
+ ev_touch_frame.emit();
});
}
-// ---- Seat requests -------------------------------------------------------------
+// ---- Seat requests (generic protocol glue) ----------------------------------
void Server::Impl::attach_seat_handlers() {
seat_request_cursor.connect(seat->events.request_set_cursor, [this](void* data) {
const auto* event = static_cast<wlr_seat_pointer_request_set_cursor_event*>(data);
- // Any client may send this; honor only the pointer-focused one.
if (seat->pointer_state.focused_client == event->seat_client) {
wlr_cursor_set_surface(cursor, event->surface, event->hotspot_x, event->hotspot_y);
}
diff --git a/packages/kernel/src/listener.hpp b/packages/kernel/src/listener.hpp
index 5b39f74..8b36b3f 100644
--- a/packages/kernel/src/listener.hpp
+++ b/packages/kernel/src/listener.hpp
@@ -1,56 +1,7 @@
#pragma once
-#include <unbox/kernel/wlr.hpp>
-
-#include <functional>
-#include <utility>
-
-namespace unbox::kernel {
-
-// RAII wl_listener: connect() subscribes, destruction/disconnect()
-// unsubscribes. PRIVATE slice-2 helper — the public typed subscription
-// handle arrives with the bus in slice 4 (.unbox/rules/listener-lifetime.md).
-//
-// A handler MAY destroy its own Listener (the destroy-event pattern: a
-// handler erases its owning entity from a container). This is safe because
-// thunk() touches nothing after handler_() returns — but the handler itself
-// must not touch captures after triggering its own destruction; make the
-// erase/delete its LAST action.
-class Listener {
-public:
- Listener() {
- node_.self = this;
- node_.listener.notify = &Listener::thunk;
- wl_list_init(&node_.listener.link);
- }
- ~Listener() { disconnect(); }
- Listener(const Listener&) = delete;
- auto operator=(const Listener&) -> Listener& = delete;
-
- void connect(wl_signal& signal, std::function<void(void*)> handler) {
- disconnect();
- handler_ = std::move(handler);
- wl_signal_add(&signal, &node_.listener);
- }
-
- void disconnect() {
- wl_list_remove(&node_.listener.link);
- wl_list_init(&node_.listener.link);
- }
-
-private:
- struct Node {
- wl_listener listener; // MUST stay first: thunk casts wl_listener* -> Node*
- Listener* self;
- };
-
- static void thunk(wl_listener* listener, void* data) {
- auto* node = reinterpret_cast<Node*>(listener);
- node->self->handler_(data);
- }
-
- Node node_{};
- std::function<void(void*)> handler_;
-};
-
-} // namespace unbox::kernel
+// The RAII wl_listener wrapper is now a PUBLIC contract type (slice 4):
+// extensions do their own wlroots glue and must never hold a bare wl_listener.
+// The kernel's own glue uses the very same type. This shim keeps the existing
+// `#include "listener.hpp"` sites in src/ pointing at the public definition.
+#include <unbox/kernel/listener.hpp>
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index df24f1f..e3308de 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -3,6 +3,8 @@
#include <ctime>
#include <stdexcept>
#include <unistd.h>
+#include <unordered_map>
+#include <utility>
namespace unbox::kernel {
@@ -42,7 +44,16 @@ auto Server::socket_name() const -> std::string {
return impl_->socket;
}
+void Server::install(std::unique_ptr<Extension> extension) {
+ impl_->install(std::move(extension));
+}
+
+void Server::activate_extensions() {
+ impl_->activate_extensions();
+}
+
void Server::run() {
+ impl_->activate_extensions();
wlr_log(WLR_INFO, "unbox running on WAYLAND_DISPLAY=%s", impl_->socket.c_str());
wl_display_run(impl_->display);
}
@@ -68,6 +79,27 @@ auto Server::ui_spike_orientation() const -> int {
// ---- Impl lifecycle --------------------------------------------------------
+void Server::Impl::register_hook(detail::HookBase& hook) {
+ hook.set_sink(this);
+ all_hooks.push_back(&hook);
+}
+
+void Server::Impl::disable(ExtensionId who) noexcept {
+ // Error isolation: a callback owned by `who` threw. Mark the extension dead
+ // and purge its subscriptions from every hook. Safe mid-dispatch — each
+ // hook tombstones now and compacts when its dispatch unwinds.
+ for (ExtensionSlot& slot : extensions) {
+ if (slot.id == who && !slot.disabled) {
+ slot.disabled = true;
+ wlr_log(WLR_ERROR, "extension '%s' disabled: a hook callback threw",
+ slot.extension->manifest().id.c_str());
+ }
+ }
+ for (detail::HookBase* hook : all_hooks) {
+ hook->purge(who);
+ }
+}
+
void Server::Impl::init() {
wlr_log_init(WLR_INFO, nullptr);
@@ -91,13 +123,12 @@ void Server::Impl::init() {
scene_layout = require(wlr_scene_attach_output_layout(scene, output_layout),
"wlr_scene_output_layout");
- xdg_shell = require(wlr_xdg_shell_create(display, 3), "wlr_xdg_shell");
- new_xdg_toplevel.connect(xdg_shell->events.new_toplevel, [this](void* data) {
- handle_new_toplevel(static_cast<wlr_xdg_toplevel*>(data));
- });
- new_xdg_popup.connect(xdg_shell->events.new_popup, [this](void* data) {
- handle_new_popup(static_cast<wlr_xdg_popup*>(data));
- });
+ // Ordered z-bands. wlr_scene_tree_create appends as the top child of its
+ // parent, so creating background -> overlay yields exactly that stacking
+ // order (background lowest, overlay highest).
+ for (auto& layer : scene_layers) {
+ layer = require(wlr_scene_tree_create(&scene->tree), "wlr_scene_tree (layer)");
+ }
cursor = require(wlr_cursor_create(), "wlr_cursor");
wlr_cursor_attach_output_layout(cursor, output_layout);
@@ -110,6 +141,24 @@ void Server::Impl::init() {
seat = require(wlr_seat_create(display, "seat0"), "wlr_seat");
attach_seat_handlers();
+ // Register every kernel-emitted hook with the isolation registry.
+ for (detail::HookBase* hook : {
+ static_cast<detail::HookBase*>(&ev_output_added),
+ static_cast<detail::HookBase*>(&ev_output_removed),
+ static_cast<detail::HookBase*>(&ev_pointer_motion),
+ static_cast<detail::HookBase*>(&ev_pointer_button),
+ static_cast<detail::HookBase*>(&ev_pointer_axis),
+ static_cast<detail::HookBase*>(&ev_pointer_frame),
+ static_cast<detail::HookBase*>(&ev_touch_down),
+ static_cast<detail::HookBase*>(&ev_touch_motion),
+ static_cast<detail::HookBase*>(&ev_touch_up),
+ static_cast<detail::HookBase*>(&ev_touch_cancel),
+ static_cast<detail::HookBase*>(&ev_touch_frame),
+ static_cast<detail::HookBase*>(&key_filter),
+ }) {
+ all_hooks.push_back(hook); // sink already set via {this} constructor
+ }
+
const char* socket_cstr = wl_display_add_socket_auto(display);
if (socket_cstr == nullptr) {
throw std::runtime_error("failed to add a Wayland socket");
@@ -135,11 +184,96 @@ void Server::Impl::init() {
}
}
+// ---- Extension host --------------------------------------------------------
+
+void Server::Impl::install(std::unique_ptr<Extension> extension) {
+ if (extensions_activated) {
+ throw std::runtime_error("Server::install called after activate_extensions");
+ }
+ const std::string& id = extension->manifest().id;
+ for (const ExtensionSlot& slot : extensions) {
+ if (slot.extension->manifest().id == id) {
+ throw std::runtime_error("duplicate extension id: " + id);
+ }
+ }
+ ExtensionSlot slot;
+ // id 0 is the kernel; extensions start at 1.
+ slot.id = static_cast<ExtensionId>(extensions.size() + 1);
+ slot.host = std::make_unique<HostImpl>(this, slot.id);
+ slot.extension = std::move(extension);
+ extensions.push_back(std::move(slot));
+}
+
+void Server::Impl::activate_extensions() {
+ if (extensions_activated) {
+ return;
+ }
+ extensions_activated = true;
+
+ // Topological sort by Manifest depends_on. Index extensions by id; ties
+ // (no dependency relation) are broken by tier (core before standard) then
+ // install order — deterministic activation.
+ const std::size_t n = extensions.size();
+ std::unordered_map<std::string, std::size_t> by_id;
+ for (std::size_t i = 0; i < n; ++i) {
+ by_id.emplace(extensions[i].extension->manifest().id, i);
+ }
+
+ // Build adjacency (dep -> dependents) and indegree; validate deps exist.
+ std::vector<std::vector<std::size_t>> dependents(n);
+ std::vector<int> indegree(n, 0);
+ for (std::size_t i = 0; i < n; ++i) {
+ const Manifest& m = extensions[i].extension->manifest();
+ for (const std::string& dep : m.depends_on) {
+ auto it = by_id.find(dep);
+ if (it == by_id.end()) {
+ throw std::runtime_error("extension '" + m.id +
+ "' depends on missing extension '" + dep + "'");
+ }
+ dependents[it->second].push_back(i);
+ ++indegree[i];
+ }
+ }
+
+ // Kahn's algorithm with a deterministic tie-break: among ready nodes pick
+ // the lowest (tier, install-index). A linear scan is fine for the handful
+ // of extensions a session installs.
+ auto rank = [&](std::size_t i) {
+ return std::pair<int, std::size_t>(
+ static_cast<int>(extensions[i].extension->manifest().tier), i);
+ };
+ std::vector<bool> done(n, false);
+ std::vector<std::size_t> order;
+ order.reserve(n);
+ for (std::size_t step = 0; step < n; ++step) {
+ std::size_t pick = n;
+ for (std::size_t i = 0; i < n; ++i) {
+ if (!done[i] && indegree[i] == 0) {
+ if (pick == n || rank(i) < rank(pick)) {
+ pick = i;
+ }
+ }
+ }
+ if (pick == n) {
+ throw std::runtime_error("extension dependency cycle detected");
+ }
+ done[pick] = true;
+ order.push_back(pick);
+ for (std::size_t d : dependents[pick]) {
+ --indegree[d];
+ }
+ }
+
+ // Activate in topological order. An activate() throw is FATAL (not
+ // isolated): a core extension that cannot start is a broken session.
+ for (std::size_t i : order) {
+ ExtensionSlot& slot = extensions[i];
+ slot.extension->activate(*slot.host);
+ slot.activated = true;
+ }
+}
+
void Server::Impl::start_ui_spike() {
- // The bridge needs the wlr renderer's EGLDisplay to build its sibling
- // GLES 3.2 context. Only the gles2 renderer exposes one; under the
- // pixman renderer (e.g. headless CI) there is no GL path, so the spike
- // stays disabled — slice-2 behaviour is preserved.
if (!wlr_renderer_is_gles2(renderer)) {
wlr_log(WLR_INFO, "ui-spike: renderer is not gles2; spike disabled");
return;
@@ -150,24 +284,34 @@ void Server::Impl::start_ui_spike() {
return;
}
EGLDisplay display_egl = wlr_egl_get_display(egl);
- ui_spike = UiSpike::create(&scene->tree, display_egl, allocator, renderer);
+ // The spike sits in the overlay band so it composites above everything.
+ ui_spike = UiSpike::create(scene_layers[static_cast<std::size_t>(SceneLayer::overlay)],
+ display_egl, allocator, renderer);
}
void Server::Impl::shutdown() {
- // Slice-3 spike: tear down before scene/renderer/allocator die (it owns
- // a scene node, GL objects on a sibling context, and borrows the others).
+ // Destroy extensions FIRST, in reverse activation order: their RAII members
+ // (Subscriptions, Listeners, scene nodes) release while the wlr objects
+ // they borrow are still alive. Reverse of `extensions` install order is a
+ // safe superset of reverse-topological (a dependent installed later than
+ // its dependency dies first; if installed earlier, it still only borrows).
+ for (auto it = extensions.rbegin(); it != extensions.rend(); ++it) {
+ it->extension.reset();
+ it->host.reset();
+ }
+ extensions.clear();
+
+ // Slice-3 spike: tear down before scene/renderer/allocator die.
ui_spike.reset();
if (display != nullptr) {
- wl_display_destroy_clients(display); // fires toplevel/popup destroy events
+ wl_display_destroy_clients(display);
}
- // Server-level listeners must detach BEFORE the wlr objects owning their
- // signals die; a wl_listener outliving its signal is a use-after-free.
+ // Server-level listeners detach BEFORE the wlr objects owning their signals
+ // die; a wl_listener outliving its signal is a use-after-free.
new_output.disconnect();
new_input.disconnect();
- new_xdg_toplevel.disconnect();
- new_xdg_popup.disconnect();
cursor_motion.disconnect();
cursor_motion_absolute.disconnect();
cursor_button.disconnect();
@@ -203,7 +347,7 @@ void Server::Impl::shutdown() {
renderer = nullptr;
}
if (backend != nullptr) {
- wlr_backend_destroy(backend); // fires output + input-device destroy events
+ wlr_backend_destroy(backend);
backend = nullptr;
}
if (display != nullptr) {
@@ -233,12 +377,9 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
outputs.push_back(std::move(owned));
output->frame.connect(wlr_output->events.frame, [this, output](void*) {
- // Slice-3 spike: render the RMLUi document if dirty, before commit so
- // its damage is picked up this frame. Cheap no-op when disabled.
if (ui_spike != nullptr) {
ui_spike->tick();
}
-
wlr_scene_output* scene_output = wlr_scene_get_scene_output(scene, output->output);
wlr_scene_output_commit(scene_output, nullptr);
@@ -251,6 +392,8 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
wlr_output_commit_state(output->output, event->state);
});
output->destroy.connect(wlr_output->events.destroy, [this, output](void*) {
+ const OutputEvent ev{output->output};
+ ev_output_removed.emit(ev);
// Last action: destroys `output` (and these listeners with it).
outputs.remove_if([output](const auto& owned) { return owned.get() == output; });
});
@@ -260,6 +403,8 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
wlr_scene_output_layout_add_output(scene_layout, layout_output, scene_output);
wlr_log(WLR_INFO, "new output %s", wlr_output->name);
+ const OutputEvent ev{wlr_output};
+ ev_output_added.emit(ev);
}
} // namespace unbox::kernel
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index 2975537..c5615b2 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -1,28 +1,33 @@
#pragma once
+#include <unbox/kernel/host.hpp>
#include <unbox/kernel/server.hpp>
#include <unbox/kernel/wlr.hpp>
#include "listener.hpp"
#include "ui_spike.hpp"
+#include <array>
#include <cstdint>
#include <list>
#include <memory>
#include <string>
+#include <typeindex>
#include <unordered_map>
-
-// Private kernel state. Entity structs mirror tinywl's, with Listener
-// members replacing manual wl_list_remove bookkeeping (RAII unsubscribes).
-// Definitions are split: server.cpp (lifecycle + outputs), toplevel.cpp
-// (xdg-shell + focus + grabs), input.cpp (devices, cursor, touch, seat).
+#include <vector>
+
+// Private kernel state (slice 4). The kernel names no concrete feature: shell
+// policy (xdg-shell toplevels/popups, focus, cycling, interactive move/resize,
+// keybindings) was EXTRACTED to extensions. What remains is generic plumbing
+// plus the extension host + typed bus.
+//
+// Definitions split: server.cpp (lifecycle, outputs, host/bus/activation),
+// input.cpp (devices, cursor, touch, seat — now event-emitting, not routing).
+//
+// Everything runs on the single wl_event_loop thread.
namespace unbox::kernel {
-struct Toplevel;
-
-enum class CursorMode { Passthrough, Move, Resize };
-
struct Output {
Server::Impl* server = nullptr;
wlr_output* output = nullptr;
@@ -31,26 +36,6 @@ struct Output {
Listener destroy;
};
-struct Toplevel {
- Server::Impl* server = nullptr;
- wlr_xdg_toplevel* xdg_toplevel = nullptr;
- wlr_scene_tree* scene_tree = nullptr;
- Listener map;
- Listener unmap;
- Listener commit;
- Listener destroy;
- Listener request_move;
- Listener request_resize;
- Listener request_maximize;
- Listener request_fullscreen;
-};
-
-struct Popup {
- wlr_xdg_popup* xdg_popup = nullptr;
- Listener commit;
- Listener destroy;
-};
-
struct Keyboard {
Server::Impl* server = nullptr;
wlr_keyboard* keyboard = nullptr;
@@ -65,7 +50,20 @@ struct TouchDevice {
Listener destroy;
};
-struct Server::Impl {
+// ---- Extension host bookkeeping ---------------------------------------------
+
+class HostImpl; // per-extension Host facade (host.cpp)
+
+// One installed extension's kernel-side record.
+struct ExtensionSlot {
+ std::unique_ptr<Extension> extension;
+ std::unique_ptr<HostImpl> host;
+ ExtensionId id{};
+ bool activated = false;
+ bool disabled = false; // tripped when a callback threw (error isolation)
+};
+
+struct Server::Impl : detail::DisableSink {
Options options;
wl_display* display = nullptr;
@@ -75,55 +73,60 @@ struct Server::Impl {
wlr_scene* scene = nullptr;
wlr_scene_output_layout* scene_layout = nullptr;
wlr_output_layout* output_layout = nullptr;
- wlr_xdg_shell* xdg_shell = nullptr;
wlr_cursor* cursor = nullptr;
wlr_xcursor_manager* cursor_mgr = nullptr;
wlr_seat* seat = nullptr;
std::string socket;
- // Slice-3 spike: RMLUi -> wlr_scene bridge. Null unless options.ui_spike
- // and the bridge started; a started-but-disabled bridge is non-null but
- // reports Plan::Disabled. Owned here; torn down in shutdown() BEFORE the
- // scene/renderer/allocator die.
+ // Ordered scene-tree z-bands (SceneLayer order). Created once over
+ // scene->tree in stacking order so background < … < overlay. Extensions
+ // attach nodes via Host::scene_layer(); the kernel owns them.
+ std::array<wlr_scene_tree*, 5> scene_layers{};
+
+ // Slice-3 spike (kernel-internal; not a contract). Torn down in shutdown()
+ // BEFORE scene/renderer/allocator.
std::unique_ptr<UiSpike> ui_spike;
- // Ownership (RAII teardown); drained naturally during shutdown by the
- // destroy events wl_display_destroy_clients / backend destroy fire.
std::list<std::unique_ptr<Output>> outputs;
- std::unordered_map<wlr_xdg_toplevel*, std::unique_ptr<Toplevel>> toplevels;
- std::unordered_map<wlr_xdg_popup*, std::unique_ptr<Popup>> popups;
std::list<std::unique_ptr<Keyboard>> keyboards;
std::list<std::unique_ptr<TouchDevice>> touch_devices;
- // Focus order: front = focused. Contains MAPPED toplevels only.
- std::list<Toplevel*> mapped_toplevels;
-
- // Interactive move/resize grab state (one grab at a time).
- CursorMode cursor_mode = CursorMode::Passthrough;
- Toplevel* grabbed_toplevel = nullptr;
- double grab_x = 0.0;
- double grab_y = 0.0;
- wlr_box grab_geobox{};
- std::uint32_t resize_edges = 0;
-
- // Touch: Wayland implicitly grabs a touch point to the surface that
- // received down; we record the surface's layout origin at down time to
- // derive surface-local coords for motion. Assumes the surface doesn't
- // move mid-touch (true except during interactive grabs; slice 5 will
- // route input properly).
- struct TouchPoint {
- wlr_surface* surface = nullptr;
- double origin_x = 0.0;
- double origin_y = 0.0;
- };
- std::unordered_map<std::int32_t, TouchPoint> touch_points;
-
- // Server-level listeners (disconnected explicitly in shutdown() BEFORE
- // the wlr objects owning their signals are destroyed).
+ // ---- The typed bus: kernel-emitted hooks (host.hpp catalogue) ----
+ Event<const OutputEvent&> ev_output_added{this};
+ Event<const OutputEvent&> ev_output_removed{this};
+ Event<const PointerMotionEvent&> ev_pointer_motion{this};
+ Event<const PointerButtonEvent&> ev_pointer_button{this};
+ Event<const PointerAxisEvent&> ev_pointer_axis{this};
+ Event<> ev_pointer_frame{this};
+ Event<const TouchDownEvent&> ev_touch_down{this};
+ Event<const TouchMotionEvent&> ev_touch_motion{this};
+ Event<const TouchUpEvent&> ev_touch_up{this};
+ Event<const TouchCancelEvent&> ev_touch_cancel{this};
+ Event<> ev_touch_frame{this};
+ Filter<KeyEvent> key_filter{this};
+
+ // Every hook bound to the isolation registry (kernel hooks above + any
+ // extension-exported hooks adopted via Host::adopt). disable() purges an
+ // extension across ALL of them. Raw borrows; lifetimes are the hooks'.
+ std::vector<detail::HookBase*> all_hooks;
+
+ // Typed service registry (one provider per interface type; no strings).
+ std::unordered_map<std::type_index, void*> services;
+
+ // Kernel-owned surface -> scene-tree association (replaces the old untyped
+ // wlr_surface.data cross-extension convention). Shared by all extensions
+ // via Host::host_surface/scene_tree_for; the kernel just stores it.
+ detail::PointerAssoc surface_assoc;
+
+ // Installed extensions, in install order; activation order is computed
+ // topologically in activate_extensions().
+ std::vector<ExtensionSlot> extensions;
+ bool extensions_activated = false;
+
+ // Server-level listeners (disconnected in shutdown() BEFORE the wlr objects
+ // owning their signals die).
Listener new_output;
Listener new_input;
- Listener new_xdg_toplevel;
- Listener new_xdg_popup;
Listener cursor_motion;
Listener cursor_motion_absolute;
Listener cursor_button;
@@ -138,33 +141,96 @@ struct Server::Impl {
Listener seat_pointer_focus_change;
Listener seat_request_set_selection;
- // server.cpp
+ // detail::DisableSink
+ void disable(ExtensionId who) noexcept override;
+
+ // server.cpp — lifecycle + outputs
void init(); // throws std::runtime_error on any component failure
void shutdown();
void handle_new_output(wlr_output* output);
void start_ui_spike(); // slice-3 spike; never throws, may no-op
+ void register_hook(detail::HookBase& hook); // track for purge/disable
- // toplevel.cpp
- void handle_new_toplevel(wlr_xdg_toplevel* toplevel);
- void handle_new_popup(wlr_xdg_popup* popup);
- void focus_toplevel(Toplevel* toplevel);
- auto toplevel_at(double lx, double ly, wlr_surface** surface, double* sx, double* sy)
- -> Toplevel*;
- void begin_interactive(Toplevel* toplevel, CursorMode mode, std::uint32_t edges);
- void reset_cursor_mode();
+ // server.cpp — extension host
+ void install(std::unique_ptr<Extension> extension);
+ void activate_extensions();
- // input.cpp
+ // input.cpp — devices, cursor, touch, seat (event-emitting glue)
void handle_new_input(wlr_input_device* device);
void new_keyboard(wlr_input_device* device);
void new_pointer(wlr_input_device* device);
void new_touch(wlr_input_device* device);
void update_seat_capabilities();
- auto handle_keybinding(std::uint32_t keysym) -> bool;
void attach_cursor_handlers();
void attach_seat_handlers();
- void process_cursor_motion(std::uint32_t time_msec);
- void process_cursor_move();
- void process_cursor_resize();
+ void emit_pointer_motion(std::uint32_t time_msec);
+};
+
+// ---- Per-extension Host facade ----------------------------------------------
+
+class HostImpl final : public Host {
+public:
+ HostImpl(Server::Impl* server, ExtensionId id) : server_(server), id_(id) {}
+
+ auto display() -> wl_display* override { return server_->display; }
+ auto scene() -> wlr_scene* override { return server_->scene; }
+ auto seat() -> wlr_seat* override { return server_->seat; }
+ auto cursor() -> wlr_cursor* override { return server_->cursor; }
+ auto cursor_manager() -> wlr_xcursor_manager* override { return server_->cursor_mgr; }
+ auto output_layout() -> wlr_output_layout* override { return server_->output_layout; }
+ auto scene_layer(SceneLayer layer) -> wlr_scene_tree* override {
+ return server_->scene_layers[static_cast<std::size_t>(layer)];
+ }
+
+ auto on_output_added() -> Event<const OutputEvent&>& override {
+ return server_->ev_output_added;
+ }
+ auto on_output_removed() -> Event<const OutputEvent&>& override {
+ return server_->ev_output_removed;
+ }
+ auto on_pointer_motion() -> Event<const PointerMotionEvent&>& override {
+ return server_->ev_pointer_motion;
+ }
+ auto on_pointer_button() -> Event<const PointerButtonEvent&>& override {
+ return server_->ev_pointer_button;
+ }
+ auto on_pointer_axis() -> Event<const PointerAxisEvent&>& override {
+ return server_->ev_pointer_axis;
+ }
+ auto on_pointer_frame() -> Event<>& override { return server_->ev_pointer_frame; }
+ auto on_touch_down() -> Event<const TouchDownEvent&>& override {
+ return server_->ev_touch_down;
+ }
+ auto on_touch_motion() -> Event<const TouchMotionEvent&>& override {
+ return server_->ev_touch_motion;
+ }
+ auto on_touch_up() -> Event<const TouchUpEvent&>& override { return server_->ev_touch_up; }
+ auto on_touch_cancel() -> Event<const TouchCancelEvent&>& override {
+ return server_->ev_touch_cancel;
+ }
+ auto on_touch_frame() -> Event<>& override { return server_->ev_touch_frame; }
+ auto key_filter() -> Filter<KeyEvent>& override { return server_->key_filter; }
+
+protected:
+ auto extension_id() const -> ExtensionId override { return id_; }
+
+ auto register_service(std::type_index type, void* impl) -> bool override {
+ const bool fresh = server_->services.emplace(type, impl).second;
+ if (!fresh) {
+ server_->services[type] = impl; // replace
+ }
+ return fresh;
+ }
+ auto lookup_service(std::type_index type) -> void* override {
+ auto it = server_->services.find(type);
+ return it == server_->services.end() ? nullptr : it->second;
+ }
+ void adopt_hook(detail::HookBase& hook) override { server_->register_hook(hook); }
+ auto surface_store() -> detail::PointerAssoc& override { return server_->surface_assoc; }
+
+private:
+ Server::Impl* server_;
+ ExtensionId id_;
};
} // namespace unbox::kernel
diff --git a/packages/kernel/src/toplevel.cpp b/packages/kernel/src/toplevel.cpp
deleted file mode 100644
index 150870d..0000000
--- a/packages/kernel/src/toplevel.cpp
+++ /dev/null
@@ -1,190 +0,0 @@
-#include "server_impl.hpp"
-
-#include <algorithm>
-
-namespace unbox::kernel {
-
-// ---- Focus ------------------------------------------------------------------
-
-void Server::Impl::focus_toplevel(Toplevel* toplevel) {
- // Keyboard focus only (pointer focus follows the cursor).
- if (toplevel == nullptr) {
- return;
- }
- wlr_surface* surface = toplevel->xdg_toplevel->base->surface;
- wlr_surface* prev_surface = seat->keyboard_state.focused_surface;
- if (prev_surface == surface) {
- return;
- }
- if (prev_surface != nullptr) {
- // Deactivate the previously focused toplevel (client stops drawing
- // its focused decoration state, e.g. hides the caret).
- wlr_xdg_toplevel* prev = wlr_xdg_toplevel_try_from_wlr_surface(prev_surface);
- if (prev != nullptr) {
- wlr_xdg_toplevel_set_activated(prev, false);
- }
- }
-
- wlr_scene_node_raise_to_top(&toplevel->scene_tree->node);
- auto it = std::find(mapped_toplevels.begin(), mapped_toplevels.end(), toplevel);
- if (it != mapped_toplevels.end()) {
- mapped_toplevels.splice(mapped_toplevels.begin(), mapped_toplevels, it);
- }
- wlr_xdg_toplevel_set_activated(toplevel->xdg_toplevel, true);
-
- // The seat tracks the focused surface and routes key events to it.
- if (wlr_keyboard* keyboard = wlr_seat_get_keyboard(seat)) {
- wlr_seat_keyboard_notify_enter(seat, surface, keyboard->keycodes,
- keyboard->num_keycodes, &keyboard->modifiers);
- }
-}
-
-auto Server::Impl::toplevel_at(double lx, double ly, wlr_surface** surface, double* sx, double* sy)
- -> Toplevel* {
- // Topmost scene node at the given layout coords; we only care about
- // buffer nodes belonging to a surface tree rooted at a Toplevel.
- wlr_scene_node* node = wlr_scene_node_at(&scene->tree.node, lx, ly, sx, sy);
- if (node == nullptr || node->type != WLR_SCENE_NODE_BUFFER) {
- return nullptr;
- }
- wlr_scene_buffer* scene_buffer = wlr_scene_buffer_from_node(node);
- wlr_scene_surface* scene_surface = wlr_scene_surface_try_from_buffer(scene_buffer);
- if (scene_surface == nullptr) {
- return nullptr;
- }
- *surface = scene_surface->surface;
-
- // Walk up to the tree whose data field we set: the Toplevel root.
- wlr_scene_tree* tree = node->parent;
- while (tree != nullptr && tree->node.data == nullptr) {
- tree = tree->node.parent;
- }
- if (tree == nullptr) {
- return nullptr;
- }
- return static_cast<Toplevel*>(tree->node.data);
-}
-
-// ---- Interactive move/resize grabs -------------------------------------------
-
-void Server::Impl::reset_cursor_mode() {
- cursor_mode = CursorMode::Passthrough;
- grabbed_toplevel = nullptr;
-}
-
-void Server::Impl::begin_interactive(Toplevel* toplevel, CursorMode mode, std::uint32_t edges) {
- // The compositor consumes pointer events itself during a grab instead
- // of forwarding them. (tinywl note kept: a fuller compositor would
- // verify this against a recent button-press serial.)
- grabbed_toplevel = toplevel;
- cursor_mode = mode;
-
- if (mode == CursorMode::Move) {
- grab_x = cursor->x - toplevel->scene_tree->node.x;
- grab_y = cursor->y - toplevel->scene_tree->node.y;
- } else {
- wlr_box* geo_box = &toplevel->xdg_toplevel->base->geometry;
- const double border_x = (toplevel->scene_tree->node.x + geo_box->x) +
- ((edges & WLR_EDGE_RIGHT) != 0 ? geo_box->width : 0);
- const double border_y = (toplevel->scene_tree->node.y + geo_box->y) +
- ((edges & WLR_EDGE_BOTTOM) != 0 ? geo_box->height : 0);
- grab_x = cursor->x - border_x;
- grab_y = cursor->y - border_y;
-
- grab_geobox = *geo_box;
- grab_geobox.x += toplevel->scene_tree->node.x;
- grab_geobox.y += toplevel->scene_tree->node.y;
- resize_edges = edges;
- }
-}
-
-// ---- xdg-shell toplevels ------------------------------------------------------
-
-void Server::Impl::handle_new_toplevel(wlr_xdg_toplevel* xdg_toplevel) {
- auto owned = std::make_unique<Toplevel>();
- Toplevel* toplevel = owned.get();
- toplevel->server = this;
- toplevel->xdg_toplevel = xdg_toplevel;
- toplevel->scene_tree = wlr_scene_xdg_surface_create(&scene->tree, xdg_toplevel->base);
- toplevel->scene_tree->node.data = toplevel;
- // Popups look this up to find their parent's scene tree.
- xdg_toplevel->base->data = toplevel->scene_tree;
- toplevels.emplace(xdg_toplevel, std::move(owned));
-
- toplevel->map.connect(xdg_toplevel->base->surface->events.map, [this, toplevel](void*) {
- mapped_toplevels.push_front(toplevel);
- focus_toplevel(toplevel);
- wlr_log(WLR_INFO, "toplevel mapped: %s",
- toplevel->xdg_toplevel->title != nullptr ? toplevel->xdg_toplevel->title : "?");
- });
- toplevel->unmap.connect(xdg_toplevel->base->surface->events.unmap, [this, toplevel](void*) {
- if (toplevel == grabbed_toplevel) {
- reset_cursor_mode();
- }
- mapped_toplevels.remove(toplevel);
- });
- toplevel->commit.connect(xdg_toplevel->base->surface->events.commit, [toplevel](void*) {
- if (toplevel->xdg_toplevel->base->initial_commit) {
- // Reply to the initial commit with a 0x0 configure: the client
- // picks its own dimensions.
- wlr_xdg_toplevel_set_size(toplevel->xdg_toplevel, 0, 0);
- }
- });
- toplevel->destroy.connect(xdg_toplevel->events.destroy, [this, toplevel](void*) {
- // Last action: destroys `toplevel` (and these listeners with it).
- toplevels.erase(toplevel->xdg_toplevel);
- });
-
- toplevel->request_move.connect(xdg_toplevel->events.request_move, [this, toplevel](void*) {
- begin_interactive(toplevel, CursorMode::Move, 0);
- });
- toplevel->request_resize.connect(
- xdg_toplevel->events.request_resize, [this, toplevel](void* data) {
- const auto* event = static_cast<wlr_xdg_toplevel_resize_event*>(data);
- begin_interactive(toplevel, CursorMode::Resize, event->edges);
- });
- toplevel->request_maximize.connect(
- xdg_toplevel->events.request_maximize, [toplevel](void*) {
- // Unsupported, but xdg-shell demands a configure reply.
- if (toplevel->xdg_toplevel->base->initialized) {
- wlr_xdg_surface_schedule_configure(toplevel->xdg_toplevel->base);
- }
- });
- toplevel->request_fullscreen.connect(
- xdg_toplevel->events.request_fullscreen, [toplevel](void*) {
- if (toplevel->xdg_toplevel->base->initialized) {
- wlr_xdg_surface_schedule_configure(toplevel->xdg_toplevel->base);
- }
- });
-}
-
-// ---- xdg-shell popups ----------------------------------------------------------
-
-void Server::Impl::handle_new_popup(wlr_xdg_popup* xdg_popup) {
- auto owned = std::make_unique<Popup>();
- Popup* popup = owned.get();
- popup->xdg_popup = xdg_popup;
- popups.emplace(xdg_popup, std::move(owned));
-
- // Parent's scene tree was stashed in base->data when it was created
- // (toplevel or ancestor popup).
- wlr_xdg_surface* parent = wlr_xdg_surface_try_from_wlr_surface(xdg_popup->parent);
- if (parent != nullptr && parent->data != nullptr) {
- auto* parent_tree = static_cast<wlr_scene_tree*>(parent->data);
- xdg_popup->base->data = wlr_scene_xdg_surface_create(parent_tree, xdg_popup->base);
- }
-
- popup->commit.connect(xdg_popup->base->surface->events.commit, [popup](void*) {
- if (popup->xdg_popup->base->initial_commit) {
- // A fuller compositor would also unconstrain the popup to keep
- // it on-screen here.
- wlr_xdg_surface_schedule_configure(popup->xdg_popup->base);
- }
- });
- popup->destroy.connect(xdg_popup->events.destroy, [this, popup](void*) {
- // Last action: destroys `popup` (and these listeners with it).
- popups.erase(popup->xdg_popup);
- });
-}
-
-} // namespace unbox::kernel
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 6f8ce9b..e5b4a64 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -1,10 +1,18 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+#include <unbox/kernel/extension.hpp>
+#include <unbox/kernel/hooks.hpp>
+#include <unbox/kernel/host.hpp>
#include <unbox/kernel/kernel.hpp>
#include <unbox/kernel/server.hpp>
+#include <unbox/kernel/surface_registry.hpp>
#include <cstdlib>
+#include <memory>
+#include <stdexcept>
+#include <string>
+#include <vector>
TEST_CASE("kernel compiles against and links wlroots + libwayland-server") {
CHECK(unbox::kernel::link_probe());
@@ -99,3 +107,378 @@ TEST_CASE("ui spike submits an upright (non-flipped) buffer") {
unsetenv("UNBOX_UI_SPIKE_FORCE_SHM");
}
+
+// ============================================================================
+// 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.
+// ============================================================================
+
+namespace {
+
+using unbox::kernel::detail::DisableSink;
+using unbox::kernel::detail::HookBase;
+using unbox::kernel::Event;
+using unbox::kernel::ExtensionId;
+using unbox::kernel::Filter;
+using unbox::kernel::Subscription;
+
+// Mirrors Server::Impl's isolation behavior at pure-core scale: on disable(),
+// purge the offending extension from every registered hook. Records who got
+// disabled so tests can assert isolation hit the RIGHT extension.
+struct TestRegistry final : DisableSink {
+ std::vector<HookBase*> hooks;
+ std::vector<ExtensionId> disabled;
+
+ void track(HookBase& h) {
+ h.set_sink(this);
+ hooks.push_back(&h);
+ }
+ void disable(ExtensionId who) noexcept override {
+ disabled.push_back(who);
+ for (HookBase* h : hooks) {
+ h->purge(who);
+ }
+ }
+};
+
+constexpr ExtensionId ext_a{1};
+constexpr ExtensionId ext_b{2};
+constexpr ExtensionId ext_c{3};
+
+} // namespace
+
+TEST_CASE("Event fans out to all listeners in subscription order") {
+ Event<int> ev;
+ std::vector<int> log;
+ auto s1 = ev.subscribe(ext_a, [&](int v) { log.push_back(v + 10); });
+ auto s2 = ev.subscribe(ext_b, [&](int v) { log.push_back(v + 20); });
+ auto s3 = ev.subscribe(ext_c, [&](int v) { log.push_back(v + 30); });
+
+ ev.emit(1);
+ CHECK(log == std::vector<int>{11, 21, 31});
+}
+
+TEST_CASE("Subscription RAII unsubscribes on destruction") {
+ Event<int> ev;
+ int hits = 0;
+ auto outer = ev.subscribe(ext_a, [&](int) { ++hits; });
+ {
+ auto inner = ev.subscribe(ext_b, [&](int) { ++hits; });
+ ev.emit(0);
+ CHECK(hits == 2);
+ }
+ // inner dropped: only outer remains.
+ ev.emit(0);
+ CHECK(hits == 3);
+
+ // Explicit reset() also unsubscribes.
+ outer.reset();
+ CHECK(!outer.active());
+ ev.emit(0);
+ CHECK(hits == 3);
+}
+
+TEST_CASE("Subscription is move-only and the moved-from handle is inert") {
+ Event<int> ev;
+ int hits = 0;
+ Subscription s = ev.subscribe(ext_a, [&](int) { ++hits; });
+ Subscription moved = std::move(s);
+ CHECK(moved.active());
+ CHECK(!s.active());
+ ev.emit(0);
+ CHECK(hits == 1);
+ s.reset(); // no-op on moved-from
+ ev.emit(0);
+ CHECK(hits == 2);
+}
+
+TEST_CASE("a listener may unsubscribe ITSELF during dispatch (deferred removal)") {
+ Event<int> ev;
+ int a = 0;
+ int c = 0;
+ std::unique_ptr<Subscription> self;
+ auto sa = ev.subscribe(ext_a, [&](int) { ++a; });
+ auto sb = ev.subscribe(ext_b, [&](int) { self->reset(); }); // drop self mid-dispatch
+ auto sc = ev.subscribe(ext_c, [&](int) { ++c; });
+ self = std::make_unique<Subscription>(std::move(sb));
+
+ ev.emit(0);
+ // a and c still fired this round despite b removing itself.
+ CHECK(a == 1);
+ CHECK(c == 1);
+
+ ev.emit(0); // b gone now
+ CHECK(a == 2);
+ CHECK(c == 2);
+}
+
+TEST_CASE("re-entrant emit is safe") {
+ Event<int> ev;
+ int inner = 0;
+ bool reentered = false;
+ auto s = ev.subscribe(ext_a, [&](int v) {
+ if (!reentered && v == 1) {
+ reentered = true;
+ ev.emit(2); // re-enter
+ }
+ ++inner;
+ });
+ ev.emit(1);
+ CHECK(inner == 2); // outer (v=1) and inner (v=2)
+}
+
+TEST_CASE("Filter threads the value through links in order") {
+ Filter<int> flt;
+ auto s1 = flt.subscribe(ext_a, [](int v) { return v + 1; });
+ auto s2 = flt.subscribe(ext_b, [](int v) { return v * 10; });
+ // (((5)+1)*10) = 60
+ CHECK(flt.apply(5) == 60);
+}
+
+TEST_CASE("Filter with no links returns the value unchanged") {
+ Filter<int> flt;
+ CHECK(flt.apply(42) == 42);
+}
+
+TEST_CASE("error isolation: a throwing listener disables only its extension") {
+ TestRegistry reg;
+ Event<int> ev{&reg};
+ reg.track(ev);
+
+ std::vector<std::string> log;
+ auto sa = ev.subscribe(ext_a, [&](int) { log.emplace_back("a"); });
+ auto sb = ev.subscribe(ext_b, [&](int) {
+ log.emplace_back("b-throw");
+ throw std::runtime_error("boom");
+ });
+ auto sc = ev.subscribe(ext_c, [&](int) { log.emplace_back("c"); });
+
+ ev.emit(0);
+ // All three ran THIS emit (isolation doesn't abort the in-flight fan-out);
+ // b was disabled.
+ CHECK(log == std::vector<std::string>{"a", "b-throw", "c"});
+ CHECK(reg.disabled == std::vector<ExtensionId>{ext_b});
+
+ log.clear();
+ ev.emit(0);
+ // b's subscription was purged; a and c remain.
+ CHECK(log == std::vector<std::string>{"a", "c"});
+}
+
+TEST_CASE("error isolation: a throwing filter link is skipped and chain continues") {
+ TestRegistry reg;
+ Filter<int> flt{&reg};
+ reg.track(flt);
+
+ auto s1 = flt.subscribe(ext_a, [](int v) { return v + 1; });
+ auto s2 = flt.subscribe(ext_b, [](int) -> int { throw std::runtime_error("boom"); });
+ auto s3 = flt.subscribe(ext_c, [](int v) { return v * 10; });
+
+ // a: 0->1, b throws (skipped, value stays 1), c: 1->10.
+ CHECK(flt.apply(0) == 10);
+ CHECK(reg.disabled == std::vector<ExtensionId>{ext_b});
+
+ // b purged: a then c.
+ CHECK(flt.apply(0) == 10);
+}
+
+TEST_CASE("disabling an extension purges it across MULTIPLE hooks") {
+ TestRegistry reg;
+ Event<int> ev1{&reg};
+ Event<int> ev2{&reg};
+ reg.track(ev1);
+ reg.track(ev2);
+
+ int ev2_hits = 0;
+ // ext_b subscribes to BOTH hooks; throwing on ev1 must drop its ev2 sub too.
+ auto a1 = ev1.subscribe(ext_a, [](int) {});
+ auto b1 = ev1.subscribe(ext_b, [](int) { throw std::runtime_error("boom"); });
+ auto b2 = ev2.subscribe(ext_b, [&](int) { ++ev2_hits; });
+
+ ev1.emit(0); // disables ext_b everywhere
+ ev2.emit(0); // ext_b's ev2 listener must NOT fire
+ CHECK(ev2_hits == 0);
+ CHECK(reg.disabled == std::vector<ExtensionId>{ext_b});
+}
+
+// ============================================================================
+// Extension host: install + topological activation (no wlroots input needed).
+// ============================================================================
+
+namespace {
+
+// Records activation order into a shared log so tests can assert topo order.
+class RecordingExtension : public unbox::kernel::Extension {
+public:
+ RecordingExtension(unbox::kernel::Manifest m, std::vector<std::string>* log)
+ : manifest_(std::move(m)), log_(log) {}
+ auto manifest() const -> const unbox::kernel::Manifest& override { return manifest_; }
+ void activate(unbox::kernel::Host&) override { log_->push_back(manifest_.id); }
+
+private:
+ unbox::kernel::Manifest manifest_;
+ std::vector<std::string>* log_;
+};
+
+auto make_headless_server() -> std::unique_ptr<unbox::kernel::Server> {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+ return unbox::kernel::Server::create({});
+}
+
+using unbox::kernel::Manifest;
+using unbox::kernel::Tier;
+
+} // namespace
+
+TEST_CASE("activation respects depends_on topological order") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+
+ // Install in an order that does NOT match the dependency order.
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"taskbar", Tier::standard, {"xdg-shell"}}, &log));
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"xdg-shell", Tier::core, {}}, &log));
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"tiling", Tier::standard, {"xdg-shell", "taskbar"}}, &log));
+
+ server->activate_extensions();
+
+ // xdg-shell first (no deps, core tier), then taskbar, then tiling.
+ CHECK(log == std::vector<std::string>{"xdg-shell", "taskbar", "tiling"});
+}
+
+TEST_CASE("activate_extensions is idempotent") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"a", Tier::core, {}}, &log));
+ server->activate_extensions();
+ server->activate_extensions();
+ CHECK(log == std::vector<std::string>{"a"});
+}
+
+TEST_CASE("duplicate extension id is a startup error at install") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"dup", Tier::core, {}}, &log));
+ CHECK_THROWS_AS(server->install(std::make_unique<RecordingExtension>(
+ Manifest{"dup", Tier::standard, {}}, &log)),
+ std::runtime_error);
+}
+
+TEST_CASE("missing dependency is a startup error at activation") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(std::make_unique<RecordingExtension>(
+ Manifest{"needs-missing", Tier::core, {"nope"}}, &log));
+ CHECK_THROWS_AS(server->activate_extensions(), std::runtime_error);
+}
+
+TEST_CASE("dependency cycle is a startup error at activation") {
+ auto server = make_headless_server();
+ std::vector<std::string> log;
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"x", Tier::core, {"y"}}, &log));
+ server->install(
+ std::make_unique<RecordingExtension>(Manifest{"y", Tier::core, {"x"}}, &log));
+ CHECK_THROWS_AS(server->activate_extensions(), std::runtime_error);
+}
+
+TEST_CASE("featureless kernel: zero extensions boots, runs, shuts down clean") {
+ auto server = make_headless_server();
+ CHECK(!server->socket_name().empty());
+ server->activate_extensions(); // no-op with zero extensions
+ for (int i = 0; i < 3; ++i) {
+ CHECK(server->dispatch(10));
+ }
+}
+
+// ============================================================================
+// Typed surface->scene-tree association — PURE CORE (no wlroots). Keys/values
+// are pointer identities; dummy addresses stand in for wlr_surface*/scene_tree*.
+// ============================================================================
+
+namespace {
+
+using unbox::kernel::detail::PointerAssoc;
+using unbox::kernel::SurfaceRegistration;
+
+// Distinct, never-dereferenced sentinel addresses.
+int surf_a_obj = 0, surf_b_obj = 0, tree_1_obj = 0, tree_2_obj = 0;
+void* const surf_a = &surf_a_obj;
+void* const surf_b = &surf_b_obj;
+void* const tree_1 = &tree_1_obj;
+void* const tree_2 = &tree_2_obj;
+
+} // namespace
+
+TEST_CASE("surface assoc: register, lookup, unregister") {
+ PointerAssoc store;
+ CHECK(store.get(surf_a) == nullptr); // unregistered -> null
+
+ SurfaceRegistration reg(&store, surf_a, store.set(surf_a, tree_1));
+ CHECK(reg.active());
+ CHECK(store.get(surf_a) == tree_1);
+ CHECK(store.get(surf_b) == nullptr); // independent key still null
+
+ reg.reset();
+ CHECK(!reg.active());
+ CHECK(store.get(surf_a) == nullptr); // unregistered on reset
+ CHECK(store.size() == 0);
+}
+
+TEST_CASE("surface assoc: RAII handle unregisters on destruction") {
+ PointerAssoc store;
+ {
+ SurfaceRegistration reg(&store, surf_a, store.set(surf_a, tree_1));
+ CHECK(store.get(surf_a) == tree_1);
+ }
+ CHECK(store.get(surf_a) == nullptr);
+}
+
+TEST_CASE("surface assoc: move transfers ownership; moved-from is inert") {
+ PointerAssoc store;
+ SurfaceRegistration a(&store, surf_a, store.set(surf_a, tree_1));
+ SurfaceRegistration b = std::move(a);
+ CHECK(b.active());
+ CHECK(!a.active());
+ a.reset(); // no-op
+ CHECK(store.get(surf_a) == tree_1); // still registered (b owns it)
+ b.reset();
+ CHECK(store.get(surf_a) == nullptr);
+}
+
+TEST_CASE("surface assoc: double-register replaces value; stale handle is a no-op") {
+ PointerAssoc store;
+ // First registration of surf_a -> tree_1.
+ SurfaceRegistration first(&store, surf_a, store.set(surf_a, tree_1));
+ CHECK(store.get(surf_a) == tree_1);
+
+ // Re-host the SAME surface in tree_2: replaces the mapping, bumps token.
+ SurfaceRegistration second(&store, surf_a, store.set(surf_a, tree_2));
+ CHECK(store.get(surf_a) == tree_2);
+
+ // Destroying the SUPERSEDED first handle must NOT tear down the newer
+ // mapping (token defense).
+ first.reset();
+ CHECK(store.get(surf_a) == tree_2);
+
+ // The current owner still unregisters correctly.
+ second.reset();
+ CHECK(store.get(surf_a) == nullptr);
+}
+
+TEST_CASE("surface assoc: distinct keys are independent") {
+ PointerAssoc store;
+ SurfaceRegistration ra(&store, surf_a, store.set(surf_a, tree_1));
+ SurfaceRegistration rb(&store, surf_b, store.set(surf_b, tree_2));
+ CHECK(store.get(surf_a) == tree_1);
+ CHECK(store.get(surf_b) == tree_2);
+ CHECK(store.size() == 2);
+ ra.reset();
+ CHECK(store.get(surf_a) == nullptr);
+ CHECK(store.get(surf_b) == tree_2); // unaffected
+}