diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 22:44:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 22:44:16 +0900 |
| commit | c102a1b67a70149b6f9c9b2cfd8b31ceb52c09b7 (patch) | |
| tree | f6dea2875b939c0f661292d8bfa0d79a96fe67d7 /packages/kernel | |
| parent | 6949c3582ed1e480e70aabfcfa3a11b78007cc12 (diff) | |
| download | unbox-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.hpp | 69 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/hooks.hpp | 334 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/host.hpp | 274 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/listener.hpp | 72 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/server.hpp | 33 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/surface_registry.hpp | 134 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/wlr.hpp | 22 | ||||
| -rw-r--r-- | packages/kernel/kernel.md | 99 | ||||
| -rw-r--r-- | packages/kernel/meson.build | 48 | ||||
| -rw-r--r-- | packages/kernel/src/input.cpp | 241 | ||||
| -rw-r--r-- | packages/kernel/src/listener.hpp | 59 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 191 | ||||
| -rw-r--r-- | packages/kernel/src/server_impl.hpp | 226 | ||||
| -rw-r--r-- | packages/kernel/src/toplevel.cpp | 190 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 383 |
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.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.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{®}; + Event<int> ev2{®}; + 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 +} |
