From de14f8b8b70b3b84c339739ba9af5a930470fcb9 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Sun, 21 Jun 2026 21:04:38 -0700 Subject: [PATCH 1/5] shell: Add Shell.WaylandServer binding for JS Wayland protocols Add a generic, GObject-introspectable binding that lets the shell implement Wayland protocols from JavaScript. Shell.WaylandServer wraps the compositor's wl_display and exposes the pieces a protocol implementation needs: defining an interface, creating a global, dispatching client requests, posting events and protocol errors, creating resources for new_id arguments, and resolving a surface-bearing resource to its MetaWindow (via mutter's meta_object_from_wl_resource). This is infrastructure only; it carries no protocol of its own. It links against wayland-server (added as a dependency). Assisted-By: Claude Opus 4.8 --- meson.build | 1 + src/meson.build | 3 + src/shell-wayland.c | 1282 +++++++++++++++++++++++++++++++++++++++++++ src/shell-wayland.h | 152 +++++ 4 files changed, 1438 insertions(+) create mode 100644 src/shell-wayland.c create mode 100644 src/shell-wayland.h diff --git a/meson.build b/meson.build index a13c322c82..6de5ee7819 100644 --- a/meson.build +++ b/meson.build @@ -82,6 +82,7 @@ clutter_dep = dependency(clutter_pc, version: mutter_req) mtk_dep = dependency(mtk_pc, version: mutter_req) cogl_dep = dependency(cogl_pc, version: mutter_req) mutter_dep = dependency(libmutter_pc, version: mutter_req) +wayland_server_dep = dependency('wayland-server') polkit_dep = dependency('polkit-agent-1', version: polkit_req) schemas_dep = dependency('gsettings-desktop-schemas', version: schemas_req) gnome_desktop_dep = dependency('gnome-desktop-4', version: gnome_desktop_req) diff --git a/src/meson.build b/src/meson.build index 128f9606e4..4786cad03b 100644 --- a/src/meson.build +++ b/src/meson.build @@ -75,6 +75,7 @@ gnome_shell_deps = [ libsystemd_dep, libpipewire_dep, pango_dep, + wayland_server_dep, ] if have_xwayland @@ -124,6 +125,7 @@ libshell_public_headers = [ 'shell-stack.h', 'shell-time-change-source.h', 'shell-util.h', + 'shell-wayland.h', 'shell-window-preview.h', 'shell-window-preview-layout.h', 'shell-window-tracker.h', @@ -179,6 +181,7 @@ libshell_sources = [ 'shell-stack.c', 'shell-time-change-source.c', 'shell-util.c', + 'shell-wayland.c', 'shell-window-preview.c', 'shell-window-preview-layout.c', 'shell-window-tracker.c', diff --git a/src/shell-wayland.c b/src/shell-wayland.c new file mode 100644 index 0000000000..468c39aa71 --- /dev/null +++ b/src/shell-wayland.c @@ -0,0 +1,1282 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ + +/** + * SECTION:shell-wayland + * @short_description: Generic libwayland-server binding for JavaScript + * + * This is a thin, introspectable wrapper around libwayland-server that lets a + * complete Wayland protocol be implemented from JavaScript. A protocol is + * described at runtime with #ShellWaylandInterface (which builds a dynamic + * `wl_interface`), advertised with shell_wayland_server_create_global(), and + * driven through the #ShellWaylandGlobal::bind and + * #ShellWaylandResource::request signals. Outgoing events are posted with + * shell_wayland_resource_post_event(). + * + * All wire arguments are funnelled through #ShellWaylandArgs, which exposes + * typed getters (to read a request) and typed builders (to assemble an event). + * + * Object arguments referring to surfaces created by other protocols (e.g. an + * `xdg_toplevel`) can be resolved to a #MetaWindow with + * shell_wayland_resource_get_meta_window(), which relies on the + * meta_object_from_wl_resource() helper exposed by mutter. + */ + +#include "config.h" + +#include "shell-wayland.h" +#include "shell-global.h" + +#include +#include +#include + +#include +#include + +/* Provided by mutter (public API added alongside meta-wayland-surface.h). The + * prototype is declared here as well so the shell builds against a mutter that + * predates the header change; the definitions must match. */ +extern GObject * meta_object_from_wl_resource (struct wl_resource *resource); + +/* Internal cross-section helpers. */ +static struct wl_resource * shell_wayland_resource_get_wl_resource (ShellWaylandResource *self); +static ShellWaylandResource * shell_wayland_resource_for_wl_resource (struct wl_resource *resource); +static ShellWaylandResource * shell_wayland_resource_new_owned (struct wl_resource *resource); + +/* ------------------------------------------------------------------ */ +/* Signature parsing helpers */ +/* ------------------------------------------------------------------ */ + +static ShellWaylandArgType +char_to_arg_type (char c) +{ + switch (c) + { + case 'i': return SHELL_WAYLAND_ARG_INT; + case 'u': return SHELL_WAYLAND_ARG_UINT; + case 'f': return SHELL_WAYLAND_ARG_FIXED; + case 's': return SHELL_WAYLAND_ARG_STRING; + case 'o': return SHELL_WAYLAND_ARG_OBJECT; + case 'n': return SHELL_WAYLAND_ARG_NEW_ID; + case 'a': return SHELL_WAYLAND_ARG_ARRAY; + case 'h': return SHELL_WAYLAND_ARG_FD; + default: return -1; + } +} + +/* Count the arguments in a wl_message signature, optionally writing their + * types into @types_out. Version-prefix digits and the nullable marker '?' + * are skipped. */ +static int +parse_signature (const char *signature, + ShellWaylandArgType *types_out, + int max_out) +{ + int n = 0; + + for (const char *p = signature; p && *p; p++) + { + ShellWaylandArgType type; + + if (g_ascii_isdigit (*p) || *p == '?') + continue; + + type = char_to_arg_type (*p); + if (type == (ShellWaylandArgType) -1) + continue; + + if (types_out && n < max_out) + types_out[n] = type; + n++; + } + + return n; +} + +/* ================================================================== */ +/* ShellWaylandInterface */ +/* ================================================================== */ + +struct _ShellWaylandInterface +{ + GObject parent_instance; + + struct wl_interface wl; + + /* Stable backing storage; freed on finalize. */ + char *name; + GArray *methods; /* struct wl_message */ + GArray *events; /* struct wl_message */ + GPtrArray *strings; /* owned char * (message names, signatures) */ + GPtrArray *type_arrays; /* owned const struct wl_interface ** blocks */ + GPtrArray *type_refs; /* refs on ShellWaylandInterface kept alive */ + + gboolean sealed; +}; + +G_DEFINE_FINAL_TYPE (ShellWaylandInterface, shell_wayland_interface, G_TYPE_OBJECT) + +static void +shell_wayland_interface_finalize (GObject *object) +{ + ShellWaylandInterface *self = SHELL_WAYLAND_INTERFACE (object); + + g_clear_pointer (&self->methods, g_array_unref); + g_clear_pointer (&self->events, g_array_unref); + g_clear_pointer (&self->strings, g_ptr_array_unref); + g_clear_pointer (&self->type_arrays, g_ptr_array_unref); + g_clear_pointer (&self->type_refs, g_ptr_array_unref); + g_clear_pointer (&self->name, g_free); + + G_OBJECT_CLASS (shell_wayland_interface_parent_class)->finalize (object); +} + +static void +shell_wayland_interface_class_init (ShellWaylandInterfaceClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->finalize = shell_wayland_interface_finalize; +} + +static void +shell_wayland_interface_init (ShellWaylandInterface *self) +{ + self->methods = g_array_new (FALSE, TRUE, sizeof (struct wl_message)); + self->events = g_array_new (FALSE, TRUE, sizeof (struct wl_message)); + self->strings = g_ptr_array_new_with_free_func (g_free); + self->type_arrays = g_ptr_array_new_with_free_func (g_free); + self->type_refs = g_ptr_array_new_with_free_func (g_object_unref); +} + +/** + * shell_wayland_interface_new: + * @name: the wire name of the interface, e.g. "zxdg_decoration_manager_v1" + * @version: the highest supported version + * + * Begins building a Wayland interface. Add requests and events with + * shell_wayland_interface_add_request() and + * shell_wayland_interface_add_event() before using it. + * + * Returns: (transfer full): a new #ShellWaylandInterface + */ +ShellWaylandInterface * +shell_wayland_interface_new (const char *name, + int version) +{ + ShellWaylandInterface *self; + + g_return_val_if_fail (name != NULL, NULL); + + self = g_object_new (SHELL_TYPE_WAYLAND_INTERFACE, NULL); + self->name = g_strdup (name); + self->wl.name = self->name; + self->wl.version = version; + + return self; +} + +const char * +shell_wayland_interface_get_name (ShellWaylandInterface *iface) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_INTERFACE (iface), NULL); + + return iface->name; +} + +static void +interface_add_message (ShellWaylandInterface *self, + GArray *array, + const char *name, + const char *signature, + guint *arg_indices, + int n_arg_indices, + ShellWaylandInterface **arg_types, + int n_arg_types) +{ + struct wl_message message = { 0 }; + const struct wl_interface **type_block; + int n_args; + int n = MIN (n_arg_indices, n_arg_types); + + g_return_if_fail (!self->sealed); + + message.name = g_strdup (name); + message.signature = g_strdup (signature); + g_ptr_array_add (self->strings, (char *) message.name); + g_ptr_array_add (self->strings, (char *) message.signature); + + n_args = parse_signature (signature, NULL, 0); + type_block = g_new0 (const struct wl_interface *, MAX (n_args, 1)); + g_ptr_array_add (self->type_arrays, type_block); + + /* @arg_indices/@arg_types is a sparse mapping listing only the typed + * object/new_id arguments; untyped slots stay NULL. This avoids passing + * NULL array elements across GObject introspection, which GJS rejects. */ + for (int i = 0; i < n; i++) + { + guint index = arg_indices[i]; + + if (index < (guint) n_args && arg_types[i]) + { + /* Reference the embedded wl_interface; it is stable for the + * lifetime of the ShellWaylandInterface, which we keep alive. */ + type_block[index] = &arg_types[i]->wl; + g_ptr_array_add (self->type_refs, g_object_ref (arg_types[i])); + } + } + + message.types = type_block; + g_array_append_val (array, message); +} + +/** + * shell_wayland_interface_add_request: + * @iface: a #ShellWaylandInterface + * @name: request name + * @signature: the wl_message signature string, e.g. "no" + * @arg_indices: (array length=n_arg_indices): the argument positions of the + * typed object/new_id arguments + * @n_arg_indices: number of entries in @arg_indices + * @arg_types: (array length=n_arg_types): the interface for each argument + * listed in @arg_indices, in the same order + * @n_arg_types: number of entries in @arg_types + * + * Adds a request (client-to-server message) to the interface. Only the typed + * object and new_id arguments need to be listed in @arg_indices/@arg_types; + * every other argument (including untyped objects) is left generic. + */ +void +shell_wayland_interface_add_request (ShellWaylandInterface *iface, + const char *name, + const char *signature, + guint *arg_indices, + int n_arg_indices, + ShellWaylandInterface **arg_types, + int n_arg_types) +{ + g_return_if_fail (SHELL_IS_WAYLAND_INTERFACE (iface)); + + interface_add_message (iface, iface->methods, name, signature, + arg_indices, n_arg_indices, arg_types, n_arg_types); +} + +/** + * shell_wayland_interface_add_event: + * @iface: a #ShellWaylandInterface + * @name: event name + * @signature: the wl_message signature string + * @arg_indices: (array length=n_arg_indices): the argument positions of the + * typed object/new_id arguments + * @n_arg_indices: number of entries in @arg_indices + * @arg_types: (array length=n_arg_types): the interface for each argument + * listed in @arg_indices, in the same order + * @n_arg_types: number of entries in @arg_types + * + * Adds an event (server-to-client message) to the interface. + */ +void +shell_wayland_interface_add_event (ShellWaylandInterface *iface, + const char *name, + const char *signature, + guint *arg_indices, + int n_arg_indices, + ShellWaylandInterface **arg_types, + int n_arg_types) +{ + g_return_if_fail (SHELL_IS_WAYLAND_INTERFACE (iface)); + + interface_add_message (iface, iface->events, name, signature, + arg_indices, n_arg_indices, arg_types, n_arg_types); +} + +/* Finalize the wl_interface so its method/event pointers are stable. */ +static const struct wl_interface * +shell_wayland_interface_seal (ShellWaylandInterface *self) +{ + if (!self->sealed) + { + self->wl.method_count = self->methods->len; + self->wl.methods = (const struct wl_message *) self->methods->data; + self->wl.event_count = self->events->len; + self->wl.events = (const struct wl_message *) self->events->data; + self->sealed = TRUE; + } + + return &self->wl; +} + +/* ================================================================== */ +/* ShellWaylandArgs */ +/* ================================================================== */ + +typedef struct +{ + ShellWaylandArgType type; + union { + gint32 i; + guint32 u; + wl_fixed_t f; + guint32 n; + int h; + } v; + char *s; /* string */ + struct wl_resource *o; /* object */ + GBytes *bytes; /* array */ +} ShellArg; + +struct _ShellWaylandArgs +{ + GObject parent_instance; + + GArray *args; /* ShellArg */ +}; + +G_DEFINE_FINAL_TYPE (ShellWaylandArgs, shell_wayland_args, G_TYPE_OBJECT) + +static void +clear_arg (gpointer data) +{ + ShellArg *arg = data; + + g_clear_pointer (&arg->s, g_free); + g_clear_pointer (&arg->bytes, g_bytes_unref); +} + +static void +shell_wayland_args_finalize (GObject *object) +{ + ShellWaylandArgs *self = SHELL_WAYLAND_ARGS (object); + + g_clear_pointer (&self->args, g_array_unref); + + G_OBJECT_CLASS (shell_wayland_args_parent_class)->finalize (object); +} + +static void +shell_wayland_args_class_init (ShellWaylandArgsClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->finalize = shell_wayland_args_finalize; +} + +static void +shell_wayland_args_init (ShellWaylandArgs *self) +{ + self->args = g_array_new (FALSE, TRUE, sizeof (ShellArg)); + g_array_set_clear_func (self->args, clear_arg); +} + +/** + * shell_wayland_args_new: + * + * Returns: (transfer full): a new empty argument list, ready to be filled with + * the `add` builders before being passed to + * shell_wayland_resource_post_event(). + */ +ShellWaylandArgs * +shell_wayland_args_new (void) +{ + return g_object_new (SHELL_TYPE_WAYLAND_ARGS, NULL); +} + +guint +shell_wayland_args_get_length (ShellWaylandArgs *args) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), 0); + + return args->args->len; +} + +static ShellArg * +args_append (ShellWaylandArgs *self, + ShellWaylandArgType type) +{ + ShellArg arg = { 0 }; + + arg.type = type; + g_array_append_val (self->args, arg); + + return &g_array_index (self->args, ShellArg, self->args->len - 1); +} + +void +shell_wayland_args_add_int (ShellWaylandArgs *args, gint32 value) +{ + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + args_append (args, SHELL_WAYLAND_ARG_INT)->v.i = value; +} + +void +shell_wayland_args_add_uint (ShellWaylandArgs *args, guint32 value) +{ + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + args_append (args, SHELL_WAYLAND_ARG_UINT)->v.u = value; +} + +void +shell_wayland_args_add_fixed (ShellWaylandArgs *args, double value) +{ + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + args_append (args, SHELL_WAYLAND_ARG_FIXED)->v.f = wl_fixed_from_double (value); +} + +void +shell_wayland_args_add_string (ShellWaylandArgs *args, const char *value) +{ + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + args_append (args, SHELL_WAYLAND_ARG_STRING)->s = g_strdup (value); +} + +void +shell_wayland_args_add_object (ShellWaylandArgs *args, ShellWaylandResource *resource) +{ + ShellArg *arg; + + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + g_return_if_fail (resource == NULL || SHELL_IS_WAYLAND_RESOURCE (resource)); + + arg = args_append (args, SHELL_WAYLAND_ARG_OBJECT); + if (resource) + arg->o = shell_wayland_resource_get_wl_resource (resource); +} + +void +shell_wayland_args_add_new_id (ShellWaylandArgs *args, guint32 value) +{ + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + args_append (args, SHELL_WAYLAND_ARG_NEW_ID)->v.n = value; +} + +void +shell_wayland_args_add_fd (ShellWaylandArgs *args, int fd) +{ + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + args_append (args, SHELL_WAYLAND_ARG_FD)->v.h = fd; +} + +void +shell_wayland_args_add_array (ShellWaylandArgs *args, GBytes *bytes) +{ + g_return_if_fail (SHELL_IS_WAYLAND_ARGS (args)); + args_append (args, SHELL_WAYLAND_ARG_ARRAY)->bytes = + bytes ? g_bytes_ref (bytes) : NULL; +} + +static ShellArg * +args_get (ShellWaylandArgs *self, guint index, ShellWaylandArgType type) +{ + ShellArg *arg; + + if (index >= self->args->len) + return NULL; + + arg = &g_array_index (self->args, ShellArg, index); + if (arg->type != type) + { + g_warning ("Wayland argument %u is not of the expected type", index); + return NULL; + } + + return arg; +} + +gint32 +shell_wayland_args_get_int (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), 0); + arg = args_get (args, index, SHELL_WAYLAND_ARG_INT); + return arg ? arg->v.i : 0; +} + +guint32 +shell_wayland_args_get_uint (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), 0); + arg = args_get (args, index, SHELL_WAYLAND_ARG_UINT); + return arg ? arg->v.u : 0; +} + +double +shell_wayland_args_get_fixed (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), 0.0); + arg = args_get (args, index, SHELL_WAYLAND_ARG_FIXED); + return arg ? wl_fixed_to_double (arg->v.f) : 0.0; +} + +const char * +shell_wayland_args_get_string (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), NULL); + arg = args_get (args, index, SHELL_WAYLAND_ARG_STRING); + return arg ? arg->s : NULL; +} + +/** + * shell_wayland_args_get_object: + * @args: a #ShellWaylandArgs + * @index: argument index + * + * Returns: (transfer full) (nullable): the object argument wrapped as a + * #ShellWaylandResource + */ +ShellWaylandResource * +shell_wayland_args_get_object (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), NULL); + arg = args_get (args, index, SHELL_WAYLAND_ARG_OBJECT); + if (!arg || !arg->o) + return NULL; + return shell_wayland_resource_for_wl_resource (arg->o); +} + +guint32 +shell_wayland_args_get_new_id (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), 0); + arg = args_get (args, index, SHELL_WAYLAND_ARG_NEW_ID); + return arg ? arg->v.n : 0; +} + +int +shell_wayland_args_get_fd (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), -1); + arg = args_get (args, index, SHELL_WAYLAND_ARG_FD); + return arg ? arg->v.h : -1; +} + +/** + * shell_wayland_args_get_array: + * @args: a #ShellWaylandArgs + * @index: argument index + * + * Returns: (transfer full) (nullable): the array argument as #GBytes + */ +GBytes * +shell_wayland_args_get_array (ShellWaylandArgs *args, guint index) +{ + ShellArg *arg; + g_return_val_if_fail (SHELL_IS_WAYLAND_ARGS (args), NULL); + arg = args_get (args, index, SHELL_WAYLAND_ARG_ARRAY); + return (arg && arg->bytes) ? g_bytes_ref (arg->bytes) : NULL; +} + +/* Build a ShellWaylandArgs from an incoming request. */ +static ShellWaylandArgs * +shell_wayland_args_new_from_request (const char *signature, + union wl_argument *wl_args) +{ + ShellWaylandArgs *self = shell_wayland_args_new (); + ShellWaylandArgType types[64]; + int n; + + n = parse_signature (signature, types, G_N_ELEMENTS (types)); + + for (int i = 0; i < n; i++) + { + ShellArg *arg = args_append (self, types[i]); + + switch (types[i]) + { + case SHELL_WAYLAND_ARG_INT: + arg->v.i = wl_args[i].i; + break; + case SHELL_WAYLAND_ARG_UINT: + arg->v.u = wl_args[i].u; + break; + case SHELL_WAYLAND_ARG_FIXED: + arg->v.f = wl_args[i].f; + break; + case SHELL_WAYLAND_ARG_STRING: + arg->s = g_strdup (wl_args[i].s); + break; + case SHELL_WAYLAND_ARG_OBJECT: + arg->o = (struct wl_resource *) wl_args[i].o; + break; + case SHELL_WAYLAND_ARG_NEW_ID: + arg->v.n = wl_args[i].n; + break; + case SHELL_WAYLAND_ARG_FD: + arg->v.h = wl_args[i].h; + break; + case SHELL_WAYLAND_ARG_ARRAY: + if (wl_args[i].a) + arg->bytes = g_bytes_new (wl_args[i].a->data, wl_args[i].a->size); + break; + } + } + + return self; +} + +/* ================================================================== */ +/* ShellWaylandResource */ +/* ================================================================== */ + +struct ShellResourceDestroyListener +{ + struct wl_listener listener; + ShellWaylandResource *wrapper; +}; + +struct _ShellWaylandResource +{ + GObject parent_instance; + + struct wl_resource *resource; /* NULLed once destroyed */ + gboolean owned; /* created via this binding */ + struct ShellResourceDestroyListener destroy_listener; +}; + +enum +{ + RESOURCE_REQUEST, + RESOURCE_DESTROY, + RESOURCE_N_SIGNALS +}; + +static guint resource_signals[RESOURCE_N_SIGNALS]; + +G_DEFINE_FINAL_TYPE (ShellWaylandResource, shell_wayland_resource, G_TYPE_OBJECT) + +/* wl_resource -> ShellWaylandResource (borrowed pointer). Lets us hand out a + * single consistent wrapper per resource. */ +static GHashTable *resource_wrappers; + +static void +shell_wayland_resource_finalize (GObject *object) +{ + ShellWaylandResource *self = SHELL_WAYLAND_RESOURCE (object); + + if (self->resource) + { + if (resource_wrappers) + g_hash_table_remove (resource_wrappers, self->resource); + + if (!self->owned) + { + /* Borrowed resource: stop listening, leave the resource itself. */ + wl_list_remove (&self->destroy_listener.listener.link); + } + self->resource = NULL; + } + + G_OBJECT_CLASS (shell_wayland_resource_parent_class)->finalize (object); +} + +static void +shell_wayland_resource_class_init (ShellWaylandResourceClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->finalize = shell_wayland_resource_finalize; + + /** + * ShellWaylandResource::request: + * @resource: the resource + * @opcode: the request opcode (index into the interface's requests) + * @args: (transfer none): the request arguments + * + * Emitted when the client sends a request on this resource. + */ + resource_signals[RESOURCE_REQUEST] = + g_signal_new ("request", + G_TYPE_FROM_CLASS (klass), + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, NULL, + G_TYPE_NONE, 2, + G_TYPE_UINT, SHELL_TYPE_WAYLAND_ARGS); + + /** + * ShellWaylandResource::destroy: + * @resource: the resource + * + * Emitted when the resource is destroyed (by the client, by + * shell_wayland_resource_destroy(), or because the client disconnected). + */ + resource_signals[RESOURCE_DESTROY] = + g_signal_new ("destroy", + G_TYPE_FROM_CLASS (klass), + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, NULL, + G_TYPE_NONE, 0); +} + +static void +shell_wayland_resource_init (ShellWaylandResource *self) +{ +} + +/* Internal: get the underlying wl_resource (used by ShellWaylandArgs). */ +static struct wl_resource * +shell_wayland_resource_get_wl_resource (ShellWaylandResource *self) +{ + return self ? self->resource : NULL; +} + +static void +borrowed_resource_destroyed (struct wl_listener *listener, + void *data) +{ + struct ShellResourceDestroyListener *l = + wl_container_of (listener, l, listener); + ShellWaylandResource *self = l->wrapper; + + if (resource_wrappers) + g_hash_table_remove (resource_wrappers, self->resource); + self->resource = NULL; + g_signal_emit (self, resource_signals[RESOURCE_DESTROY], 0); +} + +/* Wrap a wl_resource we did not create (e.g. an object request argument). */ +static ShellWaylandResource * +shell_wayland_resource_for_wl_resource (struct wl_resource *resource) +{ + ShellWaylandResource *self; + + if (!resource) + return NULL; + + if (resource_wrappers) + { + self = g_hash_table_lookup (resource_wrappers, resource); + if (self) + return g_object_ref (self); + } + else + { + resource_wrappers = g_hash_table_new (NULL, NULL); + } + + self = g_object_new (SHELL_TYPE_WAYLAND_RESOURCE, NULL); + self->resource = resource; + self->owned = FALSE; + self->destroy_listener.wrapper = self; + self->destroy_listener.listener.notify = borrowed_resource_destroyed; + wl_resource_add_destroy_listener (resource, &self->destroy_listener.listener); + + g_hash_table_insert (resource_wrappers, resource, self); + + return self; +} + +/* Wrap a wl_resource we created. The single reference is owned by the + * resource and dropped from its destructor. */ +static ShellWaylandResource * +shell_wayland_resource_new_owned (struct wl_resource *resource) +{ + ShellWaylandResource *self; + + self = g_object_new (SHELL_TYPE_WAYLAND_RESOURCE, NULL); + self->resource = resource; + self->owned = TRUE; + + if (!resource_wrappers) + resource_wrappers = g_hash_table_new (NULL, NULL); + g_hash_table_insert (resource_wrappers, resource, self); + + return self; +} + +/* Called from the dispatcher destructor when an owned resource goes away. */ +static void +owned_resource_destroyed (ShellWaylandResource *self) +{ + if (resource_wrappers) + g_hash_table_remove (resource_wrappers, self->resource); + self->resource = NULL; + g_signal_emit (self, resource_signals[RESOURCE_DESTROY], 0); + g_object_unref (self); +} + +guint32 +shell_wayland_resource_get_id (ShellWaylandResource *resource) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource), 0); + return resource->resource ? wl_resource_get_id (resource->resource) : 0; +} + +guint +shell_wayland_resource_get_version (ShellWaylandResource *resource) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource), 0); + return resource->resource ? wl_resource_get_version (resource->resource) : 0; +} + +const char * +shell_wayland_resource_get_class (ShellWaylandResource *resource) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource), NULL); + return resource->resource ? wl_resource_get_class (resource->resource) : NULL; +} + +/** + * shell_wayland_resource_post_event: + * @resource: a #ShellWaylandResource + * @opcode: the event opcode (index into the interface's events) + * @args: (nullable): the event arguments, or %NULL for no arguments + * + * Sends an event to the client. + */ +void +shell_wayland_resource_post_event (ShellWaylandResource *resource, + guint opcode, + ShellWaylandArgs *args) +{ + g_autofree union wl_argument *wl_args = NULL; + g_autofree struct wl_array *arrays = NULL; + guint n = 0; + + g_return_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource)); + + if (!resource->resource) + return; + + if (args) + n = args->args->len; + + wl_args = g_new0 (union wl_argument, MAX (n, 1)); + arrays = g_new0 (struct wl_array, MAX (n, 1)); + + for (guint i = 0; i < n; i++) + { + ShellArg *arg = &g_array_index (args->args, ShellArg, i); + + switch (arg->type) + { + case SHELL_WAYLAND_ARG_INT: + wl_args[i].i = arg->v.i; + break; + case SHELL_WAYLAND_ARG_UINT: + wl_args[i].u = arg->v.u; + break; + case SHELL_WAYLAND_ARG_FIXED: + wl_args[i].f = arg->v.f; + break; + case SHELL_WAYLAND_ARG_STRING: + wl_args[i].s = arg->s; + break; + case SHELL_WAYLAND_ARG_OBJECT: + wl_args[i].o = (struct wl_object *) arg->o; + break; + case SHELL_WAYLAND_ARG_NEW_ID: + wl_args[i].n = arg->v.n; + break; + case SHELL_WAYLAND_ARG_FD: + wl_args[i].h = arg->v.h; + break; + case SHELL_WAYLAND_ARG_ARRAY: + if (arg->bytes) + { + gsize size; + gconstpointer data = g_bytes_get_data (arg->bytes, &size); + arrays[i].data = (void *) data; + arrays[i].size = size; + arrays[i].alloc = size; + } + wl_args[i].a = &arrays[i]; + break; + } + } + + wl_resource_post_event_array (resource->resource, opcode, wl_args); +} + +/** + * shell_wayland_resource_post_error: + * @resource: a #ShellWaylandResource + * @code: the protocol error code + * @message: a human readable description + * + * Posts a fatal protocol error to the client; the client will be + * disconnected. + */ +void +shell_wayland_resource_post_error (ShellWaylandResource *resource, + guint code, + const char *message) +{ + g_return_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource)); + + if (resource->resource) + wl_resource_post_error (resource->resource, code, "%s", message); +} + +void +shell_wayland_resource_destroy (ShellWaylandResource *resource) +{ + g_return_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource)); + + if (resource->owned && resource->resource) + wl_resource_destroy (resource->resource); +} + +/** + * shell_wayland_resource_get_meta_window: + * @resource: a #ShellWaylandResource + * + * If this resource is (or is backed by) a window-bearing surface role such as + * an xdg_toplevel, returns the corresponding window. + * + * Returns: (transfer none) (nullable): the #MetaWindow, or %NULL + */ +MetaWindow * +shell_wayland_resource_get_meta_window (ShellWaylandResource *resource) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource), NULL); + + if (!resource->resource) + return NULL; + + return META_WINDOW (meta_object_from_wl_resource (resource->resource)); +} + +/* ================================================================== */ +/* ShellWaylandClient */ +/* ================================================================== */ + +struct _ShellWaylandClient +{ + GObject parent_instance; + + struct wl_client *client; +}; + +G_DEFINE_FINAL_TYPE (ShellWaylandClient, shell_wayland_client, G_TYPE_OBJECT) + +static void +shell_wayland_client_class_init (ShellWaylandClientClass *klass) +{ +} + +static void +shell_wayland_client_init (ShellWaylandClient *self) +{ +} + +static ShellWaylandClient * +shell_wayland_client_new (struct wl_client *client) +{ + ShellWaylandClient *self; + + self = g_object_new (SHELL_TYPE_WAYLAND_CLIENT, NULL); + self->client = client; + + return self; +} + +/* The dispatcher and destructor used for every resource we create. */ +static int shell_wayland_dispatch (const void *impl, + void *target, + uint32_t opcode, + const struct wl_message *message, + union wl_argument *args); + +static void +shell_wayland_resource_destructor (struct wl_resource *resource) +{ + ShellWaylandResource *wrapper = wl_resource_get_user_data (resource); + + if (wrapper) + owned_resource_destroyed (wrapper); +} + +/** + * shell_wayland_client_create_resource: + * @client: a #ShellWaylandClient + * @iface: the interface to instantiate + * @version: the resource version + * @id: the new object id (from a new_id request argument) + * + * Creates a new resource for a new_id request argument and starts routing its + * requests through the #ShellWaylandResource::request signal. + * + * Returns: (transfer full): the new resource + */ +static ShellWaylandResource * +create_resource_internal (struct wl_client *client, + ShellWaylandInterface *iface, + guint version, + guint32 id) +{ + struct wl_resource *resource; + ShellWaylandResource *wrapper; + + resource = wl_resource_create (client, + shell_wayland_interface_seal (iface), + version, id); + if (!resource) + return NULL; + + wrapper = shell_wayland_resource_new_owned (resource); + wl_resource_set_dispatcher (resource, + shell_wayland_dispatch, + iface, wrapper, + shell_wayland_resource_destructor); + + /* Hand a reference to the caller; the resource keeps its own. */ + return g_object_ref (wrapper); +} + +ShellWaylandResource * +shell_wayland_client_create_resource (ShellWaylandClient *client, + ShellWaylandInterface *iface, + guint version, + guint32 id) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_CLIENT (client), NULL); + g_return_val_if_fail (SHELL_IS_WAYLAND_INTERFACE (iface), NULL); + + return create_resource_internal (client->client, iface, version, id); +} + +/** + * shell_wayland_resource_create_resource: + * @resource: a #ShellWaylandResource + * @iface: the interface to instantiate + * @version: the resource version + * @id: the new object id (from a new_id request argument) + * + * Convenience wrapper that creates a child resource on the same client as + * @resource. Use this from a #ShellWaylandResource::request handler to service + * a request that creates a new object. + * + * Returns: (transfer full): the new resource + */ +ShellWaylandResource * +shell_wayland_resource_create_resource (ShellWaylandResource *resource, + ShellWaylandInterface *iface, + guint version, + guint32 id) +{ + g_return_val_if_fail (SHELL_IS_WAYLAND_RESOURCE (resource), NULL); + g_return_val_if_fail (SHELL_IS_WAYLAND_INTERFACE (iface), NULL); + g_return_val_if_fail (resource->resource != NULL, NULL); + + return create_resource_internal (wl_resource_get_client (resource->resource), + iface, version, id); +} + +static int +shell_wayland_dispatch (const void *impl, + void *target, + uint32_t opcode, + const struct wl_message *message, + union wl_argument *args) +{ + struct wl_resource *resource = target; + ShellWaylandResource *wrapper = wl_resource_get_user_data (resource); + ShellWaylandArgs *wargs; + + wargs = shell_wayland_args_new_from_request (message->signature, args); + g_signal_emit (wrapper, resource_signals[RESOURCE_REQUEST], 0, + (guint) opcode, wargs); + g_object_unref (wargs); + + return 0; +} + +/* ================================================================== */ +/* ShellWaylandGlobal */ +/* ================================================================== */ + +struct _ShellWaylandGlobal +{ + GObject parent_instance; + + struct wl_global *global; + ShellWaylandInterface *iface; + GHashTable *clients; /* wl_client* -> ShellWaylandClient* */ +}; + +enum +{ + GLOBAL_BIND, + GLOBAL_N_SIGNALS +}; + +static guint global_signals[GLOBAL_N_SIGNALS]; + +G_DEFINE_FINAL_TYPE (ShellWaylandGlobal, shell_wayland_global, G_TYPE_OBJECT) + +static void +shell_wayland_global_finalize (GObject *object) +{ + ShellWaylandGlobal *self = SHELL_WAYLAND_GLOBAL (object); + + g_clear_pointer (&self->global, wl_global_destroy); + g_clear_object (&self->iface); + g_clear_pointer (&self->clients, g_hash_table_unref); + + G_OBJECT_CLASS (shell_wayland_global_parent_class)->finalize (object); +} + +static void +shell_wayland_global_class_init (ShellWaylandGlobalClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->finalize = shell_wayland_global_finalize; + + /** + * ShellWaylandGlobal::bind: + * @global: the global + * @resource: (transfer none): the freshly created resource for this binding + * @version: the version the client bound + * + * Emitted when a client binds the global. Connect to the resource's + * #ShellWaylandResource::request signal here to handle its requests. + */ + global_signals[GLOBAL_BIND] = + g_signal_new ("bind", + G_TYPE_FROM_CLASS (klass), + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, NULL, + G_TYPE_NONE, 2, + SHELL_TYPE_WAYLAND_RESOURCE, G_TYPE_UINT); +} + +static void +shell_wayland_global_init (ShellWaylandGlobal *self) +{ + self->clients = g_hash_table_new_full (NULL, NULL, NULL, g_object_unref); +} + +static void +shell_wayland_global_bind (struct wl_client *client, + void *data, + uint32_t version, + uint32_t id) +{ + ShellWaylandGlobal *self = data; + struct wl_resource *resource; + ShellWaylandResource *wrapper; + ShellWaylandClient *client_wrapper; + + resource = wl_resource_create (client, + shell_wayland_interface_seal (self->iface), + version, id); + if (!resource) + { + wl_client_post_no_memory (client); + return; + } + + wrapper = shell_wayland_resource_new_owned (resource); + wl_resource_set_dispatcher (resource, + shell_wayland_dispatch, + self->iface, wrapper, + shell_wayland_resource_destructor); + + client_wrapper = g_hash_table_lookup (self->clients, client); + if (!client_wrapper) + { + client_wrapper = shell_wayland_client_new (client); + g_hash_table_insert (self->clients, client, client_wrapper); + } + + g_signal_emit (self, global_signals[GLOBAL_BIND], 0, wrapper, (guint) version); +} + +void +shell_wayland_global_destroy (ShellWaylandGlobal *global) +{ + g_return_if_fail (SHELL_IS_WAYLAND_GLOBAL (global)); + + g_clear_pointer (&global->global, wl_global_destroy); +} + +/* ================================================================== */ +/* ShellWaylandServer */ +/* ================================================================== */ + +struct _ShellWaylandServer +{ + GObject parent_instance; + + struct wl_display *display; +}; + +G_DEFINE_FINAL_TYPE (ShellWaylandServer, shell_wayland_server, G_TYPE_OBJECT) + +static void +shell_wayland_server_class_init (ShellWaylandServerClass *klass) +{ +} + +static void +shell_wayland_server_init (ShellWaylandServer *self) +{ +} + +/** + * shell_wayland_server_get_default: + * + * Returns: (transfer none) (nullable): the singleton server bound to the + * compositor's Wayland display, or %NULL when not running on Wayland + */ +ShellWaylandServer * +shell_wayland_server_get_default (void) +{ + static ShellWaylandServer *instance; + MetaContext *context; + MetaWaylandCompositor *compositor; + + if (instance) + return instance; + + context = shell_global_get_context (shell_global_get ()); + if (!context) + return NULL; + + compositor = meta_context_get_wayland_compositor (context); + if (!compositor) + return NULL; + + instance = g_object_new (SHELL_TYPE_WAYLAND_SERVER, NULL); + instance->display = meta_wayland_compositor_get_wayland_display (compositor); + + return instance; +} + +/** + * shell_wayland_server_create_global: + * @server: a #ShellWaylandServer + * @iface: the interface to advertise + * @version: the maximum version to advertise + * + * Advertises a new global to clients. Connect to the returned global's + * #ShellWaylandGlobal::bind signal to service binds. + * + * Returns: (transfer full): the new global + */ +ShellWaylandGlobal * +shell_wayland_server_create_global (ShellWaylandServer *server, + ShellWaylandInterface *iface, + guint version) +{ + ShellWaylandGlobal *global; + + g_return_val_if_fail (SHELL_IS_WAYLAND_SERVER (server), NULL); + g_return_val_if_fail (SHELL_IS_WAYLAND_INTERFACE (iface), NULL); + + global = g_object_new (SHELL_TYPE_WAYLAND_GLOBAL, NULL); + global->iface = g_object_ref (iface); + global->global = wl_global_create (server->display, + shell_wayland_interface_seal (iface), + version, + global, + shell_wayland_global_bind); + + return global; +} diff --git a/src/shell-wayland.h b/src/shell-wayland.h new file mode 100644 index 0000000000..805a997761 --- /dev/null +++ b/src/shell-wayland.h @@ -0,0 +1,152 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ +#pragma once + +#include +#include + +G_BEGIN_DECLS + +/** + * ShellWaylandArgType: + * @SHELL_WAYLAND_ARG_INT: a signed 32-bit integer ('i') + * @SHELL_WAYLAND_ARG_UINT: an unsigned 32-bit integer ('u') + * @SHELL_WAYLAND_ARG_FIXED: a 24.8 signed fixed point number ('f') + * @SHELL_WAYLAND_ARG_STRING: a string ('s') + * @SHELL_WAYLAND_ARG_OBJECT: an existing object, a #ShellWaylandResource ('o') + * @SHELL_WAYLAND_ARG_NEW_ID: a newly created object id ('n') + * @SHELL_WAYLAND_ARG_ARRAY: a blob of bytes, a #GBytes ('a') + * @SHELL_WAYLAND_ARG_FD: a file descriptor ('h') + * + * The Wayland wire argument types, matching the characters used in a + * `wl_message` signature. + */ +typedef enum +{ + SHELL_WAYLAND_ARG_INT, + SHELL_WAYLAND_ARG_UINT, + SHELL_WAYLAND_ARG_FIXED, + SHELL_WAYLAND_ARG_STRING, + SHELL_WAYLAND_ARG_OBJECT, + SHELL_WAYLAND_ARG_NEW_ID, + SHELL_WAYLAND_ARG_ARRAY, + SHELL_WAYLAND_ARG_FD, +} ShellWaylandArgType; + +/* Type declarations first, so the cross-referencing methods below resolve. */ + +#define SHELL_TYPE_WAYLAND_INTERFACE (shell_wayland_interface_get_type ()) +G_DECLARE_FINAL_TYPE (ShellWaylandInterface, shell_wayland_interface, + SHELL, WAYLAND_INTERFACE, GObject) + +#define SHELL_TYPE_WAYLAND_ARGS (shell_wayland_args_get_type ()) +G_DECLARE_FINAL_TYPE (ShellWaylandArgs, shell_wayland_args, + SHELL, WAYLAND_ARGS, GObject) + +#define SHELL_TYPE_WAYLAND_RESOURCE (shell_wayland_resource_get_type ()) +G_DECLARE_FINAL_TYPE (ShellWaylandResource, shell_wayland_resource, + SHELL, WAYLAND_RESOURCE, GObject) + +#define SHELL_TYPE_WAYLAND_CLIENT (shell_wayland_client_get_type ()) +G_DECLARE_FINAL_TYPE (ShellWaylandClient, shell_wayland_client, + SHELL, WAYLAND_CLIENT, GObject) + +#define SHELL_TYPE_WAYLAND_GLOBAL (shell_wayland_global_get_type ()) +G_DECLARE_FINAL_TYPE (ShellWaylandGlobal, shell_wayland_global, + SHELL, WAYLAND_GLOBAL, GObject) + +#define SHELL_TYPE_WAYLAND_SERVER (shell_wayland_server_get_type ()) +G_DECLARE_FINAL_TYPE (ShellWaylandServer, shell_wayland_server, + SHELL, WAYLAND_SERVER, GObject) + +/* ShellWaylandInterface: a dynamically built wl_interface */ + +ShellWaylandInterface * shell_wayland_interface_new (const char *name, + int version); + +void shell_wayland_interface_add_request (ShellWaylandInterface *iface, + const char *name, + const char *signature, + guint *arg_indices, + int n_arg_indices, + ShellWaylandInterface **arg_types, + int n_arg_types); + +void shell_wayland_interface_add_event (ShellWaylandInterface *iface, + const char *name, + const char *signature, + guint *arg_indices, + int n_arg_indices, + ShellWaylandInterface **arg_types, + int n_arg_types); + +const char * shell_wayland_interface_get_name (ShellWaylandInterface *iface); + +/* ShellWaylandArgs: a typed view over a list of wire arguments. Used both to + * read the arguments of an incoming request and to build the arguments of an + * outgoing event. */ + +ShellWaylandArgs * shell_wayland_args_new (void); + +guint shell_wayland_args_get_length (ShellWaylandArgs *args); + +/* Builders (for events) */ +void shell_wayland_args_add_int (ShellWaylandArgs *args, gint32 value); +void shell_wayland_args_add_uint (ShellWaylandArgs *args, guint32 value); +void shell_wayland_args_add_fixed (ShellWaylandArgs *args, double value); +void shell_wayland_args_add_string (ShellWaylandArgs *args, const char *value); +void shell_wayland_args_add_object (ShellWaylandArgs *args, ShellWaylandResource *resource); +void shell_wayland_args_add_new_id (ShellWaylandArgs *args, guint32 value); +void shell_wayland_args_add_fd (ShellWaylandArgs *args, int fd); +void shell_wayland_args_add_array (ShellWaylandArgs *args, GBytes *bytes); + +/* Accessors (for requests) */ +gint32 shell_wayland_args_get_int (ShellWaylandArgs *args, guint index); +guint32 shell_wayland_args_get_uint (ShellWaylandArgs *args, guint index); +double shell_wayland_args_get_fixed (ShellWaylandArgs *args, guint index); +const char * shell_wayland_args_get_string (ShellWaylandArgs *args, guint index); +ShellWaylandResource * shell_wayland_args_get_object (ShellWaylandArgs *args, guint index); +guint32 shell_wayland_args_get_new_id (ShellWaylandArgs *args, guint index); +int shell_wayland_args_get_fd (ShellWaylandArgs *args, guint index); +GBytes * shell_wayland_args_get_array (ShellWaylandArgs *args, guint index); + +/* ShellWaylandResource: wraps a wl_resource */ + +guint32 shell_wayland_resource_get_id (ShellWaylandResource *resource); +guint shell_wayland_resource_get_version (ShellWaylandResource *resource); +const char *shell_wayland_resource_get_class (ShellWaylandResource *resource); + +void shell_wayland_resource_post_event (ShellWaylandResource *resource, + guint opcode, + ShellWaylandArgs *args); +void shell_wayland_resource_post_error (ShellWaylandResource *resource, + guint code, + const char *message); +void shell_wayland_resource_destroy (ShellWaylandResource *resource); + +ShellWaylandResource * shell_wayland_resource_create_resource (ShellWaylandResource *resource, + ShellWaylandInterface *iface, + guint version, + guint32 id); + +MetaWindow * shell_wayland_resource_get_meta_window (ShellWaylandResource *resource); + +/* ShellWaylandClient: wraps a wl_client */ + +ShellWaylandResource * shell_wayland_client_create_resource (ShellWaylandClient *client, + ShellWaylandInterface *iface, + guint version, + guint32 id); + +/* ShellWaylandGlobal: a wl_global advertised to clients */ + +void shell_wayland_global_destroy (ShellWaylandGlobal *global); + +/* ShellWaylandServer: the entry point, wrapping the compositor's wl_display */ + +ShellWaylandServer * shell_wayland_server_get_default (void); + +ShellWaylandGlobal * shell_wayland_server_create_global (ShellWaylandServer *server, + ShellWaylandInterface *iface, + guint version); + +G_END_DECLS -- GitLab From 4cec576ce5f6ba81580dee8d87c3cd0e3b2620ce Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Tue, 16 Jun 2026 02:08:24 -0700 Subject: [PATCH 2/5] js: Add a Wayland protocol helper for the server binding Add misc/waylandProtocol.js, a small helper layer over Shell.WaylandServer: defineInterface() builds a wl_interface from a request/event description, and buildArgs() marshals event arguments. This keeps protocol implementations declarative and free of the low-level binding boilerplate. Assisted-By: Claude Opus 4.8 --- js/js-resources.gresource.xml | 1 + js/misc/waylandProtocol.js | 127 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 js/misc/waylandProtocol.js diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml index c16db5a4c0..ba65986611 100644 --- a/js/js-resources.gresource.xml +++ b/js/js-resources.gresource.xml @@ -45,6 +45,7 @@ misc/systemActions.js misc/timeLimitsManager.js misc/util.js + misc/waylandProtocol.js misc/weather.js ui/accessDialog.js diff --git a/js/misc/waylandProtocol.js b/js/misc/waylandProtocol.js new file mode 100644 index 0000000000..55c57ae9d3 --- /dev/null +++ b/js/misc/waylandProtocol.js @@ -0,0 +1,127 @@ +// Helpers for implementing Wayland protocols from JavaScript on top of the +// generic Shell.WaylandServer binding. +// +// A protocol interface is described declaratively and turned into a +// Shell.WaylandInterface (a dynamically built wl_interface). The returned +// wrapper also carries the request/event opcode maps so handlers can switch on +// symbolic names instead of magic numbers. +// +// Example: +// +// const iface = defineInterface('wl_fixes', 1, { +// requests: [ +// {name: 'destroy', signature: ''}, +// {name: 'destroy_registry', signature: 'o', types: [null]}, +// ], +// }); +// const global = WaylandServer.get().createGlobal(iface, 1); +// global.connect('bind', (g, resource) => { ... }); + +import Shell from 'gi://Shell'; + +/** + * Build a Shell.WaylandInterface from a declarative description. + * + * @param {string} name the wire name, e.g. 'zxdg_decoration_manager_v1' + * @param {number} version the highest supported version + * @param {object} description an object with optional `requests` and `events` + * arrays. Each message is `{name, signature, types}` where `types` is an + * array aligned with the object/new_id arguments in the signature, holding + * the referenced Shell.WaylandInterface (or the result of a previous + * defineInterface() call) for typed arguments and `null` otherwise. + * @param {Array} [description.requests] the request messages + * @param {Array} [description.events] the event messages + * @returns {object} `{iface, req, evt}` where `iface` is the + * Shell.WaylandInterface and `req`/`evt` map message names to opcodes + */ +export function defineInterface(name, version, {requests = [], events = []} = {}) { + const iface = Shell.WaylandInterface.new(name, version); + const req = {}; + const evt = {}; + + // Turn the per-argument `types` array (which may contain null for untyped + // object arguments) into the sparse [indices], [interfaces] pair the C API + // expects. This is necessary because GJS rejects null elements in a + // GObject-typed array. + const splitTypes = types => { + const indices = []; + const interfaces = []; + (types ?? []).forEach((type, index) => { + const resolved = type && type.iface ? type.iface : type; + if (resolved) { + indices.push(index); + interfaces.push(resolved); + } + }); + return [indices, interfaces]; + }; + + requests.forEach((message, opcode) => { + const [indices, interfaces] = splitTypes(message.types); + iface.add_request(message.name, message.signature ?? '', + indices, interfaces); + req[message.name] = opcode; + }); + + events.forEach((message, opcode) => { + const [indices, interfaces] = splitTypes(message.types); + iface.add_event(message.name, message.signature ?? '', + indices, interfaces); + evt[message.name] = opcode; + }); + + return {iface, req, evt}; +} + +/** + * Convenience accessor for the singleton Wayland server. + * + * @returns {Shell.WaylandServer|null} the server, or null when not on Wayland + */ +export function getServer() { + return Shell.WaylandServer.get_default(); +} + +/** + * Build a Shell.WaylandArgs from a list of `[type, value]` pairs, for posting + * events. Supported types: 'i', 'u', 'f', 's', 'o', 'n', 'h', 'a'. + * + * @param {Array} pairs the typed arguments + * @returns {Shell.WaylandArgs} the populated argument list + */ +export function buildArgs(pairs) { + const args = Shell.WaylandArgs.new(); + + for (const [type, value] of pairs) { + switch (type) { + case 'i': + args.add_int(value); + break; + case 'u': + args.add_uint(value); + break; + case 'f': + args.add_fixed(value); + break; + case 's': + args.add_string(value); + break; + case 'o': + args.add_object(value); + break; + case 'n': + args.add_new_id(value); + break; + case 'h': + args.add_fd(value); + break; + case 'a': + args.add_array(value); + break; + default: + throw new Error(`Unknown Wayland argument type '${type}'`); + } + } + + return args; +} -- GitLab From 5b808f4fe9323b12c3222f3ad2966382274a851e Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Tue, 16 Jun 2026 02:08:38 -0700 Subject: [PATCH 3/5] serverDecoration: Implement server-side window decorations Implement the zxdg_decoration_manager_v1 protocol in JavaScript, on top of Shell.WaylandServer and the Wayland protocol helper. When a client requests server-side decorations, the shell draws an Adwaita-style title bar for the window instead of letting the client draw its own. The bar mirrors the user's color scheme and configured button layout (showing only the buttons the window supports), parents itself inside the window's compositor actor so it scales and fades with the maximize/minimize animations and shows up in overview previews, and hands moves and edge/corner resizes off to mutter's grab ops. A surrounding ring of grab zones gives directional resize cursors. The manager advertises version 2 and reserves a top strip via mutter's decoration-extents API when present (degrading to a floating overlay otherwise), so placement, constraints, maximization and tiling all account for the bar. Wired up in main.js via ServerDecorationManager. Assisted-By: Claude Opus 4.8 --- js/js-resources.gresource.xml | 1 + js/ui/main.js | 5 + js/ui/serverDecoration.js | 906 ++++++++++++++++++++++++++++++++++ 3 files changed, 912 insertions(+) create mode 100644 js/ui/serverDecoration.js diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml index ba65986611..8cbbad6156 100644 --- a/js/js-resources.gresource.xml +++ b/js/js-resources.gresource.xml @@ -111,6 +111,7 @@ ui/scripting.js ui/search.js ui/searchController.js + ui/serverDecoration.js ui/sessionMode.js ui/shellDBus.js ui/shellEntry.js diff --git a/js/ui/main.js b/js/ui/main.js index 01a18b5a2d..3a202b46fe 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -34,6 +34,7 @@ import * as NotificationDaemon from './notificationDaemon.js'; import * as WindowAttentionHandler from './windowAttentionHandler.js'; import * as Screenshot from './screenshot.js'; import * as ScreenShield from './screenShield.js'; +import * as ServerDecoration from './serverDecoration.js'; import * as SessionMode from './sessionMode.js'; import * as ShellDBus from './shellDBus.js'; import * as ShellMountOperation from './shellMountOperation.js'; @@ -73,6 +74,7 @@ export let osdWindowManager = null; export let osdMonitorLabeler = null; export let sessionMode = null; export let screenshotUI = null; +export let serverDecorationManager = null; export let shellAccessDialogDBusService = null; export let shellAudioSelectionDBusService = null; export let shellDBusService = null; @@ -251,6 +253,9 @@ async function _initializeUI() { windowAttentionHandler = new WindowAttentionHandler.WindowAttentionHandler(); componentManager = new Components.ComponentManager(); + serverDecorationManager = new ServerDecoration.ServerDecorationManager(); + serverDecorationManager.enable(); + introspectService = new Introspect.IntrospectService(); // Set up the global default break reminder manager and its D-Bus interface diff --git a/js/ui/serverDecoration.js b/js/ui/serverDecoration.js new file mode 100644 index 0000000000..3b149e0847 --- /dev/null +++ b/js/ui/serverDecoration.js @@ -0,0 +1,906 @@ +// Server-side window decorations for Wayland clients. +// +// This implements the zxdg_decoration_manager_v1 protocol entirely in +// JavaScript, on top of the generic Shell.WaylandServer binding. When a client +// asks for server-side decorations we draw an Adwaita-style title bar as a +// shell overlay kept in lockstep with the window geometry, rather than letting +// the client draw its own client-side decorations. + +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Graphene from 'gi://Graphene'; +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import St from 'gi://St'; + +import {defineInterface, buildArgs} from '../misc/waylandProtocol.js'; + +const DECORATION_MANAGER_VERSION = 2; + +const TITLEBAR_HEIGHT = 38; + +// Width of the invisible resize grab zones placed just outside the window's +// frame rect. +const RESIZE_BORDER = 8; + +// The eight resize zones forming a ring around the window. Each entry names the +// mutter resize grab op (Meta.GrabOp.RESIZING_) and computes the zone's +// geometry, in the border container's local coordinates, from the frame size +// (w, h) and the border width (b). The container is offset by (-b, -b) from the +// frame rect, so the ring sits entirely outside the window content. +const RESIZE_ZONES = [ + {op: 'NW', rect: (w, h, b) => [0, 0, b, b]}, + {op: 'N', rect: (w, h, b) => [b, 0, w, b]}, + {op: 'NE', rect: (w, h, b) => [b + w, 0, b, b]}, + {op: 'W', rect: (w, h, b) => [0, b, b, h]}, + {op: 'E', rect: (w, h, b) => [b + w, b, b, h]}, + {op: 'SW', rect: (w, h, b) => [0, b + h, b, b]}, + {op: 'S', rect: (w, h, b) => [b, b + h, w, b]}, + {op: 'SE', rect: (w, h, b) => [b + w, b + h, b, b]}, +]; + +/** zxdg_toplevel_decoration_v1.mode */ +const Mode = { + CLIENT_SIDE: 1, + SERVER_SIDE: 2, +}; + +/** zxdg_toplevel_decoration_v1.error */ +const DecorationError = { + UNCONFIGURED_BUFFER: 0, + ALREADY_CONSTRUCTED: 1, + ORPHANED: 2, + INVALID_MODE: 3, +}; + +// Interface definitions are built lazily from enable(), not at module load +// time. Building them touches GObject-introspected Shell.Wayland* API; doing it +// during import would let a malformed definition (or a build without that API) +// throw while main.js is being imported, which aborts shell startup. Built on +// demand, a failure is logged and the feature simply stays disabled. +let ToplevelDecorationIface = null; +let DecorationManagerIface = null; + +function ensureInterfaces() { + if (DecorationManagerIface) + return true; + + try { + // The toplevel-decoration interface must be defined first so the + // manager can reference it for its get_toplevel_decoration new_id + // argument. + const deco = defineInterface('zxdg_toplevel_decoration_v1', 2, { + requests: [ + {name: 'destroy', signature: ''}, + {name: 'set_mode', signature: 'u'}, + {name: 'unset_mode', signature: ''}, + ], + events: [ + {name: 'configure', signature: 'u'}, + ], + }); + + const manager = defineInterface('zxdg_decoration_manager_v1', 2, { + requests: [ + {name: 'destroy', signature: ''}, + // new_id zxdg_toplevel_decoration_v1, object xdg_toplevel + { + name: 'get_toplevel_decoration', signature: 'no', + types: [deco, null], + }, + ], + }); + + ToplevelDecorationIface = deco; + DecorationManagerIface = manager; + return true; + } catch (e) { + logError(e, 'Failed to build server-side decoration protocol interfaces'); + return false; + } +} + +// Adwaita light/dark palettes for the title bar, mirroring the headerbar +// colours used by GTK. +const PALETTES = { + light: { + background: '#fafafb', + border: 'rgba(0, 0, 0, 0.15)', + text: '#2e3436', + buttonBackground: 'rgba(0, 0, 0, 0.08)', + buttonHover: 'rgba(0, 0, 0, 0.13)', + }, + dark: { + background: '#303030', + border: 'rgba(0, 0, 0, 0.5)', + text: '#ffffff', + buttonBackground: 'rgba(255, 255, 255, 0.10)', + buttonHover: 'rgba(255, 255, 255, 0.16)', + }, +}; + +// An Adwaita-style title bar drawn over a window. While Mutter does not +// support Server Side Decoration of Wayland windows as of this writing, it +// does have a new addition paired with this work, the ability to reserve +// space outside of the existing client rectangle, allowing for proper +// window tracking for the decoration strip. The tracking is activated or +// removed as needed, depending on the window state. +// +// It mirrors the user's configured appearance: the light/dark colour scheme +// (org.gnome.desktop.interface color-scheme), and the window button layout and +// placement (org.gnome.desktop.wm.preferences button-layout), while only +// showing buttons the window actually supports. +const WindowTitlebar = GObject.registerClass( +class WindowTitlebar extends St.BoxLayout { + _init(window) { + super._init({ + style_class: 'ssd-titlebar', + reactive: true, + track_hover: true, + }); + + this._window = window; + this._buttons = []; + + this._leftBox = new St.BoxLayout({style: 'spacing: 6px;'}); + this._title = new St.Label({ + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._rightBox = new St.BoxLayout({style: 'spacing: 6px;'}); + this.add_child(this._leftBox); + this.add_child(this._title); + this.add_child(this._rightBox); + + // Interactions use the Clutter gesture framework rather than raw + // button-press handlers, because St.Button itself recognises clicks via + // a ClutterPressGesture; a legacy handler that consumed presses here + // would stop those child gestures from ever recognising. The gesture is + // added in the TARGET phase so a press that lands on a child button is + // still seen here, but we pick the press target and bail when it is a + // window-control button, leaving the button to handle its own click. + // + // The gesture recognises on press (recognize_on_press) and immediately + // hands the drag to mutter's MOVING grab. Beginning a grab on press — + // rather than waiting for a motion threshold — is what makes the move + // reliable: once the pointer crosses onto the client's own Wayland + // surface, the shell no longer sees motion events, so a + // threshold-based gesture would never recognise if the pointer left the + // bar first. Because starting the grab cancels our Clutter sequence, + // double-click-to-maximize cannot be detected by a second gesture, so + // we time consecutive presses ourselves. + this._pressGesture = new Clutter.ClickGesture({recognize_on_press: true}); + this._pressGesture.connect('recognize', () => this._onTitlebarPressed()); + this.add_action_full('titlebar-press', + Clutter.EventPhase.TARGET, this._pressGesture); + + this._stSettings = St.Settings.get(); + this._colorSchemeId = this._stSettings.connect('notify::color-scheme', + () => this._applyColors()); + + // Rebuild when the configured button layout changes. + this._wmSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.wm.preferences', + }); + this._buttonLayoutId = this._wmSettings.connect('changed::button-layout', + () => this._rebuildButtons()); + + this._titleChangedId = + window.connect('notify::title', () => this._syncTitle()); + + this._rebuildButtons(); + this._applyColors(); + this._syncTitle(); + } + + _isDark() { + return this._stSettings.color_scheme === St.SystemColorScheme.PREFER_DARK; + } + + _palette() { + return this._isDark() ? PALETTES.dark : PALETTES.light; + } + + _applyColors() { + const palette = this._palette(); + + // A maximized (or tiled) window fills its work area edge to edge, so + // its title bar gets square corners, matching Adwaita. + const radius = this._window.is_maximized() ? '0' : '12px 12px 0 0'; + + this.style = ` + background-color: ${palette.background}; + border-bottom: 1px solid ${palette.border}; + border-radius: ${radius}; + padding: 0 6px; + spacing: 6px;`; + this._title.style = `color: ${palette.text}; font-weight: bold;`; + + for (const button of this._buttons) + this._styleButton(button); + } + + _styleButton(button) { + const palette = this._palette(); + const background = button.hover + ? palette.buttonHover : palette.buttonBackground; + + button.style = ` + width: 24px; + height: 24px; + border-radius: 12px; + background-color: ${background};`; + button.get_child().style = `color: ${palette.text};`; + } + + _rebuildButtons() { + this._leftBox.remove_all_children(); + this._rightBox.remove_all_children(); + this._buttons = []; + this._maximizeButton = null; + + const layout = Meta.prefs_get_button_layout(); + for (const fn of layout.left_buttons) + this._addButtonFor(this._leftBox, fn); + for (const fn of layout.right_buttons) + this._addButtonFor(this._rightBox, fn); + + this._updateMaximizeIcon(); + } + + _addButtonFor(box, fn) { + switch (fn) { + case Meta.ButtonFunction.MINIMIZE: + if (this._window.can_minimize()) { + this._addButton(box, 'window-minimize-symbolic', + () => this._window.minimize()); + } + break; + case Meta.ButtonFunction.MAXIMIZE: + if (this._window.can_maximize()) { + this._maximizeButton = this._addButton(box, + 'window-maximize-symbolic', () => this._toggleMaximize()); + } + break; + case Meta.ButtonFunction.CLOSE: + if (this._window.can_close()) { + this._addButton(box, 'window-close-symbolic', + () => this._window.delete(global.get_current_time())); + } + break; + default: + // MENU/APPMENU/spacers are not represented in the overlay. + break; + } + } + + _addButton(box, iconName, onClicked) { + const button = new St.Button({ + child: new St.Icon({ + icon_name: iconName, + icon_size: 16, + }), + y_align: Clutter.ActorAlign.CENTER, + }); + button.connect('clicked', onClicked); + button.connect('notify::hover', () => this._styleButton(button)); + box.add_child(button); + this._buttons.push(button); + this._styleButton(button); + return button; + } + + _syncTitle() { + this._title.text = this._window.get_title() ?? ''; + } + + _updateMaximizeIcon() { + const maximized = this._window.is_maximized(); + + // Refresh the corner style (rounded vs square) when the maximized + // state changes; cache it so we do not restyle on every geometry sync. + if (this._maximized !== maximized) { + this._maximized = maximized; + this._applyColors(); + } + + if (this._maximizeButton) { + this._maximizeButton.get_child().icon_name = maximized + ? 'window-restore-symbolic' : 'window-maximize-symbolic'; + } + } + + _toggleMaximize() { + if (this._window.is_maximized()) + this._window.unmaximize(); + else + this._window.maximize(); + } + + _isWindowControl(actor) { + // True when the picked actor is one of our title-bar buttons (or a + // descendant of one), so we can leave the press to the button. + for (let a = actor; a && a !== this; a = a.get_parent()) { + if (this._buttons.includes(a)) + return true; + } + return false; + } + + _isDoubleClick(x, y, time) { + const settings = Clutter.Settings.get_default(); + const withinTime = this._lastPressTime > 0 && + time - this._lastPressTime <= settings.double_click_time; + const distance = settings.double_click_distance; + const withinDistance = + Math.abs(x - this._lastPressX) <= distance && + Math.abs(y - this._lastPressY) <= distance; + return withinTime && withinDistance; + } + + _onTitlebarPressed() { + if (this._pressGesture.get_button() !== Clutter.BUTTON_PRIMARY) + return; + + const event = this._pressGesture.get_point_event(0); + if (!event) + return; + const [x, y] = event.get_coords(); + const time = event.get_time(); + + // The gesture also fires for presses on child buttons; let those be + // handled by the button itself. + const target = + global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); + if (this._isWindowControl(target)) + return; + + // A press on the bar always brings the window to the foreground. + this._window.activate(time); + + // Second press of a double-click: maximize instead of moving. The grab + // started by the first press is a no-op when no motion followed, so the + // window did not actually move. + if (this._isDoubleClick(x, y, time)) { + this._lastPressTime = 0; + this._toggleMaximize(); + return; + } + + this._lastPressTime = time; + this._lastPressX = x; + this._lastPressY = y; + + // Hand the drag to mutter, which moves the window (with edge snapping, + // tiling, etc.) until the button is released. Starting the grab on the + // press — rather than after a motion threshold — keeps moving reliable + // even when the pointer immediately leaves the bar. + const backend = global.stage.get_context().get_backend(); + const sprite = backend.get_sprite(global.stage, event); + this._window.begin_grab_op(Meta.GrabOp.MOVING, sprite, time, + new Graphene.Point({x, y})); + } + + destroy() { + if (this._titleChangedId) { + this._window.disconnect(this._titleChangedId); + this._titleChangedId = 0; + } + if (this._colorSchemeId) { + this._stSettings.disconnect(this._colorSchemeId); + this._colorSchemeId = 0; + } + if (this._buttonLayoutId) { + this._wmSettings.disconnect(this._buttonLayoutId); + this._buttonLayoutId = 0; + } + super.destroy(); + } +}); + +// An invisible ring of eight grab zones (four edges, four corners) placed just +// outside a window's frame rect. Each zone hands a drag off to mutter's resize +// grab op for that direction, the same way the title bar hands a drag off to +// the MOVING grab. The container itself is not reactive and the zones lie +// entirely outside the window content, so the client keeps receiving its own +// pointer events; only the surrounding ring resizes. +const WindowResizeBorder = GObject.registerClass( +class WindowResizeBorder extends Clutter.Actor { + _init(window) { + super._init({reactive: false}); + + this._window = window; + this._zones = []; + + for (const {op} of RESIZE_ZONES) { + const grabOp = Meta.GrabOp[`RESIZING_${op}`]; + const zone = new Clutter.Actor({reactive: true}); + + // Give each zone its directional resize cursor so the otherwise + // invisible grab regions are discoverable on hover. The op names + // (N, NE, …) line up with Clutter's cursor enum (N_RESIZE, …). + zone.set_cursor_type(Clutter.CursorType[`${op}_RESIZE`]); + + // Recognise on press and immediately start mutter's resize grab, + // which takes over the pointer (breaking our sequence, so the + // framework cancels the gesture) and drives the resize until + // release. Beginning on press — rather than after a motion + // threshold — is essential here: the zones are only a few pixels + // wide, so a threshold-based gesture would routinely lose the + // pointer off the zone before it ever recognised. + const gesture = new Clutter.ClickGesture({recognize_on_press: true}); + gesture.connect('recognize', + () => this._onZoneRecognize(gesture, grabOp)); + zone.add_action_full(`resize-${op.toLowerCase()}`, + Clutter.EventPhase.TARGET, gesture); + + this.add_child(zone); + this._zones.push(zone); + } + } + + syncGeometry(rect) { + const b = RESIZE_BORDER; + this.set_position(rect.x - b, rect.y - b); + this.set_size(rect.width + 2 * b, rect.height + 2 * b); + + RESIZE_ZONES.forEach(({rect: zoneRect}, i) => { + const [x, y, w, h] = zoneRect(rect.width, rect.height, b); + this._zones[i].set_position(x, y); + this._zones[i].set_size(w, h); + }); + } + + _onZoneRecognize(gesture, grabOp) { + if (gesture.get_button() !== Clutter.BUTTON_PRIMARY) + return; + + const event = gesture.get_point_event(0); + if (!event) + return; + + const time = event.get_time(); + + // Focus and raise the window before starting the grab. Otherwise the + // resize op begins while another window still holds the focus, and the + // first frame of the drag is applied to that window until focus catches + // up to this one. + this._window.activate(time); + + const backend = global.stage.get_context().get_backend(); + const sprite = backend.get_sprite(global.stage, event); + const [x, y] = event.get_coords(); + this._window.begin_grab_op(grabOp, sprite, time, + new Graphene.Point({x, y})); + } +}); + +// Tracks a single zxdg_toplevel_decoration_v1 resource: negotiates the mode +// and owns the title bar actor. +class ToplevelDecoration { + constructor(manager, resource, window) { + this._manager = manager; + this._resource = resource; + this._window = window; + this._mode = Mode.SERVER_SIDE; + this._titlebar = null; + this._barInActor = false; + this._windowSignalIds = []; + this._restackedId = 0; + this._actorDestroyId = 0; + this._actorVisibleId = 0; + this._negotiated = false; + this._initialApplyId = 0; + + resource.connect('request', this._onRequest.bind(this)); + resource.connect('destroy', () => this._onDestroyed()); + + // Announce our preferred (server-side) mode, but do not reserve the + // decoration strip yet. A client that wants client-side decorations + // (e.g. a GTK app or Ghostty) sends set_mode(CLIENT_SIDE) right after + // get_toplevel_decoration; reserving immediately would shrink its + // initial size before that request is processed. Defer the first apply + // to an idle so the client's initial mode request settles first. + this._sendConfigure(this._mode); + this._initialApplyId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._initialApplyId = 0; + this._negotiated = true; + this._applyMode(); + return GLib.SOURCE_REMOVE; + }); + } + + _onRequest(resource, opcode, args) { + try { + this._handleRequest(resource, opcode, args); + } catch (e) { + logError(e, 'Error handling toplevel decoration request'); + } + } + + _handleRequest(resource, opcode, args) { + const {req} = ToplevelDecorationIface; + + switch (opcode) { + case req.destroy: + resource.destroy(); + break; + case req.set_mode: { + const mode = args.get_uint(0); + if (mode !== Mode.CLIENT_SIDE && mode !== Mode.SERVER_SIDE) { + resource.post_error(DecorationError.INVALID_MODE, + 'invalid decoration mode'); + return; + } + this._mode = mode; + this._sendConfigure(this._mode); + // Before the initial negotiation settles, just record the mode; + // the pending idle applies it (so a CLIENT_SIDE request never + // briefly reserves the strip). Runtime toggles apply immediately. + if (this._negotiated) + this._applyMode(); + break; + } + case req.unset_mode: + this._mode = Mode.SERVER_SIDE; + this._sendConfigure(this._mode); + if (this._negotiated) + this._applyMode(); + break; + } + } + + _sendConfigure(mode) { + this._resource.post_event(ToplevelDecorationIface.evt.configure, + buildArgs([['u', mode]])); + } + + _applyMode() { + if (this._mode === Mode.SERVER_SIDE) + this._ensureTitlebar(); + else + this._removeTitlebar(); + } + + _ensureTitlebar() { + if (this._titlebar || !this._window) + return; + + // Ask mutter to reserve a top strip for our title bar (when the build + // supports it). The window's frame rect then grows to include the + // strip, so placement, on-screen constraints, maximization and tiling + // all account for it, and the client is sized to sit below it. A + // fullscreen window gets no decorations, so reserve nothing while it is + // fullscreen. + this._reserveStrip(this._stripHeight()); + + this._titlebar = new WindowTitlebar(this._window); + + // Parent the bar inside the window's own compositor actor (a sibling of + // the surface, drawn above it) rather than floating it as a separate + // actor in window_group. This makes the bar a genuine part of the + // window's visual tree: it scales and fades together with the window + // during the maximize/unmaximize/minimize animations mutter runs on the + // actor, and it is reproduced wherever the shell clones the window + // (overview previews, the window switcher, workspace thumbnails), + // because those clone the window actor and all of its children. The + // window-preview bounding box already reserves the strip, since it is + // derived from the frame rect that our decoration extents grow. + // + // The bar's position is then expressed in actor-local coordinates, + // which stay constant as the window moves, so a move needs no re-sync — + // only a resize changes the bar's width. If the actor is somehow not + // present yet, fall back to a floating sibling in window_group kept in + // lockstep via the geometry signals. + const actor = this._window.get_compositor_private(); + this._barInActor = !!actor; + if (actor) + actor.add_child(this._titlebar); + else + global.window_group.add_child(this._titlebar); + + // A ring of resize grab zones around the window, so the user can resize + // it from any edge or corner exactly like a client-decorated window. + // The ring stays a window_group sibling: it is invisible, exists only to + // catch pointer input on the edges outside the client, and should track + // the live frame rect rather than animate with the window. + this._resizeBorder = new WindowResizeBorder(this._window); + global.window_group.add_child(this._resizeBorder); + + const sync = () => this._syncGeometry(); + this._windowSignalIds.push(this._window.connect('position-changed', sync)); + this._windowSignalIds.push(this._window.connect('size-changed', sync)); + + // The minimized property flips when the minimize animation begins, + // whereas the actor's visibility only changes once it ends; tracking it + // hides the (window_group) resize ring up front instead of letting it + // linger over the shrinking window. + this._windowSignalIds.push( + this._window.connect('notify::minimized', () => this._restack())); + + // Drop the reserved strip (and hide the bar) while fullscreen, and + // restore it on the way back out. + this._windowSignalIds.push( + this._window.connect('notify::fullscreen', + () => this._updateFullscreen())); + + // Keep the bar and resize ring pinned to their window in the stack. A + // bar inside the window actor follows it automatically, but the + // window_group resize ring (and a fallback floating bar) must be + // re-raised just above their window whenever stacking changes (focus, a + // new window, etc.). + this._restackedId = + global.display.connect('restacked', () => this._restack()); + + if (actor) { + // Reset our state if the window actor is torn down underneath us + // (e.g. the window is closed), so we never touch a destroyed actor. + this._actorDestroyId = + actor.connect('destroy', () => this._onActorDestroyed()); + this._actorVisibleId = + actor.connect('notify::visible', () => this._restack()); + } + + this._syncGeometry(); + } + + _onActorDestroyed() { + // The window actor (and our child bar with it) is gone; drop the + // references the cleanup path would otherwise try to disconnect from a + // destroyed actor, then tear the rest of the decoration down. + this._actorDestroyId = 0; + this._actorVisibleId = 0; + this._titlebar = null; + this._removeTitlebar(); + } + + _syncGeometry() { + if (!this._titlebar || !this._window) + return; + + // The frame rect includes the reserved decoration strip at its top + // (see _reserveStrip), with the client content sized to sit below it. + // The bar therefore occupies the top TITLEBAR_HEIGHT of the frame rect, + // outside the client buffer, so its rounded top corners reveal whatever + // is behind the window rather than the client's own background. + const frame = this._window.get_frame_rect(); + + if (this._barInActor) { + // Position in window-actor-local coordinates. The actor sits at the + // buffer (client) rect; the reserved strip lies just above it, so + // the bar's local origin is the frame-to-buffer offset (a negative + // y). These local coordinates stay constant as the window moves, so + // moving needs no re-sync — only a resize changes the bar's width. + // Without a reserved strip (older mutter) the frame and buffer rects + // coincide, so float the bar one strip-height above the client. + const buffer = this._window.get_buffer_rect(); + const lx = frame.x - buffer.x; + const ly = this._stripReserved + ? frame.y - buffer.y : -TITLEBAR_HEIGHT; + this._titlebar.set_position(lx, ly); + } else { + // Floating fallback: track the frame rect in stage coordinates. + const y = this._stripReserved ? frame.y : frame.y - TITLEBAR_HEIGHT; + this._titlebar.set_position(frame.x, y); + } + + this._titlebar.set_size(frame.width, TITLEBAR_HEIGHT); + this._titlebar._updateMaximizeIcon(); + this._resizeBorder?.syncGeometry(frame); + this._restack(); + } + + _stripHeight() { + // No decorations while fullscreen. + return this._window.is_fullscreen() ? 0 : TITLEBAR_HEIGHT; + } + + _updateFullscreen() { + if (!this._titlebar || !this._window) + return; + + this._reserveStrip(this._stripHeight()); + this._syncGeometry(); + } + + _reserveStrip(top) { + // meta_window_set_decoration_extents is only present on builds carrying + // the server-side-decoration reservation API; degrade gracefully (to a + // floating overlay) when it is missing. + if (!this._window || !this._window.set_decoration_extents) { + this._stripReserved = false; + return; + } + + this._window.set_decoration_extents(0, 0, top, 0); + this._stripReserved = top > 0; + } + + _restack() { + if (!this._titlebar || !this._window) + return; + + const actor = this._window.get_compositor_private(); + + if (this._barInActor) { + // Keep the bar above the surface (and any later-added child) within + // its own window actor. + if (actor && this._titlebar.get_parent() === actor) + actor.set_child_above_sibling(this._titlebar, null); + } else { + // Floating fallback: re-raise the sibling bar just above its window. + const parent = this._titlebar.get_parent(); + if (actor && parent && actor.get_parent() === parent) + parent.set_child_above_sibling(this._titlebar, actor); + } + + // Keep the resize ring just above its window in window_group. + if (this._resizeBorder && actor && + this._resizeBorder.get_parent() === actor.get_parent()) { + this._resizeBorder.get_parent().set_child_above_sibling( + this._resizeBorder, actor); + } + + // Hide the bar when its window is not shown (minimized, on another + // workspace, …) or when fullscreen, so it does not linger over other + // windows or cover fullscreen content. + const shown = !this._window.minimized && !this._window.is_fullscreen() && + !!actor && actor.visible; + + // A bar living inside the window actor is already hidden whenever the + // actor is (minimized, off-workspace, replaced by a clone in the + // overview), so it only needs to be forced away while fullscreen. A + // floating fallback bar has to mirror the full shown state itself. + this._titlebar.visible = this._barInActor + ? !this._window.is_fullscreen() : shown; + + // The resize ring only makes sense when the window can actually be + // resized; allows_resize() is already false while maximized, fullscreen + // or for fixed-size windows. + if (this._resizeBorder) + this._resizeBorder.visible = shown && this._window.allows_resize(); + } + + _removeTitlebar() { + // Cancel a still-pending initial apply (e.g. when disabled mid-flight). + if (this._initialApplyId) { + GLib.source_remove(this._initialApplyId); + this._initialApplyId = 0; + } + + for (const id of this._windowSignalIds) + this._window.disconnect(id); + this._windowSignalIds = []; + + if (this._restackedId) { + global.display.disconnect(this._restackedId); + this._restackedId = 0; + } + const actor = this._window?.get_compositor_private(); + if (this._actorDestroyId) { + if (actor) + actor.disconnect(this._actorDestroyId); + this._actorDestroyId = 0; + } + if (this._actorVisibleId) { + if (actor) + actor.disconnect(this._actorVisibleId); + this._actorVisibleId = 0; + } + + // Give the reserved strip back to the client. + if (this._stripReserved) { + this._window?.set_decoration_extents(0, 0, 0, 0); + this._stripReserved = false; + } + + this._resizeBorder?.destroy(); + this._resizeBorder = null; + this._titlebar?.destroy(); + this._titlebar = null; + } + + _onDestroyed() { + this._removeTitlebar(); + this._manager._onDecorationDestroyed(this); + } +} + +export class ServerDecorationManager { + constructor() { + this._global = null; + this._decorations = new Set(); + // Windows that currently have a live decoration object, so a second + // get_toplevel_decoration for the same toplevel can be rejected with + // the already_constructed protocol error. + this._decoratedWindows = new Set(); + } + + enable() { + if (this._global) + return; + + try { + const server = Shell.WaylandServer.get_default(); + if (!server) { + log('Server-side decorations unavailable: not running on Wayland'); + return; + } + + if (!ensureInterfaces()) + return; + + this._global = server.create_global(DecorationManagerIface.iface, + DECORATION_MANAGER_VERSION); + this._global.connect('bind', + (g, resource, _version) => this._onManagerBind(resource)); + } catch (e) { + logError(e, 'Failed to enable server-side decorations'); + this.disable(); + } + } + + disable() { + if (this._global) { + this._global.destroy(); + this._global = null; + } + for (const decoration of [...this._decorations]) + decoration._removeTitlebar(); + this._decorations.clear(); + this._decoratedWindows.clear(); + } + + _onManagerBind(resource) { + resource.connect('request', (res, opcode, args) => { + try { + this._onManagerRequest(res, opcode, args); + } catch (e) { + logError(e, 'Error handling decoration manager request'); + } + }); + } + + _onManagerRequest(res, opcode, args) { + const {req} = DecorationManagerIface; + + switch (opcode) { + case req.destroy: + res.destroy(); + break; + case req.get_toplevel_decoration: { + const id = args.get_new_id(0); + const toplevel = args.get_object(1); + const window = toplevel ? toplevel.get_meta_window() : null; + + // The new_id must be consumed off the wire, so always create the + // resource; reject a duplicate decoration for the same toplevel + // afterwards (a fatal protocol error tears the client down anyway). + const decoResource = res.create_resource( + ToplevelDecorationIface.iface, res.get_version(), id); + + if (window && this._decoratedWindows.has(window)) { + decoResource.post_error(DecorationError.ALREADY_CONSTRUCTED, + 'xdg_toplevel already has a decoration object'); + return; + } + + const decoration = + new ToplevelDecoration(this, decoResource, window); + this._decorations.add(decoration); + if (window) + this._decoratedWindows.add(window); + break; + } + } + } + + _onDecorationDestroyed(decoration) { + this._decorations.delete(decoration); + if (decoration._window) + this._decoratedWindows.delete(decoration._window); + } +} -- GitLab From 4e9316d5dfaa7b2f30f3822966612507df4b6748 Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Wed, 24 Jun 2026 23:49:37 -0700 Subject: [PATCH 4/5] serverDecoration: Add accessibility labels to window control buttons Add accessible_name (wrapped in _() for translation), accessible_role (Atk.Role.PUSH_BUTTON), and can_focus to the minimize, maximize, and close buttons in the server-side decoration title bar. Update the maximize button label when the window state changes between maximized and restored. Add serverDecoration.js to POTFILES.in so the new translatable strings are extracted. Signed-off-by: Christopher Snowhill Assisted-By: Qwen3.6-35B-A3B-MTP-GGUF --- js/ui/serverDecoration.js | 39 ++++++++++++++++++++++++++------------- po/POTFILES.in | 1 + 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/js/ui/serverDecoration.js b/js/ui/serverDecoration.js index 3b149e0847..49f67ac540 100644 --- a/js/ui/serverDecoration.js +++ b/js/ui/serverDecoration.js @@ -6,6 +6,7 @@ // shell overlay kept in lockstep with the window geometry, rather than letting // the client draw its own client-side decorations. +import Atk from 'gi://Atk'; import Clutter from 'gi://Clutter'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; @@ -252,24 +253,31 @@ class WindowTitlebar extends St.BoxLayout { } _addButtonFor(box, fn) { + let accessibleName; switch (fn) { case Meta.ButtonFunction.MINIMIZE: - if (this._window.can_minimize()) { - this._addButton(box, 'window-minimize-symbolic', - () => this._window.minimize()); - } + if (!this._window.can_minimize()) + return; + accessibleName = _('Minimize'); + this._addButton(box, 'window-minimize-symbolic', + accessibleName, () => this._window.minimize()); break; case Meta.ButtonFunction.MAXIMIZE: - if (this._window.can_maximize()) { - this._maximizeButton = this._addButton(box, - 'window-maximize-symbolic', () => this._toggleMaximize()); - } + if (!this._window.can_maximize()) + return; + accessibleName = this._window.is_maximized() + ? _('Restore') : _('Maximize'); + this._maximizeButton = this._addButton(box, + 'window-maximize-symbolic', accessibleName, + () => this._toggleMaximize()); break; case Meta.ButtonFunction.CLOSE: - if (this._window.can_close()) { - this._addButton(box, 'window-close-symbolic', - () => this._window.delete(global.get_current_time())); - } + if (!this._window.can_close()) + return; + accessibleName = _('Close'); + this._addButton(box, 'window-close-symbolic', + accessibleName, + () => this._window.delete(global.get_current_time())); break; default: // MENU/APPMENU/spacers are not represented in the overlay. @@ -277,12 +285,15 @@ class WindowTitlebar extends St.BoxLayout { } } - _addButton(box, iconName, onClicked) { + _addButton(box, iconName, accessibleName, onClicked) { const button = new St.Button({ child: new St.Icon({ icon_name: iconName, icon_size: 16, }), + accessible_name: accessibleName, + accessible_role: Atk.Role.PUSH_BUTTON, + can_focus: true, y_align: Clutter.ActorAlign.CENTER, }); button.connect('clicked', onClicked); @@ -310,6 +321,8 @@ class WindowTitlebar extends St.BoxLayout { if (this._maximizeButton) { this._maximizeButton.get_child().icon_name = maximized ? 'window-restore-symbolic' : 'window-maximize-symbolic'; + this._maximizeButton.accessible_name = maximized + ? _('Restore') : _('Maximize'); } } diff --git a/po/POTFILES.in b/po/POTFILES.in index 450e28d698..e9ce095207 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -61,6 +61,7 @@ js/ui/screenShield.js js/ui/screenshot.js js/ui/search.js js/ui/searchController.js +js/ui/serverDecoration.js js/ui/shellEntry.js js/ui/shellMountOperation.js js/ui/status/accessibility.js -- GitLab From a388b7d9ed18a79cc9367b1aaa1fe0f6797a4fcb Mon Sep 17 00:00:00 2001 From: Christopher Snowhill Date: Thu, 25 Jun 2026 00:36:01 -0700 Subject: [PATCH 5/5] serverDecoration: Hide invisible drag widgets from ATK Set accessible_role to Atk.Role.FILLER on the WindowTitlebar, WindowResizeBorder container, and all eight resize zone actors so they are excluded from the accessibility tree and do not clutter the controls list. Signed-off-by: Christopher Snowhill Assisted-By: Qwen3.6-35B-A3B-MTP-GGUF --- js/ui/serverDecoration.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/js/ui/serverDecoration.js b/js/ui/serverDecoration.js index 49f67ac540..0cea31087f 100644 --- a/js/ui/serverDecoration.js +++ b/js/ui/serverDecoration.js @@ -140,6 +140,7 @@ class WindowTitlebar extends St.BoxLayout { style_class: 'ssd-titlebar', reactive: true, track_hover: true, + accessible_role: Atk.Role.FILLER, }); this._window = window; @@ -423,14 +424,20 @@ class WindowTitlebar extends St.BoxLayout { const WindowResizeBorder = GObject.registerClass( class WindowResizeBorder extends Clutter.Actor { _init(window) { - super._init({reactive: false}); + super._init({ + reactive: false, + accessible_role: Atk.Role.FILLER, + }); this._window = window; this._zones = []; for (const {op} of RESIZE_ZONES) { const grabOp = Meta.GrabOp[`RESIZING_${op}`]; - const zone = new Clutter.Actor({reactive: true}); + const zone = new Clutter.Actor({ + reactive: true, + accessible_role: Atk.Role.FILLER, + }); // Give each zone its directional resize cursor so the otherwise // invisible grab regions are discoverable on hover. The op names -- GitLab