From 2dadead39919daeff538ce7cb04096c006275814 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Wed, 26 Aug 2026 15:44:44 +1200 Subject: [PATCH] Add native Wayland shell host scaffolding Introduce a Linux-only C++20 `nebula-shell` host built with CMake, GTK4, gtk4-layer-shell, and WebKitGTK 6.0. Add a shared `Surface` abstraction and concrete Desktop/TopBar/Launcher surfaces with layer placement, sizing, transparency, and per-surface `?surface=` URL loading. Add `ShellApplication` startup flow that validates `NEBULA_SHELL_DEV_URL`, checks layer-shell support, creates/presents desktop and top bar, and keeps launcher created but hidden for future bridge-driven toggling. Update the native README to document architecture, development setup, dependencies, and current milestone scope. --- shells/desktop/native/CMakeLists.txt | 32 +++++ shells/desktop/native/README.md | 123 +++++++++++++----- shells/desktop/native/src/DesktopSurface.cpp | 17 +++ shells/desktop/native/src/DesktopSurface.hpp | 10 ++ shells/desktop/native/src/LauncherSurface.cpp | 21 +++ shells/desktop/native/src/LauncherSurface.hpp | 10 ++ .../desktop/native/src/ShellApplication.cpp | 107 +++++++++++++++ .../desktop/native/src/ShellApplication.hpp | 26 ++++ shells/desktop/native/src/Surface.cpp | 115 ++++++++++++++++ shells/desktop/native/src/Surface.hpp | 40 ++++++ shells/desktop/native/src/TopBarSurface.cpp | 17 +++ shells/desktop/native/src/TopBarSurface.hpp | 13 ++ shells/desktop/native/src/main.cpp | 7 + 13 files changed, 504 insertions(+), 34 deletions(-) create mode 100644 shells/desktop/native/CMakeLists.txt create mode 100644 shells/desktop/native/src/DesktopSurface.cpp create mode 100644 shells/desktop/native/src/DesktopSurface.hpp create mode 100644 shells/desktop/native/src/LauncherSurface.cpp create mode 100644 shells/desktop/native/src/LauncherSurface.hpp create mode 100644 shells/desktop/native/src/ShellApplication.cpp create mode 100644 shells/desktop/native/src/ShellApplication.hpp create mode 100644 shells/desktop/native/src/Surface.cpp create mode 100644 shells/desktop/native/src/Surface.hpp create mode 100644 shells/desktop/native/src/TopBarSurface.cpp create mode 100644 shells/desktop/native/src/TopBarSurface.hpp create mode 100644 shells/desktop/native/src/main.cpp diff --git a/shells/desktop/native/CMakeLists.txt b/shells/desktop/native/CMakeLists.txt new file mode 100644 index 0000000..1be3d1c --- /dev/null +++ b/shells/desktop/native/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) + +# Linux-only. GTK4 Layer Shell and WebKitGTK are not built or stubbed on Windows. +project(nebula-shell LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK4 REQUIRED IMPORTED_TARGET gtk4) +pkg_check_modules(GTK4_LAYER_SHELL REQUIRED IMPORTED_TARGET gtk4-layer-shell-0) +pkg_check_modules(WEBKITGTK REQUIRED IMPORTED_TARGET webkitgtk-6.0) + +add_executable(nebula-shell + src/main.cpp + src/ShellApplication.cpp + src/Surface.cpp + src/DesktopSurface.cpp + src/TopBarSurface.cpp + src/LauncherSurface.cpp +) + +target_link_libraries(nebula-shell PRIVATE + PkgConfig::GTK4 + PkgConfig::GTK4_LAYER_SHELL + PkgConfig::WEBKITGTK +) + +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(nebula-shell PRIVATE -Wall -Wextra) +endif() diff --git a/shells/desktop/native/README.md b/shells/desktop/native/README.md index 3a006c4..2ae06b0 100644 --- a/shells/desktop/native/README.md +++ b/shells/desktop/native/README.md @@ -1,34 +1,55 @@ # Nebula Desktop native host -This directory will contain the Linux native shell host for Nebula Desktop. +`nebula-shell` hosts the React-based Nebula Desktop chrome as real Wayland Layer Shell surfaces. -The host is a C++ process that creates **several** GTK4 windows, each backed by WebKitGTK 6.0 and placed with **gtk4-layer-shell**. It does not composite application windows. Those remain ordinary Wayland / XWayland clients of the compositor. +It is a C++ process that creates **several** GTK4 windows, each backed by WebKitGTK 6.0 and placed with **gtk4-layer-shell**. It does not composite application windows. Those remain ordinary Wayland / XWayland clients of the compositor. -Planned stack: +Stack: -- C++ +- C++20 - GTK4 - gtk4-layer-shell - WebKitGTK 6.0 +- CMake - Wayland -## Surfaces +This host is Linux-only. It will not compile on Windows. -The React UI in `shells/desktop/ui` can render each shell layer independently via a query parameter. The native host will create one WebKitGTK view per surface and append that parameter to the load URL. +## Purpose + +Nebula Desktop's React UI is shell chrome only: wallpaper/desktop, top bar, and launcher. `nebula-shell` maps each of those to a Wayland layer surface so they sit in the compositor like a real desktop environment, not like a browser window. + +Normal applications and games never render inside WebKit. + +## Architecture + +```text +Nebula Shell +├── DesktopSurface +│ ├── WebKitGTK +│ ├── ?surface=desktop +│ └── BACKGROUND +│ +├── TopBarSurface +│ ├── WebKitGTK +│ ├── ?surface=topbar +│ └── TOP +│ +└── LauncherSurface + ├── WebKitGTK + ├── ?surface=launcher + └── OVERLAY +``` | Surface | Query | Layer Shell layer | Placement | |-----------|-----------------------|-------------------|-----------| | Desktop | `?surface=desktop` | `BACKGROUND` | Anchored to all monitor edges | -| Top Bar | `?surface=topbar` | `TOP` | Anchored top/left/right, fixed shell-defined height, exclusive zone enabled | -| Launcher | `?surface=launcher` | `OVERLAY` | Hidden/unmapped when closed | - -The host must not hardcode a Vite port. It loads a base URL and appends the surface parameter. - -## Stacking +| Top Bar | `?surface=topbar` | `TOP` | Anchored top/left/right, 40px (matches the React top bar), exclusive zone enabled | +| Launcher | `?surface=launcher` | `OVERLAY` | Created at startup, hidden/unmapped until later toggle work | ```text OVERLAY - Nebula Launcher + Nebula Launcher ← created, currently unmapped notifications OSDs @@ -43,26 +64,36 @@ BACKGROUND wallpaper ``` -React draws **shell chrome only**. Applications and games render through the compositor, not through WebKit. +React draws **shell chrome only**. Applications and games render through the compositor, not through WebKit: ```text -Application / game - → Wayland / XWayland - → compositor - → GPU +App + ↓ +Wayland / XWayland + ↓ +Compositor + ↓ +GPU ``` -## Loading modes +## Development -### Development +The host must not hardcode a Vite host or port. It reads a base origin from the environment and appends the surface query parameter. -The host will load a configurable Vite origin, conceptually: +Start the React UI (the port is an example, not part of the native architecture): -```text -NEBULA_SHELL_DEV_URL=http://127.0.0.1:5174 +```bash +cd shells/desktop/ui +npm run dev -- --host 127.0.0.1 --port 5174 --strictPort ``` -and request: +Point the shell at that origin: + +```bash +export NEBULA_SHELL_DEV_URL=http://127.0.0.1:5174 +``` + +That loads: ```text {NEBULA_SHELL_DEV_URL}/?surface=desktop @@ -70,22 +101,46 @@ and request: {NEBULA_SHELL_DEV_URL}/?surface=launcher ``` -The port is environment configuration, not architecture. A combined browser preview (`/?surface=preview`, also the default) is for Windows UI work only; the native host should load the three surfaces separately. +Build: -### Production +```bash +cd shells/desktop/native -The host will load the compiled assets from `npm run build` in `shells/desktop/ui/dist`, still using the same `?surface=` parameters. It will not depend on a Vite server. +cmake -S . -B build +cmake --build build -j$(nproc) +``` + +Run inside a Wayland compositor such as Sway: + +```bash +./build/nebula-shell +``` + +On startup the desktop and top bar are presented. The launcher is created but left hidden. + +A combined browser preview (`/?surface=preview`, also the default) is for Windows UI work only. The native host loads the three surfaces separately. + +### Linux packages + +Build dependencies (install on the Ubuntu machine, not from this tree): + +```text +libgtk-4-dev +libgtk4-layer-shell-dev +libwebkitgtk-6.0-dev +pkg-config +cmake +build-essential +``` + +## Production + +Production loading from bundled `shells/desktop/ui/dist` files is not implemented yet. This milestone is development-URL loading only. ## Shell bridge -Each WebKit view talks to the host through a JavaScript bridge (`shellBridge` in the React UI). The development build uses an in-page mock. The native host will inject the real implementation so the top bar can toggle the launcher, and so launching an app can unmap the overlay and update the top-bar context. - -`nebula.*` remains the API boundary for system services (apps, audio, network, power). Those stay mocked until Linux services land. +Not implemented in this milestone. Each WebKit view will later talk to the host through a JavaScript bridge (`shellBridge` in the React UI) so the top bar can map and unmap `LauncherSurface`. ## Current development compositor Sway is the temporary compositor. See `compositor/sway/nebula-sway.conf`. - -## Build notes - -This host is Linux-only. Do not expect it to compile on Windows. Implementation is the next Linux-side task. diff --git a/shells/desktop/native/src/DesktopSurface.cpp b/shells/desktop/native/src/DesktopSurface.cpp new file mode 100644 index 0000000..5c846eb --- /dev/null +++ b/shells/desktop/native/src/DesktopSurface.cpp @@ -0,0 +1,17 @@ +#include "DesktopSurface.hpp" + +DesktopSurface::DesktopSurface(GtkApplication* app, const std::string& base_url) + : Surface(app, base_url, Config{ + .layer_namespace = "nebula-desktop", + .surface_name = "desktop", + .layer = GTK_LAYER_SHELL_LAYER_BACKGROUND, + .anchor_top = true, + .anchor_bottom = true, + .anchor_left = true, + .anchor_right = true, + .height_px = -1, + .keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_NONE, + .auto_exclusive_zone = false, + }) +{ +} diff --git a/shells/desktop/native/src/DesktopSurface.hpp b/shells/desktop/native/src/DesktopSurface.hpp new file mode 100644 index 0000000..df85fd2 --- /dev/null +++ b/shells/desktop/native/src/DesktopSurface.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include "Surface.hpp" + +#include + +class DesktopSurface : public Surface { +public: + DesktopSurface(GtkApplication* app, const std::string& base_url); +}; diff --git a/shells/desktop/native/src/LauncherSurface.cpp b/shells/desktop/native/src/LauncherSurface.cpp new file mode 100644 index 0000000..b6152fb --- /dev/null +++ b/shells/desktop/native/src/LauncherSurface.cpp @@ -0,0 +1,21 @@ +#include "LauncherSurface.hpp" + +LauncherSurface::LauncherSurface(GtkApplication* app, const std::string& base_url) + : Surface(app, base_url, Config{ + .layer_namespace = "nebula-launcher", + .surface_name = "launcher", + .layer = GTK_LAYER_SHELL_LAYER_OVERLAY, + .anchor_top = true, + .anchor_bottom = true, + .anchor_left = true, + .anchor_right = true, + .height_px = -1, + .keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_ON_DEMAND, + .auto_exclusive_zone = false, + }) +{ + // Created and fully configured, but left unmapped on purpose. + // Later, when shellBridge is wired to WebKitGTK, call present() to show + // the launcher and gtk_widget_set_visible(GTK_WIDGET(window()), FALSE) to hide it. + gtk_widget_set_visible(GTK_WIDGET(window()), FALSE); +} diff --git a/shells/desktop/native/src/LauncherSurface.hpp b/shells/desktop/native/src/LauncherSurface.hpp new file mode 100644 index 0000000..5164afd --- /dev/null +++ b/shells/desktop/native/src/LauncherSurface.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include "Surface.hpp" + +#include + +class LauncherSurface : public Surface { +public: + LauncherSurface(GtkApplication* app, const std::string& base_url); +}; diff --git a/shells/desktop/native/src/ShellApplication.cpp b/shells/desktop/native/src/ShellApplication.cpp new file mode 100644 index 0000000..5f3270f --- /dev/null +++ b/shells/desktop/native/src/ShellApplication.cpp @@ -0,0 +1,107 @@ +#include "ShellApplication.hpp" + +#include + +#include +#include +#include +#include +#include + +#ifndef G_APPLICATION_DEFAULT_FLAGS +#define G_APPLICATION_DEFAULT_FLAGS G_APPLICATION_FLAGS_NONE +#endif + +namespace { + +std::string trim_copy(std::string value) +{ + const auto is_not_space = [](unsigned char c) { return !std::isspace(c); }; + value.erase(value.begin(), std::find_if(value.begin(), value.end(), is_not_space)); + value.erase(std::find_if(value.rbegin(), value.rend(), is_not_space).base(), value.end()); + return value; +} + +std::string read_dev_url() +{ + const char* value = std::getenv("NEBULA_SHELL_DEV_URL"); + if (value == nullptr) { + return {}; + } + return trim_copy(value); +} + +void print_missing_dev_url_error() +{ + std::cerr + << "nebula-shell: NEBULA_SHELL_DEV_URL is not set or empty.\n" + << "Set it to the origin of the Nebula Desktop UI, then run nebula-shell again.\n" + << "Example:\n" + << " export NEBULA_SHELL_DEV_URL=://:\n"; +} + +} // namespace + +void ShellApplication::on_activate(GtkApplication* app, gpointer user_data) +{ + auto* self = static_cast(user_data); + self->activate(app); +} + +void ShellApplication::activate(GtkApplication* app) +{ + if (desktop_ != nullptr) { + return; + } + + if (!gtk_layer_is_supported()) { + std::cerr + << "nebula-shell: this session does not support wlr-layer-shell.\n" + << "Run nebula-shell inside a Wayland compositor such as Sway.\n"; + g_application_quit(G_APPLICATION(app)); + return; + } + + desktop_ = std::make_unique(app, base_url_); + topBar_ = std::make_unique(app, base_url_); + launcher_ = std::make_unique(app, base_url_); + + desktop_->present(); + topBar_->present(); + // launcher_ is created above but not presented; it stays hidden/unmapped. + + started_ = true; +} + +int ShellApplication::run(int argc, char** argv) +{ + base_url_ = read_dev_url(); + if (base_url_.empty()) { + print_missing_dev_url_error(); + return EXIT_FAILURE; + } + + if (base_url_.find("://") == std::string::npos) { + std::cerr + << "nebula-shell: NEBULA_SHELL_DEV_URL must include a URI scheme " + << "(for example http or https).\n"; + return EXIT_FAILURE; + } + + GtkApplication* app = gtk_application_new("org.nebulaos.shell", G_APPLICATION_DEFAULT_FLAGS); + if (app == nullptr) { + std::cerr << "nebula-shell: failed to create the GTK application.\n"; + return EXIT_FAILURE; + } + + g_signal_connect(app, "activate", G_CALLBACK(on_activate), this); + + const int status = g_application_run(G_APPLICATION(app), argc, argv); + g_object_unref(app); + + if (!started_) { + return EXIT_FAILURE; + } + + return status; +} diff --git a/shells/desktop/native/src/ShellApplication.hpp b/shells/desktop/native/src/ShellApplication.hpp new file mode 100644 index 0000000..f8f67de --- /dev/null +++ b/shells/desktop/native/src/ShellApplication.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "DesktopSurface.hpp" +#include "LauncherSurface.hpp" +#include "TopBarSurface.hpp" + +#include + +#include +#include + +class ShellApplication { +public: + int run(int argc, char** argv); + +private: + static void on_activate(GtkApplication* app, gpointer user_data); + void activate(GtkApplication* app); + + std::string base_url_; + bool started_ = false; + + std::unique_ptr desktop_; + std::unique_ptr topBar_; + std::unique_ptr launcher_; +}; diff --git a/shells/desktop/native/src/Surface.cpp b/shells/desktop/native/src/Surface.cpp new file mode 100644 index 0000000..7d1c835 --- /dev/null +++ b/shells/desktop/native/src/Surface.cpp @@ -0,0 +1,115 @@ +#include "Surface.hpp" + +#include + +#include + +namespace { + +void apply_transparent_window_css() +{ + static bool applied = false; + if (applied) { + return; + } + applied = true; + + GtkCssProvider* provider = gtk_css_provider_new(); + const char* css = "window.nebula-shell-surface { background-color: transparent; }"; + +#if GTK_CHECK_VERSION(4, 12, 0) + gtk_css_provider_load_from_string(provider, css); +#else + gtk_css_provider_load_from_data(provider, css, -1); +#endif + + GdkDisplay* display = gdk_display_get_default(); + if (display != nullptr) { + gtk_style_context_add_provider_for_display( + display, + GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + } + + g_object_unref(provider); +} + +gboolean on_load_failed( + [[maybe_unused]] WebKitWebView* web_view, + [[maybe_unused]] WebKitLoadEvent load_event, + char* failing_uri, + GError* error, + [[maybe_unused]] gpointer user_data) +{ + const char* uri = failing_uri != nullptr ? failing_uri : "(unknown URI)"; + const char* message = (error != nullptr && error->message != nullptr) + ? error->message + : "unknown error"; + std::cerr << "nebula-shell: failed to load " << uri << ": " << message << '\n'; + return FALSE; +} + +} // namespace + +std::string make_surface_url(std::string_view base_url, std::string_view surface_name) +{ + std::string url{base_url}; + while (!url.empty() && url.back() == '/') { + url.pop_back(); + } + url += "/?surface="; + url += surface_name; + return url; +} + +Surface::Surface(GtkApplication* app, const std::string& base_url, const Config& config) +{ + apply_transparent_window_css(); + + const std::string url = make_surface_url(base_url, config.surface_name); + + window_ = GTK_WINDOW(gtk_application_window_new(app)); + gtk_window_set_decorated(window_, FALSE); + gtk_window_set_title(window_, config.layer_namespace); + gtk_widget_add_css_class(GTK_WIDGET(window_), "nebula-shell-surface"); + + // Layer Shell must be configured before the window is realized or presented. + gtk_layer_init_for_window(window_); + gtk_layer_set_namespace(window_, config.layer_namespace); + gtk_layer_set_layer(window_, config.layer); + gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_TOP, config.anchor_top); + gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_BOTTOM, config.anchor_bottom); + gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_LEFT, config.anchor_left); + gtk_layer_set_anchor(window_, GTK_LAYER_SHELL_EDGE_RIGHT, config.anchor_right); + gtk_layer_set_keyboard_mode(window_, config.keyboard_mode); + + if (config.auto_exclusive_zone) { + gtk_layer_auto_exclusive_zone_enable(window_); + } else { + gtk_layer_set_exclusive_zone(window_, 0); + } + + if (config.height_px > 0) { + gtk_window_set_default_size(window_, 1, config.height_px); + gtk_widget_set_size_request(GTK_WIDGET(window_), -1, config.height_px); + } + + GtkWidget* webview_widget = webkit_web_view_new(); + WebKitWebView* webview = WEBKIT_WEB_VIEW(webview_widget); + + GdkRGBA transparent{0.0f, 0.0f, 0.0f, 0.0f}; + webkit_web_view_set_background_color(webview, &transparent); + + gtk_widget_set_hexpand(webview_widget, TRUE); + gtk_widget_set_vexpand(webview_widget, TRUE); + + g_signal_connect(webview, "load-failed", G_CALLBACK(on_load_failed), nullptr); + + gtk_window_set_child(window_, webview_widget); + webkit_web_view_load_uri(webview, url.c_str()); +} + +void Surface::present() +{ + gtk_window_present(window_); +} diff --git a/shells/desktop/native/src/Surface.hpp b/shells/desktop/native/src/Surface.hpp new file mode 100644 index 0000000..22739ac --- /dev/null +++ b/shells/desktop/native/src/Surface.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include +#include + +// Shared Layer Shell + WebKitGTK surface. Subclasses only supply layout. +class Surface { +public: + struct Config { + const char* layer_namespace = nullptr; + const char* surface_name = nullptr; + GtkLayerShellLayer layer = GTK_LAYER_SHELL_LAYER_TOP; + bool anchor_top = false; + bool anchor_bottom = false; + bool anchor_left = false; + bool anchor_right = false; + int height_px = -1; + GtkLayerShellKeyboardMode keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_NONE; + bool auto_exclusive_zone = false; + }; + + Surface(GtkApplication* app, const std::string& base_url, const Config& config); + ~Surface() = default; + + Surface(const Surface&) = delete; + Surface& operator=(const Surface&) = delete; + Surface(Surface&&) = delete; + Surface& operator=(Surface&&) = delete; + + void present(); + GtkWindow* window() const { return window_; } + +private: + GtkWindow* window_ = nullptr; +}; + +std::string make_surface_url(std::string_view base_url, std::string_view surface_name); diff --git a/shells/desktop/native/src/TopBarSurface.cpp b/shells/desktop/native/src/TopBarSurface.cpp new file mode 100644 index 0000000..4e11088 --- /dev/null +++ b/shells/desktop/native/src/TopBarSurface.cpp @@ -0,0 +1,17 @@ +#include "TopBarSurface.hpp" + +TopBarSurface::TopBarSurface(GtkApplication* app, const std::string& base_url) + : Surface(app, base_url, Config{ + .layer_namespace = "nebula-topbar", + .surface_name = "topbar", + .layer = GTK_LAYER_SHELL_LAYER_TOP, + .anchor_top = true, + .anchor_bottom = false, + .anchor_left = true, + .anchor_right = true, + .height_px = kTopBarHeightPx, + .keyboard_mode = GTK_LAYER_SHELL_KEYBOARD_MODE_NONE, + .auto_exclusive_zone = true, + }) +{ +} diff --git a/shells/desktop/native/src/TopBarSurface.hpp b/shells/desktop/native/src/TopBarSurface.hpp new file mode 100644 index 0000000..673bb3f --- /dev/null +++ b/shells/desktop/native/src/TopBarSurface.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "Surface.hpp" + +#include + +// Matches --nebula-topbar-height in shells/desktop/ui/src/styles/tokens.css. +inline constexpr int kTopBarHeightPx = 40; + +class TopBarSurface : public Surface { +public: + TopBarSurface(GtkApplication* app, const std::string& base_url); +}; diff --git a/shells/desktop/native/src/main.cpp b/shells/desktop/native/src/main.cpp new file mode 100644 index 0000000..1f773ed --- /dev/null +++ b/shells/desktop/native/src/main.cpp @@ -0,0 +1,7 @@ +#include "ShellApplication.hpp" + +int main(int argc, char* argv[]) +{ + ShellApplication application; + return application.run(argc, argv); +}