kick/tabbed-wlroots.c

3032 lines
110 KiB
C
Raw Normal View History

/* tabbed-wlroots.c -- nested tabbed compositor built on wlroots 0.19.
*
* Renders a headless wlroots output (via wlr_scene) inside a host Wayland
* window (the "host" connection, e.g. labwc). Input from the host is
* forwarded into a wlr_seat, so wlroots handles all sub-surface hit-testing,
* enter/leave, input regions and coordinate translation. Clients are always
* maximized to the host content area; their minimize/maximize/fullscreen
* requests are ignored.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <execinfo.h>
#include <fcntl.h>
#include <linux/memfd.h>
#include <poll.h>
#include <signal.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/poll.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <drm_fourcc.h>
#include <wayland-client.h>
#include <wayland-egl.h>
#include <wayland-server.h>
#include "xdg-decoration-client.h"
#include "xdg-shell-client.h"
#include "wlr-data-control-client.h"
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES2/gl2.h>
#include <GLES2/gl2ext.h>
#include <xkbcommon/xkbcommon.h>
#include <wlr/backend.h>
#include <wlr/backend/headless.h>
#include <wlr/interfaces/wlr_keyboard.h>
#include <wlr/render/allocator.h>
#include <wlr/render/drm_format_set.h>
#include <wlr/render/egl.h>
#include <wlr/render/gles2.h>
#include <wlr/render/swapchain.h>
#include <wlr/render/wlr_renderer.h>
#include <wlr/types/wlr_buffer.h>
#include <wlr/types/wlr_compositor.h>
#include <wlr/types/wlr_data_control_v1.h>
#include <wlr/types/wlr_data_device.h>
#include <wlr/types/wlr_keyboard.h>
#include <wlr/types/wlr_linux_dmabuf_v1.h>
#include <wlr/types/wlr_output.h>
#include <wlr/types/wlr_pointer.h>
#include <wlr/types/wlr_scene.h>
#include <wlr/types/wlr_seat.h>
#include <wlr/types/wlr_shm.h>
#include <wlr/types/wlr_subcompositor.h>
#include <wlr/types/wlr_xdg_shell.h>
#include <wlr/util/log.h>
/* server.apps grows as needed; there is no tab limit. */
/* ===================== Server-side (wlroots) state ===================== */
struct tabbed_app {
char *cmd;
pid_t pid;
struct wlr_xdg_toplevel *toplevel;
struct wlr_scene_tree *tree;
struct wl_listener destroy;
struct wl_listener map;
struct wl_listener commit;
struct wl_listener request_maximize;
struct wl_listener request_fullscreen;
struct wl_listener request_minimize;
struct wl_listener request_move;
struct wl_listener request_resize;
};
struct tabbed_server {
struct wl_display *wl_display;
struct wl_event_loop *loop;
struct wlr_backend *backend;
struct wlr_renderer *renderer;
struct wlr_allocator *allocator;
struct wlr_scene *scene;
struct wlr_output *output;
struct wlr_scene_output *scene_output;
struct wlr_scene_rect *bg_rect;
struct wlr_seat *seat;
struct wlr_xdg_shell *xdg_shell;
struct wlr_keyboard keyboard;
struct wlr_swapchain *swapchain;
struct wl_listener new_output;
struct wl_listener new_toplevel;
struct wl_listener new_popup;
struct wl_listener frame;
struct wl_event_source *ctl_source;
struct wlr_xdg_toplevel *active;
struct tabbed_app **apps;
int num_apps;
int apps_cap;
struct xkb_context *xkb_ctx;
struct xkb_keymap *xkb_keymap;
};
/* ===================== Host-side (nested) state ===================== */
struct tabbed_host {
struct wl_display *display;
struct wl_registry *registry;
struct wl_compositor *compositor;
struct wl_surface *surface;
struct xdg_wm_base *wm_base;
struct xdg_surface *xdg_surface;
struct xdg_toplevel *toplevel;
struct zxdg_decoration_manager_v1 *decoration;
struct zxdg_toplevel_decoration_v1 *decoration_obj;
struct wl_seat *seat;
struct wl_keyboard *keyboard;
struct wl_pointer *pointer;
bool configured;
bool fullscreen;
int width, height;
/* EGL */
bool egl_ok;
EGLDisplay egl_display;
EGLConfig egl_config;
EGLContext egl_context;
EGLSurface egl_surface;
struct wl_egl_window *egl_window;
/* host modifiers (for tab-switch detection) */
uint32_t host_mods_depressed;
uint32_t axis_source;
int32_t axis_discrete[2];
/* GL program */
GLuint gl_program;
GLint gl_pos_loc, gl_uv_loc, gl_tex_loc;
GLuint import_tex;
GLuint popup_tex;
/* Present gating: at most one buffer aimed at the host compositor at a
time, paced by the host surface's wl_callback. */
struct wl_callback *frame_cb;
bool present_pending;
bool back_dirty;
/* Keybind repeat for the captured tab-switch binding. The host
compositor only sends one physical press, so we drive the repeat
ourselves with a timer (fixed delay/rate), like labwc does for its
own keybinds. */
struct wl_event_source *repeat_timer;
uint32_t repeat_key;
int repeat_dir;
};
static struct tabbed_server server;
static struct tabbed_host host;
static char tabbed_socket[64];
static char socket_path[256];
static volatile sig_atomic_t needs_redraw;
static bool frame_pending;
static bool shutting_down;
/* ===================== Clipboard bridge ===================== */
enum bridge_backend {
BRIDGE_BACKEND_NONE = 0,
BRIDGE_BACKEND_DATA_CONTROL,
BRIDGE_BACKEND_WL_DATA_DEVICE,
};
/* Bridges the nested seat's clipboard with the host compositor's. Two host
backends share one inner (nested-seat) implementation:
- data-control: zwlr_data_control_v1 (labwc/sway/etc.), full duplex,
independent of keyboard focus.
- wl_data_device: plain stable protocol (Mutter/GNOME fallback). Writing
the host selection always works (needs a captured keyboard serial);
reading the host selection only works while our window is focused.
Data flows by passing file descriptors straight through in both directions
(host offer -> nested source, nested source -> host source), so no
intermediate copy is needed. */
struct clipboard_bridge {
enum bridge_backend backend;
/* nested (wlroots) seat */
struct wl_listener request_set_selection;
struct wl_listener set_selection;
/* the nested selection source currently mirrored to the host; serves
host paste requests until it is destroyed */
struct wlr_data_source *inner_src;
struct wl_listener inner_src_destroy;
bool inner_clear_requested; /* nested client asked to clear the selection */
bool ignore_next_inner; /* one-shot echo guard for host->nested clears */
/* host: standard wl_data_device (always bound; the fallback backend) */
struct wl_data_device_manager *ddm;
struct wl_data_device *dd;
struct wl_data_source *dd_source; /* our host-side source (nested copy) */
struct wl_data_offer *dd_offer; /* current host selection offer */
struct wl_data_offer *dd_echo_offer; /* offer describing our own set */
uint32_t kbd_serial; /* last host keyboard/enter serial */
/* host: wlr-data-control backend (if the host exposes it) */
struct zwlr_data_control_manager_v1 *dc_manager;
struct zwlr_data_control_device_v1 *dc_device;
struct zwlr_data_control_source_v1 *dc_source;
struct zwlr_data_control_offer_v1 *dc_offer;
struct zwlr_data_control_offer_v1 *dc_echo_offer;
/* MIME list currently being collected from a host offer */
char **mimes;
size_t mimes_len, mimes_cap;
bool dc_pending_echo; /* our own set via data-control: echo not yet consumed */
bool dd_pending_echo; /* our own set via wl_data_device: echo not yet consumed */
bool host_set_pending; /* nested selection not yet on the host; retry on focus */
/* Eager text prefetch: when a real host selection is mirrored, its primary
text MIME is pulled into this buffer at copy time so pastes are served
instantly instead of asking the host for each one. */
int prefetch_fd; /* pipe read end, -1 when idle */
struct wl_event_source *prefetch_src;
char *prefetch_buf;
size_t prefetch_len, prefetch_cap;
char prefetch_mime[64]; /* canonical text MIME, "" if none */
bool prefetch_ready; /* buffer complete (EOF received) */
};
static struct clipboard_bridge bridge = { .prefetch_fd = -1 };
static void bridge_cleanup(void);
static void bridge_init(void);
static void bridge_attach_seat(void);
/* Temporary frame-dump keys (F8/F9) to attribute the lxterm grey band: F8
saves the scene buffer (client content as wlroots drew it), F9 saves our
composited host back buffer. To be removed after diagnosis. */
static volatile sig_atomic_t dump_scene_req, dump_host_req;
static int dump_seq;
static bool client_dumped = false;
static void set_active_surface(struct wlr_xdg_toplevel *toplevel);
static void render_and_present(void);
static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel);
static int app_index(struct tabbed_app *app);
/* Logging levels: quiet by default so a plain launch writes nothing to the
launching terminal except fatal errors. TABBED_LOG=1 shows status lines,
TABBED_LOG=2 adds the per-frame [diag] trace. The F9/F10 keys, SIGUSR1 and
TABBED_AUTODUMP dumps always write their .ppm files regardless of level. */
enum { TABBED_LOG_QUIET = 0, TABBED_LOG_INFO = 1, TABBED_LOG_DEBUG = 2 };
static int tabbed_log_level(void) {
static volatile sig_atomic_t level = -1;
if (level < 0) {
const char *v = getenv("TABBED_LOG");
int l = v ? atoi(v) : 0;
if (l < TABBED_LOG_QUIET) l = TABBED_LOG_QUIET;
if (l > TABBED_LOG_DEBUG) l = TABBED_LOG_DEBUG;
level = l;
}
return level;
}
static void tabbed_vlog(int level, const char *fmt, va_list args) {
if (level > tabbed_log_level())
return;
vfprintf(stderr, fmt, args);
}
static void tabbed_log(int level, const char *fmt, ...) {
va_list args;
va_start(args, fmt);
tabbed_vlog(level, fmt, args);
va_end(args);
}
/* Route libxkbcommon's own log output (which otherwise writes
"xkbcommon: ERROR: ..." straight to stderr, bypassing TABBED_LOG) through
our level system. Errors surface at info level; warnings and below are
debug-only, so a quiet launch no longer leaks parse failures to the
launching terminal. */
static void tabbed_xkb_log(struct xkb_context *ctx, enum xkb_log_level level,
const char *fmt, va_list args)
{
(void)ctx;
int l = (level <= XKB_LOG_LEVEL_ERROR) ? TABBED_LOG_INFO : TABBED_LOG_DEBUG;
if (l > tabbed_log_level())
return;
fputs("tabbed-wlroots: xkbcommon: ", stderr);
vfprintf(stderr, fmt, args);
}
static void tabbed_log_level_init(void) {
(void)tabbed_log_level();
}
/* Terminate every launched client: SIGTERM first for a graceful shutdown,
then SIGKILL any stragglers after a short grace period. */
static void terminate_clients(void) {
for (int i = 0; i < server.num_apps; i++) {
if (server.apps[i]->pid > 0)
kill(-server.apps[i]->pid, SIGTERM);
}
struct timespec wait = { .tv_sec = 0, .tv_nsec = 150000000 };
nanosleep(&wait, NULL);
for (int i = 0; i < server.num_apps; i++) {
if (server.apps[i]->pid > 0)
kill(-server.apps[i]->pid, SIGKILL);
}
}
/* Remove the session's Wayland socket file (and its companion .lock file,
both created by wl_display_add_socket), if present. The full path is
precomputed at startup so this only ever calls unlink()/snprintf(), which
are async-signal-safe and therefore safe from the signal/crash handlers
too. A missing file is fine: ENOENT is ignored, i.e. nothing to clean. */
static void remove_socket_file(void) {
unlink(socket_path);
char lock_path[sizeof(socket_path) + 6];
snprintf(lock_path, sizeof(lock_path), "%s.lock", socket_path);
unlink(lock_path);
}
/* Grow server.apps to hold at least n entries. Returns 0 on success, -1 on
allocation failure. The buffer is freed in shutdown_cleanup(). */
static int apps_ensure_cap(int n) {
if (n <= server.apps_cap) return 0;
struct tabbed_app **grown =
realloc(server.apps, (size_t)n * sizeof(*grown));
if (!grown) return -1;
server.apps = grown;
server.apps_cap = n;
return 0;
}
/* Full shutdown of the nested session: take down the clients first, then
free our own allocations and exit. Guarded so the various close paths
(host window close, last tab closed) only run once. */
static void shutdown_cleanup(void) {
if (shutting_down) return;
shutting_down = true;
terminate_clients();
for (int i = 0; i < server.num_apps; i++) {
free(server.apps[i]->cmd);
free(server.apps[i]);
}
free(server.apps);
server.apps = NULL;
if (server.xkb_keymap) xkb_keymap_unref(server.xkb_keymap);
if (server.xkb_ctx) xkb_context_unref(server.xkb_ctx);
bridge_cleanup();
remove_socket_file();
}
/* ===================== EGL helpers ===================== */
static PFNEGLCREATEIMAGEKHRPROC pfn_eglCreateImageKHR;
static PFNEGLDESTROYIMAGEKHRPROC pfn_eglDestroyImageKHR;
static PFNEGLGETPLATFORMDISPLAYEXTPROC pfn_eglGetPlatformDisplayEXT;
static PFNGLEGLIMAGETARGETTEXTURE2DOESPROC pfn_glEGLImageTargetTexture2DOES;
static void *egl_proc(const char *name) {
void *p = (void *)eglGetProcAddress(name);
if (!p)
fprintf(stderr, "tabbed-wlroots: EGL proc %s unavailable\n", name);
return p;
}
static GLuint compile_shader(GLenum type, const char *src) {
GLuint sh = glCreateShader(type);
glShaderSource(sh, 1, &src, NULL);
glCompileShader(sh);
GLint ok;
glGetShaderiv(sh, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[512];
glGetShaderInfoLog(sh, sizeof(log), NULL, log);
fprintf(stderr, "tabbed-wlroots: shader error: %s\n", log);
glDeleteShader(sh);
return 0;
}
return sh;
}
static bool gl_setup(void) {
const char *vs =
"attribute vec2 a_pos;\n"
"attribute vec2 a_uv;\n"
"varying vec2 v_uv;\n"
"void main() {\n"
" v_uv = a_uv;\n"
" gl_Position = vec4(a_pos, 0.0, 1.0);\n"
"}\n";
const char *fs =
"precision mediump float;\n"
"varying vec2 v_uv;\n"
"uniform sampler2D u_tex;\n"
"void main() {\n"
" gl_FragColor = texture2D(u_tex, v_uv);\n"
"}\n";
GLuint vsh = compile_shader(GL_VERTEX_SHADER, vs);
GLuint fsh = compile_shader(GL_FRAGMENT_SHADER, fs);
if (!vsh || !fsh) return false;
host.gl_program = glCreateProgram();
glAttachShader(host.gl_program, vsh);
glAttachShader(host.gl_program, fsh);
glBindAttribLocation(host.gl_program, 0, "a_pos");
glBindAttribLocation(host.gl_program, 1, "a_uv");
glLinkProgram(host.gl_program);
GLint ok;
glGetProgramiv(host.gl_program, GL_LINK_STATUS, &ok);
glDeleteShader(vsh);
glDeleteShader(fsh);
if (!ok) {
char log[512];
glGetProgramInfoLog(host.gl_program, sizeof(log), NULL, log);
fprintf(stderr, "tabbed-wlroots: link error: %s\n", log);
return false;
}
host.gl_pos_loc = glGetAttribLocation(host.gl_program, "a_pos");
host.gl_uv_loc = glGetAttribLocation(host.gl_program, "a_uv");
host.gl_tex_loc = glGetUniformLocation(host.gl_program, "u_tex");
glGenTextures(1, &host.import_tex);
glBindTexture(GL_TEXTURE_2D, host.import_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
glGenTextures(1, &host.popup_tex);
glBindTexture(GL_TEXTURE_2D, host.popup_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
return true;
}
/* Blit a source rect of a texture into the window at the given destination
offset (window coords), clipped to the window. */
static void draw_texture_rect(GLuint tex, int tex_w, int tex_h,
int src_x, int src_y, int src_w, int src_h,
int dst_x, int dst_y, int win_w, int win_h)
{
if (win_w <= 0 || win_h <= 0) return;
if (src_w <= 0 || src_h <= 0) return;
if (src_x >= tex_w || src_y >= tex_h) return;
if (src_w > tex_w - src_x) src_w = tex_w - src_x;
if (src_h > tex_h - src_y) src_h = tex_h - src_y;
int cx = dst_x < 0 ? -dst_x : 0;
int cy = dst_y < 0 ? -dst_y : 0;
if (cx >= src_w || cy >= src_h) return;
int wx0 = dst_x + cx;
int wy0 = dst_y + cy;
int cw = src_w - cx;
int ch = src_h - cy;
int maxw = win_w - wx0;
int maxh = win_h - wy0;
if (cw > maxw) cw = maxw;
if (ch > maxh) ch = maxh;
if (cw <= 0 || ch <= 0) return;
src_x += cx;
src_y += cy;
float u0 = (float)src_x / (float)tex_w;
float u1 = (float)(src_x + cw) / (float)tex_w;
float v0 = (float)src_y / (float)tex_h;
float v1 = (float)(src_y + ch) / (float)tex_h;
float nx0 = -1.0f + 2.0f * (float)wx0 / (float)win_w;
float ny0 = 1.0f - 2.0f * (float)wy0 / (float)win_h;
float nx1 = -1.0f + 2.0f * (float)(wx0 + cw) / (float)win_w;
float ny1 = 1.0f - 2.0f * (float)(wy0 + ch) / (float)win_h;
GLfloat verts[8] = {
nx0, ny0,
nx1, ny0,
nx1, ny1,
nx0, ny1,
};
GLfloat uvs[8] = {
u0, v0,
u1, v0,
u1, v1,
u0, v1,
};
GLushort idx[6] = { 0, 1, 2, 0, 2, 3 };
glUseProgram(host.gl_program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex);
glUniform1i(host.gl_tex_loc, 0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, verts);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, uvs);
glEnableVertexAttribArray(1);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, idx);
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
}
/* ===================== Server-side wlroots setup ===================== */
static void swapchain_resize(int width, int height) {
if (width < 50) width = 50;
if (height < 50) height = 50;
static uint64_t mods[] = { DRM_FORMAT_MOD_LINEAR };
struct wlr_drm_format fmt = {
.format = DRM_FORMAT_ARGB8888,
.len = 1,
.capacity = 1,
.modifiers = mods,
};
if (server.swapchain) {
wlr_swapchain_destroy(server.swapchain);
server.swapchain = NULL;
}
server.swapchain = wlr_swapchain_create(server.allocator, width, height, &fmt);
if (!server.swapchain)
fprintf(stderr, "tabbed-wlroots: swapchain create failed at %dx%d\n",
width, height);
}
static void output_resize(int width, int height) {
if (width < 50) width = 50;
if (height < 50) height = 50;
if (!server.output || !server.allocator) return;
struct wlr_output_state state;
wlr_output_state_init(&state);
wlr_output_state_set_enabled(&state, true);
wlr_output_state_set_custom_mode(&state, width, height, 0);
wlr_output_commit_state(server.output, &state);
wlr_output_state_finish(&state);
swapchain_resize(width, height);
if (server.bg_rect)
wlr_scene_rect_set_size(server.bg_rect, width, height);
/* Keep every mapped client at the current maximized size so switching
tabs is instant and no tab is left small after a host resize. */
for (int i = 0; i < server.num_apps; i++) {
struct tabbed_app *app = server.apps[i];
if (!app->toplevel) continue;
wlr_xdg_toplevel_set_maximized(app->toplevel, true);
wlr_xdg_toplevel_set_size(app->toplevel, width, height);
}
needs_redraw = true;
}
static void handle_output_frame(struct wl_listener *listener, void *data) {
(void)listener; (void)data;
frame_pending = true;
}
static void handle_new_output(struct wl_listener *listener, void *data) {
(void)listener;
struct wlr_output *output = data;
if (!wlr_output_init_render(output, server.allocator, server.renderer)) {
fprintf(stderr, "tabbed-wlroots: output init_render failed\n");
return;
}
wlr_output_create_global(output, server.wl_display);
server.output = output;
server.scene_output = wlr_scene_output_create(server.scene, output);
wlr_scene_output_set_position(server.scene_output, 0, 0);
/* Render whenever the output is ready for a frame (refresh-rate driven),
like tinywl; this is what drives client frame callbacks / surface
enter events even before any damage has accumulated. */
server.frame.notify = handle_output_frame;
wl_signal_add(&output->events.frame, &server.frame);
/* Enable the output up front so buffer commits succeed even before the
host window has been configured. */
int w = output->width > 0 ? output->width : host.width;
int h = output->height > 0 ? output->height : host.height;
struct wlr_output_state state;
wlr_output_state_init(&state);
wlr_output_state_set_enabled(&state, true);
wlr_output_state_set_custom_mode(&state, w, h, 0);
wlr_output_commit_state(output, &state);
wlr_output_state_finish(&state);
swapchain_resize(w, h);
/* An opaque full-output background node gives the scene a deterministic
backdrop so the nested frame is never transparent. (Direct scanout is
disabled separately above; a rect alone cannot prevent it because
wlroots culls a rect that a covering client surface occludes, leaving
a single render-list entry which is exactly what triggers scanout.) */
server.bg_rect = wlr_scene_rect_create(&server.scene->tree, w, h,
(float[]){ 0.0f, 0.0f, 0.0f, 1.0f });
if (server.bg_rect)
wlr_scene_node_lower_to_bottom(&server.bg_rect->node);
}
static void handle_toplevel_destroy(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_app *app = wl_container_of(listener, app, destroy);
struct wlr_xdg_toplevel *toplevel = app->toplevel;
app->toplevel = NULL;
app->tree = NULL;
/* wlroots emits destroy with data=NULL and asserts every toplevel
event listener list is empty afterwards; remove all listeners we
attached to satisfy that and avoid use-after-free. */
wl_list_remove(&app->destroy.link);
wl_list_remove(&app->map.link);
wl_list_remove(&app->commit.link);
wl_list_remove(&app->request_maximize.link);
wl_list_remove(&app->request_fullscreen.link);
wl_list_remove(&app->request_minimize.link);
wl_list_remove(&app->request_move.link);
wl_list_remove(&app->request_resize.link);
if (server.active != NULL && server.active == toplevel) {
server.active = NULL;
for (int i = 0; i < server.num_apps; i++)
if (server.apps[i]->toplevel) {
set_active_surface(server.apps[i]->toplevel);
return;
}
/* last tab closed: close the host window and exit */
xdg_toplevel_destroy(host.toplevel);
wl_surface_destroy(host.surface);
shutdown_cleanup();
exit(0);
}
}
static void handle_request_maximize(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_app *app = wl_container_of(listener, app, request_maximize);
struct wlr_xdg_toplevel *toplevel = app->toplevel;
if (toplevel->base->initialized)
wlr_xdg_toplevel_set_maximized(toplevel, true);
}
static void handle_request_fullscreen(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_app *app = wl_container_of(listener, app, request_fullscreen);
struct wlr_xdg_toplevel *toplevel = app->toplevel;
if (toplevel->base->initialized)
wlr_xdg_toplevel_set_fullscreen(toplevel, false);
}
static void handle_request_minimize(struct wl_listener *listener, void *data) {
(void)listener; (void)data;
}
static void handle_request_move(struct wl_listener *listener, void *data) {
(void)listener; (void)data;
}
static void handle_request_resize(struct wl_listener *listener, void *data) {
(void)listener; (void)data;
}
static void position_toplevel(struct tabbed_app *app) {
if (!app || !app->tree) return;
/* CSD clients (foot/kitty) reserve a titlebar gutter above their
content, which we don't paint, so it shows as a dark bar and their
content sits too low (bottom row/cursor cropped). Shift them up by
that gutter so the dark bar is cropped instead of the terminal's
bottom. Clients with no gutter (geometry.y >= 0, e.g. wltest) are
left untouched. */
struct wlr_box geom = app->toplevel->base->geometry;
int y = -geom.y;
if (geom.y < 0)
y = 0;
wlr_scene_node_set_position(&app->tree->node, -geom.x, y);
}
static void handle_toplevel_commit(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_app *app = wl_container_of(listener, app, commit);
struct wlr_xdg_toplevel *toplevel = app->toplevel;
if (toplevel->base->initial_commit) {
wlr_xdg_toplevel_set_maximized(toplevel, true);
wlr_xdg_toplevel_set_size(toplevel, server.output->width,
server.output->height);
}
/* Geometry can settle after the first frames (kitty/foot report a
negative y once they decide on their titlebar), so keep the scene
position in sync with every commit instead of only at activation. */
position_toplevel(app);
/* [diag] What does this client commit, and is its scene node visible? */
struct wlr_surface *s = toplevel->base->surface;
struct wlr_buffer *cbuf = s ? s->current.buffer : NULL;
struct wlr_dmabuf_attributes dma;
static struct wlr_buffer *last_logged = NULL;
if (cbuf && cbuf != last_logged) {
last_logged = cbuf;
bool is_dma = wlr_buffer_get_dmabuf(cbuf, &dma);
bool node_on = app->tree ? app->tree->node.enabled : false;
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
tabbed_log(TABBED_LOG_DEBUG, "[diag] commit app=%d geom=%dx%d+%d+%d "
"buf=%dx%d dma=%d%s node_enabled=%d active=%d\n",
app_index(app),
toplevel->base->geometry.width,
toplevel->base->geometry.height,
toplevel->base->geometry.x,
toplevel->base->geometry.y,
s->current.buffer_width,
s->current.buffer_height,
is_dma,
is_dma ? (dma.n_planes ? "" : " planes=0") : "",
node_on,
server.active == toplevel);
if (is_dma) {
tabbed_log(TABBED_LOG_DEBUG, "[diag] dmabuf planes=%d "
"format=0x%x mod=0x%llx\n",
dma.n_planes, dma.format,
(unsigned long long)dma.modifier);
}
}
if (!is_dma) {
/* shm buffer: flag for TABBED_AUTODUMP; at debug level also count
non-black pixels and save the client's own buffer so we can tell
"client painted nothing" from "we failed to import". */
void *data;
uint32_t fmt;
size_t stride;
if (wlr_buffer_begin_data_ptr_access(
cbuf, WLR_BUFFER_DATA_PTR_ACCESS_READ,
&data, &fmt, &stride)) {
client_dumped = true;
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
int bw = cbuf->width, bh = cbuf->height;
unsigned long nonblack = 0, lum = 0;
unsigned char *p = data;
for (int yy = 0; yy < bh; yy++) {
unsigned char *row = p + (size_t)yy * stride;
for (int xx = 0; xx < bw; xx++) {
unsigned long v = row[xx*4+0] + row[xx*4+1]
+ row[xx*4+2];
lum += v;
if (v > 12) nonblack++;
}
}
tabbed_log(TABBED_LOG_DEBUG, "[diag] shm fmt=0x%x "
"nonblack=%lu/%lu mean=%lu\n", fmt, nonblack,
(unsigned long)bw * bh,
lum / ((unsigned long)bw * bh + 1));
char cpath[64];
snprintf(cpath, sizeof(cpath), "/tmp/client_%d.ppm",
dump_seq);
FILE *cf = fopen(cpath, "wb");
if (cf) {
fprintf(cf, "P6\n%d %d\n255\n", bw, bh);
unsigned char *cp = data;
for (int yy = 0; yy < bh; yy++) {
unsigned char *crow = cp + (size_t)yy * stride;
for (int xx = 0; xx < bw; xx++) {
fputc(crow[xx*4+0], cf);
fputc(crow[xx*4+1], cf);
fputc(crow[xx*4+2], cf);
}
}
fclose(cf);
tabbed_log(TABBED_LOG_DEBUG, "[diag] client buffer "
"-> %s\n", cpath);
}
}
wlr_buffer_end_data_ptr_access(cbuf);
}
}
}
}
static void handle_toplevel_map(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_app *app = wl_container_of(listener, app, map);
struct wlr_xdg_toplevel *toplevel = app->toplevel;
if (tabbed_log_level() >= TABBED_LOG_DEBUG)
tabbed_log(TABBED_LOG_DEBUG, "[diag] map app=%d\n", app_index(app));
wlr_xdg_toplevel_set_activated(toplevel, true);
set_active_surface(toplevel);
}
static void handle_new_toplevel(struct wl_listener *listener, void *data) {
(void)listener;
struct wlr_xdg_toplevel *toplevel = data;
struct tabbed_app *app = NULL;
for (int i = 0; i < server.num_apps; i++)
if (!server.apps[i]->toplevel) {
app = server.apps[i];
break;
}
if (!app) {
/* No slot for this toplevel (e.g. a dialog/second window opened by
an already-running client). Give it its own tab so it appears on
top and stays reachable via tab cycling. Place the new tab right
after the tab that created it (protocol parent, else the active
tab); fall back to the far right if neither can be identified. */
if (apps_ensure_cap(server.num_apps + 1) != 0) return;
app = calloc(1, sizeof(*app));
if (!app) return;
app->pid = -1;
app->cmd = NULL;
int anchor = -1;
struct tabbed_app *pa = toplevel->parent
? app_for_toplevel(toplevel->parent) : NULL;
if (!pa)
pa = app_for_toplevel(server.active);
if (pa)
for (int i = 0; i < server.num_apps; i++)
if (server.apps[i] == pa) {
anchor = i;
break;
}
if (anchor < 0) {
server.apps[server.num_apps] = app;
} else {
int at = anchor + 1;
if (at < server.num_apps)
memmove(&server.apps[at + 1], &server.apps[at],
(server.num_apps - at) * sizeof(*server.apps));
server.apps[at] = app;
}
server.num_apps++;
}
app->toplevel = toplevel;
app->tree = wlr_scene_xdg_surface_create(&server.scene->tree,
toplevel->base);
/* hidden until it becomes the active tab */
wlr_scene_node_set_enabled(&app->tree->node, false);
app->destroy.notify = handle_toplevel_destroy;
wl_signal_add(&toplevel->events.destroy, &app->destroy);
app->map.notify = handle_toplevel_map;
wl_signal_add(&toplevel->base->surface->events.map, &app->map);
app->commit.notify = handle_toplevel_commit;
wl_signal_add(&toplevel->base->surface->events.commit, &app->commit);
app->request_maximize.notify = handle_request_maximize;
wl_signal_add(&toplevel->events.request_maximize, &app->request_maximize);
app->request_fullscreen.notify = handle_request_fullscreen;
wl_signal_add(&toplevel->events.request_fullscreen, &app->request_fullscreen);
app->request_minimize.notify = handle_request_minimize;
wl_signal_add(&toplevel->events.request_minimize, &app->request_minimize);
app->request_move.notify = handle_request_move;
wl_signal_add(&toplevel->events.request_move, &app->request_move);
app->request_resize.notify = handle_request_resize;
wl_signal_add(&toplevel->events.request_resize, &app->request_resize);
}
/* ===================== Tab management ===================== */
static struct tabbed_app *app_for_toplevel(struct wlr_xdg_toplevel *toplevel) {
if (!toplevel) return NULL;
for (int i = 0; i < server.num_apps; i++)
if (server.apps[i]->toplevel == toplevel)
return server.apps[i];
return NULL;
}
static int app_index(struct tabbed_app *app) {
for (int i = 0; i < server.num_apps; i++)
if (server.apps[i] == app)
return i;
return -1;
}
struct tabbed_popup {
struct wlr_xdg_surface *xdg_surface;
struct wlr_scene_tree *scene;
struct wl_list link;
struct wl_listener commit;
struct wl_listener destroy;
struct wl_listener reposition;
};
/* Popups are also drawn by us (host-side) for the presented frame, because
wlroots' own premultiplied-alpha rendering of these ARGB menus produces
nothing in this nested EGL pipeline. This list tracks the live ones. */
static struct wl_list popups = { &popups, &popups };
/* Enough of the host window for popups to be positioned within; open menus
may exceed the toplevel but we keep them inside the visible area. */
static void popup_usable_box(struct wlr_box *box) {
box->x = 0;
box->y = 0;
if (server.output) {
box->width = server.output->width;
box->height = server.output->height;
} else {
box->width = host.width;
box->height = host.height;
}
}
/* Menu popups (e.g. galculator's) submit ARGB buffers that wlroots renders
with premultiplied-alpha blending, which produces nothing in this nested
EGL pipeline. We therefore draw them ourselves into the presented frame
(see composite_popups in blit_scene_buffer). */
static void popup_handle_commit(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_popup *tp = wl_container_of(listener, tp, commit);
struct wlr_xdg_surface *xdg_surface = tp->xdg_surface;
/* The client cannot map/render the popup until it receives its initial
configure, which wlroots only sends via wlr_xdg_popup_unconstrain_from
(which also adjusts geometry within the usable area). initial_commit is
true on the first commit; later commits are ignored. */
if (xdg_surface->initial_commit && xdg_surface->popup) {
struct wlr_box box;
popup_usable_box(&box);
wlr_xdg_popup_unconstrain_from_box(xdg_surface->popup, &box);
}
}
static void popup_handle_reposition(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_popup *tp = wl_container_of(listener, tp, reposition);
if (tp->xdg_surface->popup) {
struct wlr_box box;
popup_usable_box(&box);
wlr_xdg_popup_unconstrain_from_box(tp->xdg_surface->popup, &box);
}
}
static void popup_handle_destroy(struct wl_listener *listener, void *data) {
(void)data;
struct tabbed_popup *tp = wl_container_of(listener, tp, destroy);
wl_list_remove(&tp->link);
wl_list_remove(&tp->commit.link);
wl_list_remove(&tp->destroy.link);
wl_list_remove(&tp->reposition.link);
free(tp);
}
static void create_popup(struct wlr_xdg_popup *popup,
struct wlr_scene_tree *parent_tree) {
struct wlr_xdg_surface *xdg_surface = popup->base;
struct tabbed_popup *tp = calloc(1, sizeof(*tp));
if (!tp) return;
tp->xdg_surface = xdg_surface;
tp->scene = wlr_scene_xdg_surface_create(parent_tree, xdg_surface);
wl_list_insert(&popups, &tp->link);
/* wlr_scene_xdg_surface_create destroys the node when the surface is
destroyed, so we only track positioning/reposition listeners here. */
tp->commit.notify = popup_handle_commit;
wl_signal_add(&xdg_surface->surface->events.commit, &tp->commit);
tp->destroy.notify = popup_handle_destroy;
/* Listen on the popup's own destroy: wlroots emits it (then asserts its
event lists are empty) before the popup is freed. */
wl_signal_add(&popup->events.destroy, &tp->destroy);
tp->reposition.notify = popup_handle_reposition;
wl_signal_add(&popup->events.reposition, &tp->reposition);
/* remember the scene tree so a submenu popup can find its parent's tree */
xdg_surface->data = tp;
}
static void handle_new_popup(struct wl_listener *listener, void *data) {
(void)listener;
struct wlr_xdg_popup *popup = data;
/* Popups position themselves relative to their immediate parent surface,
so place each popup under its parent's scene tree. */
struct wlr_scene_tree *parent_tree = NULL;
struct wlr_xdg_surface *parent =
wlr_xdg_surface_try_from_wlr_surface(popup->parent);
if (parent) {
if (parent->role == WLR_XDG_SURFACE_ROLE_TOPLEVEL) {
struct tabbed_app *app = app_for_toplevel(parent->toplevel);
if (app) parent_tree = app->tree;
} else if (parent->role == WLR_XDG_SURFACE_ROLE_POPUP) {
struct tabbed_popup *tp = parent->data;
if (tp) parent_tree = tp->scene;
}
}
if (!parent_tree) return;
create_popup(popup, parent_tree);
}
static void set_active_surface(struct wlr_xdg_toplevel *toplevel) {
if (server.active == toplevel) return;
struct tabbed_app *old = app_for_toplevel(server.active);
if (old && old->tree)
wlr_scene_node_set_enabled(&old->tree->node, false);
if (server.active)
wlr_xdg_toplevel_set_activated(server.active, false);
server.active = toplevel;
wlr_xdg_toplevel_set_activated(toplevel, true);
struct tabbed_app *app = app_for_toplevel(server.active);
if (app && app->tree) {
position_toplevel(app);
wlr_scene_node_set_enabled(&app->tree->node, true);
if (tabbed_log_level() >= TABBED_LOG_DEBUG)
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d surface=%dx%d "
"node on\n", app_index(app),
toplevel->base->surface->current.width,
toplevel->base->surface->current.height);
} else if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
tabbed_log(TABBED_LOG_DEBUG, "[diag] active app=%d has NO scene node!\n",
app ? app_index(app) : -1);
}
wlr_seat_keyboard_notify_enter(server.seat, toplevel->base->surface,
server.keyboard.keycodes, server.keyboard.num_keycodes,
&server.keyboard.modifiers);
needs_redraw = true;
}
static void cycle_tab(int dir) {
if (server.num_apps == 0) return;
int idx = -1;
for (int i = 0; i < server.num_apps; i++)
if (server.apps[i]->toplevel == server.active) {
idx = i;
break;
}
for (int k = 1; k <= server.num_apps; k++) {
int j = ((idx < 0 ? 0 : idx) + k * dir + server.num_apps) % server.num_apps;
if (server.apps[j]->toplevel) {
set_active_surface(server.apps[j]->toplevel);
return;
}
}
}
/* Fixed keybind repeat: first repeat after 600ms, then every 40ms (25Hz). */
#define TABBED_REPEAT_DELAY_MS 600
#define TABBED_REPEAT_INTERVAL_MS 40
static void kbd_repeat_cancel(void) {
if (host.repeat_timer) {
wl_event_source_remove(host.repeat_timer);
host.repeat_timer = NULL;
}
host.repeat_key = 0;
}
static int kbd_repeat_cb(void *data) {
(void)data;
if (host.repeat_key == 0)
return 0;
cycle_tab(host.repeat_dir);
if (host.repeat_timer)
wl_event_source_timer_update(host.repeat_timer,
TABBED_REPEAT_INTERVAL_MS);
return 0;
}
static void kbd_repeat_start(int key, int dir) {
kbd_repeat_cancel();
host.repeat_key = key;
host.repeat_dir = dir;
host.repeat_timer = wl_event_loop_add_timer(server.loop, kbd_repeat_cb, NULL);
if (host.repeat_timer)
wl_event_source_timer_update(host.repeat_timer, TABBED_REPEAT_DELAY_MS);
}
/* ===================== Clipboard bridge implementation ===================== */
static void bridge_mimes_reset(void) {
for (size_t i = 0; i < bridge.mimes_len; i++)
free(bridge.mimes[i]);
bridge.mimes_len = 0;
}
static void bridge_mimes_append(const char *mime) {
if (bridge.mimes_len == bridge.mimes_cap) {
size_t ncap = bridge.mimes_cap ? bridge.mimes_cap * 2 : 8;
char **nm = realloc(bridge.mimes, ncap * sizeof(*nm));
if (!nm)
return;
bridge.mimes = nm;
bridge.mimes_cap = ncap;
}
bridge.mimes[bridge.mimes_len++] = strdup(mime);
}
static void bridge_inner_src_destroy(struct wl_listener *l, void *data);
static bool bridge_prefetch_mime_compatible(const char *mime);
static void bridge_prefetch_serve(const char *mime, int32_t fd);
/* Compositor-side nested source: when a nested client pastes, wlroots calls
our impl->send() and we pull the data from the host offer straight into the
fd wlroots gave us (zero copy, no relay). */
static void bridge_inner_source_send(struct wlr_data_source *src,
const char *mime, int32_t fd) {
(void)src;
if (bridge_prefetch_mime_compatible(mime)) {
bridge_prefetch_serve(mime, fd);
return;
}
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_offer) {
zwlr_data_control_offer_v1_receive(bridge.dc_offer, mime, fd);
close(fd);
} else if (bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE &&
bridge.dd_offer) {
wl_data_offer_receive(bridge.dd_offer, mime, fd);
close(fd);
} else {
close(fd);
}
}
static void bridge_inner_source_destroy(struct wlr_data_source *src) {
free(src);
}
static const struct wlr_data_source_impl bridge_inner_source_impl = {
.send = bridge_inner_source_send,
.destroy = bridge_inner_source_destroy,
};
static void bridge_set_inner_src(struct wlr_data_source *src) {
if (bridge.inner_src == src)
return;
if (bridge.inner_src) {
wl_list_remove(&bridge.inner_src_destroy.link);
bridge.inner_src = NULL;
}
bridge.inner_src = src;
if (src) {
bridge.inner_src_destroy.notify = bridge_inner_src_destroy;
wl_signal_add(&src->events.destroy, &bridge.inner_src_destroy);
}
}
static void bridge_inner_src_destroy(struct wl_listener *l, void *data) {
(void)data;
wl_list_remove(&l->link);
bridge.inner_src = NULL;
}
/* Put a host selection into the nested seat. The source stays valid for the
whole lifetime of the host offer, so pastes can be served asynchronously. */
static void bridge_mirror_to_nested(void) {
struct wlr_data_source *src = calloc(1, sizeof(*src));
if (!src) {
bridge_mimes_reset();
return;
}
wlr_data_source_init(src, &bridge_inner_source_impl);
for (size_t i = 0; i < bridge.mimes_len; i++) {
char **p = wl_array_add(&src->mime_types, sizeof(char *));
if (!p) {
for (size_t j = i; j < bridge.mimes_len; j++)
free(bridge.mimes[j]);
bridge.mimes_len = 0;
break;
}
*p = bridge.mimes[i];
bridge.mimes[i] = NULL; /* ownership moves into the wl_array */
}
bridge.mimes_len = 0;
bridge_set_inner_src(src);
wlr_seat_set_selection(server.seat, src,
wl_display_next_serial(server.wl_display));
}
static void bridge_clear_nested(void) {
if (!server.seat->selection_source)
return;
bridge.ignore_next_inner = true;
wlr_seat_set_selection(server.seat, NULL,
wl_display_next_serial(server.wl_display));
}
/* ---------------- text prefetch: serve pastes from a buffered copy -------- */
#define TABBED_PREFETCH_MAX (16 * 1024 * 1024)
static void bridge_prefetch_cancel(void) {
if (bridge.prefetch_src) {
wl_event_source_remove(bridge.prefetch_src);
bridge.prefetch_src = NULL;
}
if (bridge.prefetch_fd >= 0) {
close(bridge.prefetch_fd);
bridge.prefetch_fd = -1;
}
free(bridge.prefetch_buf);
bridge.prefetch_buf = NULL;
bridge.prefetch_len = bridge.prefetch_cap = 0;
bridge.prefetch_mime[0] = '\0';
bridge.prefetch_ready = false;
}
/* Drain the prefetch pipe into the buffer. Returns true when the stream is
finished (EOF/error) and the buffer should be kept as the paste payload. */
static bool bridge_prefetch_drain(int fd) {
for (;;) {
size_t room = bridge.prefetch_cap - bridge.prefetch_len;
if (bridge.prefetch_len + 4096 > bridge.prefetch_cap &&
bridge.prefetch_cap < TABBED_PREFETCH_MAX) {
size_t ncap = bridge.prefetch_cap ? bridge.prefetch_cap * 2 : 8192;
if (ncap > TABBED_PREFETCH_MAX)
ncap = TABBED_PREFETCH_MAX;
char *nb = realloc(bridge.prefetch_buf, ncap);
if (nb) {
bridge.prefetch_buf = nb;
bridge.prefetch_cap = ncap;
room = bridge.prefetch_cap - bridge.prefetch_len;
}
}
if (room == 0) {
/* cap reached: drain without storing to keep the writer unblocked */
char tmp;
ssize_t n = read(fd, &tmp, 1);
if (n > 0)
continue;
if (n == 0)
return true;
if (errno == EINTR)
continue;
if (errno == EAGAIN)
return false;
return true;
}
ssize_t n = read(fd, bridge.prefetch_buf + bridge.prefetch_len, room);
if (n > 0) {
bridge.prefetch_len += (size_t)n;
continue;
}
if (n == 0)
return true; /* EOF */
if (errno == EINTR)
continue;
if (errno == EAGAIN)
return false; /* more data pending */
return true; /* read error: keep whatever we got */
}
}
static int bridge_prefetch_cb(int fd, uint32_t mask, void *data) {
(void)data; (void)mask;
if (!bridge_prefetch_drain(fd))
return 0;
if (bridge.prefetch_src) {
wl_event_source_remove(bridge.prefetch_src);
bridge.prefetch_src = NULL;
}
close(fd);
bridge.prefetch_fd = -1;
bridge.prefetch_ready = true;
return 0;
}
/* Pick the best text MIME offered for prefetching. */
static const char *bridge_pick_text_mime(void) {
static const char *pref[] = {
"text/plain;charset=utf-8", "text/plain", "UTF8_STRING", NULL,
};
for (int i = 0; pref[i]; i++)
for (size_t j = 0; j < bridge.mimes_len; j++)
if (strcmp(bridge.mimes[j], pref[i]) == 0)
return bridge.mimes[j];
for (size_t j = 0; j < bridge.mimes_len; j++)
if (strncmp(bridge.mimes[j], "text/", 5) == 0)
return bridge.mimes[j];
return NULL;
}
/* Start pulling the current host offer's primary text MIME into our buffer.
Must be called while bridge.mimes holds the freshly collected list. */
static void bridge_prefetch_start(void) {
bridge_prefetch_cancel();
if (!bridge.mimes_len)
return;
const char *mime = bridge_pick_text_mime();
if (!mime)
return;
int p[2];
if (pipe2(p, O_CLOEXEC | O_NONBLOCK) != 0)
return;
bridge.prefetch_fd = p[0];
bridge.prefetch_src = wl_event_loop_add_fd(server.loop, p[0],
WL_EVENT_READABLE, bridge_prefetch_cb, NULL);
if (!bridge.prefetch_src) {
close(p[0]);
close(p[1]);
bridge.prefetch_fd = -1;
return;
}
snprintf(bridge.prefetch_mime, sizeof(bridge.prefetch_mime), "%s", mime);
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_offer) {
zwlr_data_control_offer_v1_receive(bridge.dc_offer, mime, p[1]);
close(p[1]);
} else if (bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE &&
bridge.dd_offer) {
wl_data_offer_receive(bridge.dd_offer, mime, p[1]);
close(p[1]);
} else {
bridge_prefetch_cancel();
}
}
/* The requested MIME can be served from the prefetched plain-text buffer. */
static bool bridge_prefetch_mime_compatible(const char *mime) {
if (!bridge.prefetch_ready || bridge.prefetch_mime[0] == '\0')
return false;
if (strcmp(mime, bridge.prefetch_mime) == 0)
return true;
bool a = strncmp(mime, "text/plain", 10) == 0 ||
strcmp(mime, "UTF8_STRING") == 0;
bool b = strncmp(bridge.prefetch_mime, "text/plain", 10) == 0 ||
strcmp(bridge.prefetch_mime, "UTF8_STRING") == 0;
return a && b;
}
static void bridge_prefetch_serve(const char *mime, int32_t fd) {
if (!bridge_prefetch_mime_compatible(mime)) {
close(fd);
return;
}
size_t off = 0;
while (off < bridge.prefetch_len) {
ssize_t n = write(fd, bridge.prefetch_buf + off,
bridge.prefetch_len - off);
if (n > 0) {
off += (size_t)n;
} else if (n < 0 && (errno == EAGAIN || errno == EINTR)) {
continue;
} else {
break;
}
}
close(fd);
}
/* ---------------- host backend: wlr-data-control ---------------- */
static void bridge_dc_source_send(void *data,
struct zwlr_data_control_source_v1 *src, const char *mime, int32_t fd) {
(void)data; (void)src;
if (bridge.inner_src)
wlr_data_source_send(bridge.inner_src, mime, fd);
else
close(fd);
}
static void bridge_dc_source_cancelled(void *data,
struct zwlr_data_control_source_v1 *src) {
(void)data;
zwlr_data_control_source_v1_destroy(src);
bridge.dc_source = NULL;
bridge.dc_pending_echo = false;
bridge.dd_pending_echo = false;
}
static const struct zwlr_data_control_source_v1_listener bridge_dc_source_listener = {
.send = bridge_dc_source_send,
.cancelled = bridge_dc_source_cancelled,
};
static void bridge_dc_offer_mime(void *data,
struct zwlr_data_control_offer_v1 *offer, const char *mime) {
(void)data; (void)offer;
bridge_mimes_append(mime);
}
static const struct zwlr_data_control_offer_v1_listener bridge_dc_offer_listener = {
.offer = bridge_dc_offer_mime,
};
static void bridge_dc_device_finished(void *data,
struct zwlr_data_control_device_v1 *dev) {
(void)data; (void)dev;
bridge.backend = bridge.dd ? BRIDGE_BACKEND_WL_DATA_DEVICE
: BRIDGE_BACKEND_NONE;
if (bridge.dc_offer) {
zwlr_data_control_offer_v1_destroy(bridge.dc_offer);
bridge.dc_offer = NULL;
}
if (bridge.dc_device) {
zwlr_data_control_device_v1_destroy(bridge.dc_device);
bridge.dc_device = NULL;
}
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: host data-control "
"finished; falling back to %s\n",
bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE ?
"focus-based wl_data_device" : "none");
}
static void bridge_dc_device_data_offer(void *data,
struct zwlr_data_control_device_v1 *dev,
struct zwlr_data_control_offer_v1 *offer) {
(void)data; (void)dev;
if (bridge.dc_pending_echo) {
bridge.dc_pending_echo = false;
bridge.dc_echo_offer = offer;
}
bridge_mimes_reset();
zwlr_data_control_offer_v1_add_listener(offer, &bridge_dc_offer_listener, NULL);
}
static void bridge_dc_device_selection(void *data,
struct zwlr_data_control_device_v1 *dev,
struct zwlr_data_control_offer_v1 *offer) {
(void)data; (void)dev;
bridge_prefetch_cancel();
if (offer && offer == bridge.dc_echo_offer) {
bridge.dc_echo_offer = NULL;
bridge_mimes_reset();
if (offer)
zwlr_data_control_offer_v1_destroy(offer);
return;
}
bridge.dc_pending_echo = false;
bridge.dd_pending_echo = false;
if (bridge.dc_offer)
zwlr_data_control_offer_v1_destroy(bridge.dc_offer);
bridge.dc_offer = offer;
if (!offer) {
bridge_mimes_reset();
bridge_clear_nested();
return;
}
bridge_prefetch_start();
bridge_mirror_to_nested();
}
/* Primary selection is intentionally not bridged; the offer must still be
acknowledged (NULL handler would make libwayland abort on the event) and
destroyed so it does not leak. */
static void bridge_dc_device_primary_selection(void *data,
struct zwlr_data_control_device_v1 *dev,
struct zwlr_data_control_offer_v1 *offer) {
(void)data; (void)dev;
if (offer)
zwlr_data_control_offer_v1_destroy(offer);
}
static const struct zwlr_data_control_device_v1_listener bridge_dc_listener = {
.data_offer = bridge_dc_device_data_offer,
.selection = bridge_dc_device_selection,
.primary_selection = bridge_dc_device_primary_selection,
.finished = bridge_dc_device_finished,
};
/* ---------------- host backend: wl_data_device (focus-based) ---------------- */
static void bridge_dd_source_send(void *data, struct wl_data_source *src,
const char *mime, int32_t fd) {
(void)data; (void)src;
if (bridge.inner_src)
wlr_data_source_send(bridge.inner_src, mime, fd);
else
close(fd);
}
static void bridge_dd_source_cancelled(void *data, struct wl_data_source *src) {
(void)data;
wl_data_source_destroy(src);
bridge.dd_source = NULL;
bridge.dc_pending_echo = false;
bridge.dd_pending_echo = false;
bridge.host_set_pending = true;
}
static const struct wl_data_source_listener bridge_dd_source_listener = {
.send = bridge_dd_source_send,
.cancelled = bridge_dd_source_cancelled,
};
static void bridge_dd_offer_mime(void *data, struct wl_data_offer *offer,
const char *mime) {
(void)data; (void)offer;
bridge_mimes_append(mime);
}
static const struct wl_data_offer_listener bridge_dd_offer_listener = {
.offer = bridge_dd_offer_mime,
/* source_actions / action left unset: drag & drop is not bridged */
};
static void bridge_dd_data_offer(void *data, struct wl_data_device *dev,
struct wl_data_offer *offer) {
(void)data; (void)dev;
if (bridge.dd_pending_echo) {
bridge.dd_pending_echo = false;
bridge.dd_echo_offer = offer;
}
bridge_mimes_reset();
wl_data_offer_add_listener(offer, &bridge_dd_offer_listener, NULL);
}
static void bridge_dd_selection(void *data, struct wl_data_device *dev,
struct wl_data_offer *offer) {
(void)data; (void)dev;
bridge_prefetch_cancel();
if (offer && offer == bridge.dd_echo_offer) {
bridge.dd_echo_offer = NULL;
bridge_mimes_reset();
if (offer)
wl_data_offer_destroy(offer);
return;
}
bridge.dc_pending_echo = false;
bridge.dd_pending_echo = false;
if (bridge.dd_offer)
wl_data_offer_destroy(bridge.dd_offer);
bridge.dd_offer = offer;
if (!offer) {
/* selection cleared, or we lost keyboard focus: keep the last-known
nested selection rather than emptying it on every focus change */
bridge_mimes_reset();
return;
}
bridge_prefetch_start();
bridge_mirror_to_nested();
}
static const struct wl_data_device_listener bridge_dd_listener = {
.data_offer = bridge_dd_data_offer,
.selection = bridge_dd_selection,
/* enter/leave/motion/drop left unset: drag & drop is not bridged */
};
/* ---------------- nested seat side ---------------- */
static void bridge_seat_request_set_selection(struct wl_listener *l, void *data) {
(void)l;
struct wlr_seat_request_set_selection_event *event = data;
bridge.inner_clear_requested = (event->source == NULL);
wlr_seat_set_selection(server.seat, event->source, event->serial);
}
static void bridge_host_set_selection(struct wlr_data_source *src) {
bridge_set_inner_src(src);
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_device) {
if (bridge.dc_source) {
zwlr_data_control_source_v1_destroy(bridge.dc_source);
bridge.dc_source = NULL;
}
struct zwlr_data_control_source_v1 *s =
zwlr_data_control_manager_v1_create_data_source(bridge.dc_manager);
char **p;
wl_array_for_each(p, &src->mime_types)
zwlr_data_control_source_v1_offer(s, *p);
zwlr_data_control_source_v1_add_listener(s, &bridge_dc_source_listener, NULL);
bridge.dc_source = s;
bridge.dc_pending_echo = true;
bridge.dd_pending_echo = true;
zwlr_data_control_device_v1_set_selection(bridge.dc_device, s);
bridge.host_set_pending = false;
return;
}
if (bridge.backend == BRIDGE_BACKEND_WL_DATA_DEVICE && bridge.dd) {
if (bridge.dd_source) {
wl_data_source_destroy(bridge.dd_source);
bridge.dd_source = NULL;
}
if (!bridge.kbd_serial) {
bridge.host_set_pending = true;
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: no host keyboard "
"serial yet; deferring host set_selection until focus\n");
return;
}
struct wl_data_source *s =
wl_data_device_manager_create_data_source(bridge.ddm);
char **p;
wl_array_for_each(p, &src->mime_types)
wl_data_source_offer(s, *p);
wl_data_source_add_listener(s, &bridge_dd_source_listener, NULL);
bridge.dd_source = s;
bridge.dc_pending_echo = true;
bridge.dd_pending_echo = true;
wl_data_device_set_selection(bridge.dd, s, bridge.kbd_serial);
bridge.host_set_pending = false;
return;
}
}
static void bridge_host_clear(void) {
if (bridge.backend == BRIDGE_BACKEND_DATA_CONTROL && bridge.dc_device)
zwlr_data_control_device_v1_set_selection(bridge.dc_device, NULL);
/* focus-based backend: nothing reliable to clear without focus/serial */
}
static void bridge_seat_set_selection(struct wl_listener *l, void *data) {
(void)l; (void)data;
struct wlr_data_source *src = server.seat->selection_source;
if (src && src->impl == &bridge_inner_source_impl)
return; /* our own host->nested mirror */
if (bridge.ignore_next_inner) {
bridge.ignore_next_inner = false;
return;
}
if (!src) {
if (bridge.inner_clear_requested) {
bridge.inner_clear_requested = false;
bridge_host_clear();
}
return;
}
bridge.inner_clear_requested = false;
bridge_host_set_selection(src);
}
/* ---------------- lifecycle ---------------- */
static void bridge_attach_seat(void) {
bridge.request_set_selection.notify = bridge_seat_request_set_selection;
wl_signal_add(&server.seat->events.request_set_selection,
&bridge.request_set_selection);
bridge.set_selection.notify = bridge_seat_set_selection;
wl_signal_add(&server.seat->events.set_selection, &bridge.set_selection);
}
static void bridge_init(void) {
if (!host.seat) {
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: no host seat; disabled\n");
return;
}
if (bridge.ddm) {
bridge.dd = wl_data_device_manager_get_data_device(bridge.ddm, host.seat);
wl_data_device_add_listener(bridge.dd, &bridge_dd_listener, NULL);
}
if (bridge.dc_manager) {
bridge.dc_device = zwlr_data_control_manager_v1_get_data_device(
bridge.dc_manager, host.seat);
zwlr_data_control_device_v1_add_listener(bridge.dc_device,
&bridge_dc_listener, NULL);
bridge.backend = BRIDGE_BACKEND_DATA_CONTROL;
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: host data-control backend\n");
} else if (bridge.dd) {
bridge.backend = BRIDGE_BACKEND_WL_DATA_DEVICE;
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: focus-based wl_data_device "
"backend (host lacks wlr-data-control; host->nested sync needs "
"focus)\n");
} else {
tabbed_log(TABBED_LOG_INFO, "clipboard bridge: no host clipboard path\n");
}
}
static void bridge_cleanup(void) {
if (bridge.inner_src) {
wl_list_remove(&bridge.inner_src_destroy.link);
bridge.inner_src = NULL;
}
if (bridge.dc_source) {
zwlr_data_control_source_v1_destroy(bridge.dc_source);
bridge.dc_source = NULL;
}
if (bridge.dd_source) {
wl_data_source_destroy(bridge.dd_source);
bridge.dd_source = NULL;
}
if (bridge.dc_offer) {
zwlr_data_control_offer_v1_destroy(bridge.dc_offer);
bridge.dc_offer = NULL;
}
if (bridge.dd_offer) {
wl_data_offer_destroy(bridge.dd_offer);
bridge.dd_offer = NULL;
}
if (bridge.dc_device) {
zwlr_data_control_device_v1_destroy(bridge.dc_device);
bridge.dc_device = NULL;
}
bridge_mimes_reset();
free(bridge.mimes);
bridge.mimes = NULL;
bridge.mimes_cap = 0;
bridge_prefetch_cancel();
bridge.backend = BRIDGE_BACKEND_NONE;
}
/* ===================== Host-side listeners ===================== */
static void host_registry_global(void *data, struct wl_registry *registry,
uint32_t name, const char *interface,
uint32_t version)
{
(void)data;
if (strcmp(interface, wl_compositor_interface.name) == 0) {
host.compositor = wl_registry_bind(registry, name,
&wl_compositor_interface, version < 4 ? version : 4);
} else if (strcmp(interface, xdg_wm_base_interface.name) == 0) {
host.wm_base = wl_registry_bind(registry, name,
&xdg_wm_base_interface, version < 1 ? version : 1);
} else if (strcmp(interface, zxdg_decoration_manager_v1_interface.name) == 0) {
host.decoration = wl_registry_bind(registry, name,
&zxdg_decoration_manager_v1_interface, 1);
} else if (strcmp(interface, wl_seat_interface.name) == 0) {
host.seat = wl_registry_bind(registry, name,
&wl_seat_interface, version < 5 ? version : 5);
} else if (strcmp(interface, wl_data_device_manager_interface.name) == 0) {
bridge.ddm = wl_registry_bind(registry, name,
&wl_data_device_manager_interface, version < 3 ? version : 3);
} else if (strcmp(interface,
zwlr_data_control_manager_v1_interface.name) == 0) {
bridge.dc_manager = wl_registry_bind(registry, name,
&zwlr_data_control_manager_v1_interface, version < 2 ? version : 2);
}
}
static void host_registry_global_remove(void *data, struct wl_registry *registry,
uint32_t name)
{
(void)data; (void)registry; (void)name;
}
static const struct wl_registry_listener host_registry_listener = {
.global = host_registry_global,
.global_remove = host_registry_global_remove,
};
static void host_xdg_wm_ping(void *data, struct xdg_wm_base *wm, uint32_t serial) {
(void)data;
xdg_wm_base_pong(wm, serial);
}
static const struct xdg_wm_base_listener host_xdg_wm_listener = {
.ping = host_xdg_wm_ping,
};
static void host_xdg_surface_configure(void *data, struct xdg_surface *s,
uint32_t serial)
{
(void)data;
xdg_surface_ack_configure(s, serial);
}
static const struct xdg_surface_listener host_xdg_surface_listener = {
.configure = host_xdg_surface_configure,
};
static void host_resize(int width, int height) {
if (width < 50) width = 50;
if (height < 50) height = 50;
host.width = width;
host.height = height;
if (host.egl_window)
wl_egl_window_resize(host.egl_window, width, height, 0, 0);
if (host.egl_ok) {
eglMakeCurrent(host.egl_display, host.egl_surface, host.egl_surface,
host.egl_context);
}
output_resize(width, height);
needs_redraw = true;
}
static void host_xdg_toplevel_configure(void *data, struct xdg_toplevel *t,
int32_t width, int32_t height,
struct wl_array *states)
{
(void)data; (void)t;
host.fullscreen = false;
uint32_t *state;
wl_array_for_each(state, states)
if (*state == XDG_TOPLEVEL_STATE_FULLSCREEN)
host.fullscreen = true;
if (width > 0 && height > 0)
host_resize(width, height);
host.configured = true;
}
static void host_xdg_toplevel_close(void *data, struct xdg_toplevel *t) {
(void)data; (void)t;
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: window closed\n");
shutdown_cleanup();
exit(0);
}
static const struct xdg_toplevel_listener host_xdg_toplevel_listener = {
.configure = host_xdg_toplevel_configure,
.close = host_xdg_toplevel_close,
};
static void host_decoration_configure(void *data,
struct zxdg_toplevel_decoration_v1 *dec,
uint32_t mode)
{
(void)data; (void)dec;
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: host decoration mode=%u (%s)\n",
mode,
mode == ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE
? "server-side" : "client-side");
}
static const struct zxdg_toplevel_decoration_v1_listener host_decoration_listener = {
.configure = host_decoration_configure,
};
/* ---------- host keyboard ---------- */
static void host_kbd_keymap(void *data, struct wl_keyboard *kbd, uint32_t format,
int32_t fd, uint32_t size)
{
(void)data; (void)kbd;
if (format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1 || size == 0 || !server.xkb_ctx) {
close(fd);
return;
}
tabbed_log(TABBED_LOG_DEBUG, "tabbed-wlroots: host keymap: format=%u size=%u\n",
format, size);
/* The keymap is an shm file (size includes the trailing NUL), so mmap it
like wlroots' own clients do; fall back to a plain read loop. */
char *buf = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
bool mapped = buf != MAP_FAILED;
size_t total = mapped ? size : 0;
if (!mapped) {
buf = malloc(size + 1);
if (!buf) {
close(fd);
return;
}
while (total < size) {
ssize_t rd = read(fd, buf + total, size - total);
if (rd > 0) {
total += rd;
} else if (rd < 0 && (errno == EAGAIN || errno == EINTR)) {
struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 };
nanosleep(&ts, NULL);
} else {
break;
}
}
buf[total] = '\0';
if (total < size)
tabbed_log(TABBED_LOG_DEBUG,
"tabbed-wlroots: host keymap: short read %zu/%u bytes\n",
total, size);
}
struct xkb_keymap *km = xkb_keymap_new_from_string(
server.xkb_ctx, buf, XKB_KEYMAP_FORMAT_TEXT_V1, 0);
if (km) {
wlr_keyboard_set_keymap(&server.keyboard, km);
if (server.xkb_keymap)
xkb_keymap_unref(server.xkb_keymap);
server.xkb_keymap = km;
tabbed_log(TABBED_LOG_DEBUG, "tabbed-wlroots: host keymap parsed OK\n");
} else {
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: failed to parse host "
"keymap (%u bytes); keeping fallback keymap\n", size);
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
char hex[129];
size_t n = total < 64 ? total : 64;
for (size_t i = 0; i < n; i++)
snprintf(hex + i * 2, sizeof(hex) - i * 2, "%02x",
(unsigned char)buf[i]);
tabbed_log(TABBED_LOG_DEBUG, "tabbed-wlroots: keymap head (%zu/%u "
"bytes): %s\n", n, size, hex);
}
}
if (mapped)
munmap(buf, size);
else
free(buf);
close(fd);
}
static void host_kbd_enter(void *data, struct wl_keyboard *kbd, uint32_t serial,
struct wl_surface *surface, struct wl_array *keys)
{
(void)data; (void)kbd; (void)surface; (void)keys;
/* Remember the enter serial for host clipboard set_selection, and drop any
stale echo guard from a set_selection made while unfocused. */
bridge.kbd_serial = serial;
bridge.dc_pending_echo = false;
bridge.dd_pending_echo = false;
if (bridge.host_set_pending && server.seat->selection_source &&
server.seat->selection_source->impl != &bridge_inner_source_impl) {
bridge.host_set_pending = false;
bridge_host_set_selection(server.seat->selection_source);
}
if (server.active)
wlr_seat_keyboard_notify_enter(server.seat,
server.active->base->surface,
server.keyboard.keycodes, server.keyboard.num_keycodes,
&server.keyboard.modifiers);
}
static void host_kbd_leave(void *data, struct wl_keyboard *kbd, uint32_t serial,
struct wl_surface *surface)
{
(void)data; (void)kbd; (void)serial; (void)surface;
wlr_seat_keyboard_notify_clear_focus(server.seat);
}
/* Temporary grey-band diagnosis: dump the scene buffer (client content as
wlroots rendered it) to /tmp/scene_N.ppm. The buffer memory is top-down,
XRGB8888. */
static void dump_scene_buffer(struct wlr_buffer *buf) {
if (!buf) return;
void *data;
uint32_t format;
size_t stride;
if (!wlr_buffer_begin_data_ptr_access(buf, WLR_BUFFER_DATA_PTR_ACCESS_READ,
&data, &format, &stride)) {
tabbed_log(TABBED_LOG_INFO, "[dump] scene buffer not CPU-readable "
"(%dx%d)\n", buf->width, buf->height);
return;
}
/* [diag] classify the buffer: count non-black pixels so we can tell
"client painted nothing" from "we failed to import". */
int w = buf->width, h = buf->height;
unsigned long nonblack = 0, lum = 0;
unsigned char *p = data;
for (int y = 0; y < h; y++) {
unsigned char *row = p + (size_t)y * stride;
for (int x = 0; x < w; x++) {
uint32_t b = row[x*4+0];
unsigned long v = b + row[x*4+1] + row[x*4+2];
lum += v;
if (v > 12) nonblack++;
}
}
tabbed_log(TABBED_LOG_INFO, "[dump] scene %dx%d fmt=0x%x "
"nonblack=%lu/%lu mean=%lu\n", w, h, format, nonblack,
(unsigned long)w * h, lum / ((unsigned long)w * h + 1));
char path[64];
snprintf(path, sizeof(path), "/tmp/scene_%d.ppm", dump_seq);
FILE *f = fopen(path, "wb");
if (f) {
int w = buf->width, h = buf->height;
fprintf(f, "P6\n%d %d\n255\n", w, h);
unsigned char *p = data;
for (int y = 0; y < h; y++) {
unsigned char *row = p + (size_t)y * stride;
for (int x = 0; x < w; x++) {
fputc(row[x*4+0], f);
fputc(row[x*4+1], f);
fputc(row[x*4+2], f);
}
}
fclose(f);
tabbed_log(TABBED_LOG_INFO, "[dump] scene buffer -> %s (%dx%d, "
"fmt=0x%x)\n", path, w, h, format);
}
wlr_buffer_end_data_ptr_access(buf);
dump_seq++;
}
/* Dump what we are about to present to the host (our composited back buffer).
Must be called with the host EGL context current and back buffer freshly
drawn. GL readback is bottom-up, so flip vertically. */
static void dump_host_back(void) {
int w = host.width, h = host.height;
unsigned char *px = malloc((size_t)w * h * 4);
if (!px) return;
glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, px);
unsigned long nonblack = 0, lum = 0;
for (int y = 0; y < h * w; y++) {
unsigned long v = px[y*4+0] + px[y*4+1] + px[y*4+2];
lum += v;
if (v > 12) nonblack++;
}
tabbed_log(TABBED_LOG_INFO, "[dump] host %dx%d nonblack=%lu/%lu mean=%lu\n",
w, h, nonblack, (unsigned long)w * h,
lum / ((unsigned long)w * h + 1));
char path[64];
snprintf(path, sizeof(path), "/tmp/host_%d.ppm", dump_seq);
FILE *f = fopen(path, "wb");
if (f) {
fprintf(f, "P6\n%d %d\n255\n", w, h);
for (int y = h - 1; y >= 0; y--) {
unsigned char *row = px + (size_t)y * w * 4;
for (int x = 0; x < w; x++) {
fputc(row[x*4+0], f);
fputc(row[x*4+1], f);
fputc(row[x*4+2], f);
}
}
fclose(f);
tabbed_log(TABBED_LOG_INFO, "[dump] host back buffer -> %s (%dx%d)\n",
path, w, h);
}
free(px);
dump_seq++;
}
static void host_kbd_key(void *data, struct wl_keyboard *kbd, uint32_t serial,
uint32_t time, uint32_t key, uint32_t state)
{
(void)data; (void)kbd;
bridge.kbd_serial = serial;
tabbed_log(TABBED_LOG_DEBUG,
"[kbd] key=%u state=%u ctrl=%d repeat_key=%u\n", key, state,
(host.host_mods_depressed & (1u << 2)) != 0, host.repeat_key);
/* F11 toggles the host window fullscreen. */
if (state == WL_KEYBOARD_KEY_STATE_PRESSED && key == 87) {
host.fullscreen = !host.fullscreen;
tabbed_log(TABBED_LOG_INFO, "host fullscreen=%d\n", host.fullscreen);
if (host.fullscreen)
xdg_toplevel_set_fullscreen(host.toplevel, NULL);
else
xdg_toplevel_unset_fullscreen(host.toplevel);
return;
}
/* Temporary: F9/F10 frame dumps for the grey-band diagnosis. */
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (key == 67) /* F9 */ {
dump_scene_req = true;
needs_redraw = true;
return;
}
if (key == 68) /* F10 */ {
dump_host_req = true;
needs_redraw = true;
return;
}
}
/* Ctrl+PageUp / Ctrl+PageDown cycle tabs. The host sends a single
physical press, so repeats are synthesized by kbd_repeat_start();
subsequent host presses of an already-held key are ignored so a
host-sent repeat never cycles twice. */
bool ctrl = (host.host_mods_depressed & (1u << 2)) != 0;
if (ctrl && (key == 104 || key == 109)) {
if (state == WL_KEYBOARD_KEY_STATE_PRESSED) {
if (host.repeat_key != key) {
int dir = key == 109 ? 1 : -1;
cycle_tab(dir);
kbd_repeat_start(key, dir);
}
} else {
kbd_repeat_cancel();
}
return;
}
/* any other key activity stops the tab-switch repeat */
kbd_repeat_cancel();
struct wlr_keyboard_key_event event = {
.time_msec = time,
.keycode = key,
.update_state = true,
.state = state,
};
wlr_keyboard_notify_key(&server.keyboard, &event);
wlr_seat_set_keyboard(server.seat, &server.keyboard);
wlr_seat_keyboard_notify_key(server.seat, time, key, state);
}
static void host_kbd_modifiers(void *data, struct wl_keyboard *kbd, uint32_t serial,
uint32_t mods_depressed, uint32_t mods_latched,
uint32_t mods_locked, uint32_t group)
{
(void)data; (void)kbd; (void)serial;
host.host_mods_depressed = mods_depressed;
if (host.repeat_key && !(mods_depressed & (1u << 2)))
kbd_repeat_cancel();
wlr_keyboard_notify_modifiers(&server.keyboard, mods_depressed,
mods_latched, mods_locked, group);
wlr_seat_keyboard_notify_modifiers(server.seat, &server.keyboard.modifiers);
}
static void host_kbd_repeat_info(void *data, struct wl_keyboard *kbd,
int32_t rate, int32_t delay)
{
(void)data; (void)kbd;
wlr_keyboard_set_repeat_info(&server.keyboard, rate, delay);
}
static const struct wl_keyboard_listener host_kbd_listener = {
.keymap = host_kbd_keymap,
.enter = host_kbd_enter,
.leave = host_kbd_leave,
.key = host_kbd_key,
.modifiers = host_kbd_modifiers,
.repeat_info = host_kbd_repeat_info,
};
/* ============ stdin event feed (test/driver channel) ============
Reads raw keyboard events from stdin and dispatches them into the
same host_kbd_key / host_kbd_modifiers handlers a real host would
deliver, so the key handler itself stays under test. Two records:
1 | u32 key | u32 state (WL_KEYBOARD_KEY_STATE_*)
2 | u32 mods | u32 latched | u32 locked | u32 group
*/
#define CTL_CMD_KEY 1
#define CTL_CMD_MODS 2
#define CTL_MAX_MSG 20 /* 1 + 4 + 4 (or 1 + 16) after padding */
#define CTL_BUF_SIZE 512
static uint32_t ctl_serial;
static uint32_t ctl_time;
static void ctl_dispatch(const uint8_t *msg, size_t len) {
uint32_t u32[4];
if (len < 5) return;
switch (msg[0]) {
case CTL_CMD_KEY:
if (len < 9) return;
memcpy(u32, msg + 1, 8);
ctl_time += 1;
host_kbd_key(NULL, NULL, ++ctl_serial, ctl_time, u32[0], u32[1]);
break;
case CTL_CMD_MODS:
if (len < 17) return;
memcpy(u32, msg + 1, 16);
ctl_time += 1;
host_kbd_modifiers(NULL, NULL, ++ctl_serial, u32[0], u32[1],
u32[2], u32[3]);
break;
}
}
static int ctl_cb(int fd, uint32_t mask, void *data) {
static uint8_t buf[CTL_BUF_SIZE];
static size_t have = 0;
(void)data;
/* stdin closed: stop watching but keep the compositor running */
if (mask & (WL_EVENT_HANGUP | WL_EVENT_ERROR)) {
wl_event_source_remove(server.ctl_source);
server.ctl_source = NULL;
return 0;
}
ssize_t n = read(fd, buf + have, sizeof(buf) - have);
if (n <= 0) {
if (n == 0) {
wl_event_source_remove(server.ctl_source);
server.ctl_source = NULL;
}
return 0;
}
have += (size_t)n;
uint8_t msg[CTL_MAX_MSG];
while (have >= 5) {
size_t need = (buf[0] == CTL_CMD_MODS) ? 17 : 9;
if (have < need) break;
memcpy(msg, buf, need);
ctl_dispatch(msg, need);
memmove(buf, buf + need, have - need);
have -= need;
}
return 0;
}
/* ---------- host pointer ---------- */
static struct wlr_surface *surface_at(double x, double y,
double *sx, double *sy)
{
struct wlr_scene_node *node = wlr_scene_node_at(
&server.scene->tree.node, x, y, sx, sy);
if (node && node->type == WLR_SCENE_NODE_BUFFER) {
struct wlr_scene_buffer *buffer = wlr_scene_buffer_from_node(node);
struct wlr_scene_surface *scene_surface =
wlr_scene_surface_try_from_buffer(buffer);
if (scene_surface)
return scene_surface->surface;
}
return NULL;
}
static void host_ptr_motion(uint32_t time, double sx, double sy) {
if (!server.seat) return;
double nx = 0.0, ny = 0.0;
struct wlr_surface *surface = surface_at(sx, sy, &nx, &ny);
if (surface) {
wlr_seat_pointer_notify_enter(server.seat, surface, nx, ny);
wlr_seat_pointer_notify_motion(server.seat, time, nx, ny);
} else {
wlr_seat_pointer_notify_clear_focus(server.seat);
}
wlr_seat_pointer_notify_frame(server.seat);
}
static void host_ptr_enter(void *data, struct wl_pointer *p, uint32_t serial,
struct wl_surface *surface, wl_fixed_t sx, wl_fixed_t sy)
{
(void)data; (void)p; (void)serial; (void)surface;
host_ptr_motion(0, wl_fixed_to_double(sx), wl_fixed_to_double(sy));
}
static void host_ptr_leave(void *data, struct wl_pointer *p, uint32_t serial,
struct wl_surface *surface)
{
(void)data; (void)p; (void)serial; (void)surface;
if (server.seat) {
wlr_seat_pointer_notify_clear_focus(server.seat);
wlr_seat_pointer_notify_frame(server.seat);
}
}
static void host_ptr_motion_event(void *data, struct wl_pointer *p, uint32_t time,
wl_fixed_t sx, wl_fixed_t sy)
{
(void)data; (void)p;
host_ptr_motion(time, wl_fixed_to_double(sx), wl_fixed_to_double(sy));
}
static void host_ptr_button(void *data, struct wl_pointer *p, uint32_t serial,
uint32_t time, uint32_t button, uint32_t state)
{
(void)data; (void)p; (void)serial;
if (!server.seat) return;
wlr_seat_pointer_notify_button(server.seat, time, button, state);
wlr_seat_pointer_notify_frame(server.seat);
}
static void host_ptr_axis(void *data, struct wl_pointer *p, uint32_t time,
uint32_t axis, wl_fixed_t value)
{
(void)data; (void)p;
if (!server.seat) return;
int32_t discrete = axis < 2 ? host.axis_discrete[axis] : 0;
host.axis_discrete[axis] = 0;
wlr_seat_pointer_notify_axis(server.seat, time, axis,
wl_fixed_to_double(value), discrete,
host.axis_source,
WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL);
wlr_seat_pointer_notify_frame(server.seat);
}
static void host_ptr_axis_source(void *data, struct wl_pointer *p, uint32_t source)
{
(void)data; (void)p;
host.axis_source = source;
}
static void host_ptr_axis_discrete(void *data, struct wl_pointer *p, uint32_t axis,
int32_t discrete)
{
(void)data; (void)p;
if (axis < 2)
host.axis_discrete[axis] += discrete;
}
static void host_ptr_axis_stop(void *data, struct wl_pointer *p, uint32_t time,
uint32_t axis)
{
(void)data; (void)p; (void)time; (void)axis;
}
static void host_ptr_frame(void *data, struct wl_pointer *p) {
(void)data; (void)p;
}
static const struct wl_pointer_listener host_ptr_listener = {
.enter = host_ptr_enter,
.leave = host_ptr_leave,
.motion = host_ptr_motion_event,
.button = host_ptr_button,
.axis = host_ptr_axis,
.frame = host_ptr_frame,
.axis_source = host_ptr_axis_source,
.axis_stop = host_ptr_axis_stop,
.axis_discrete = host_ptr_axis_discrete,
};
/* ===================== Host init / EGL ===================== */
static bool host_init(void) {
const char *disp = getenv("WAYLAND_DISPLAY");
if (!disp)
disp = "wayland-0";
host.display = wl_display_connect(NULL);
if (!host.display) {
fprintf(stderr, "tabbed-wlroots: cannot connect to host compositor "
"(WAYLAND_DISPLAY=%s). Run me inside a running Wayland session.\n",
disp);
return false;
}
host.registry = wl_display_get_registry(host.display);
wl_registry_add_listener(host.registry, &host_registry_listener, NULL);
wl_display_roundtrip(host.display);
if (!host.compositor || !host.wm_base) {
fprintf(stderr, "tabbed-wlroots: host lacks compositor/xdg_wm_base\n");
return false;
}
host.surface = wl_compositor_create_surface(host.compositor);
host.xdg_surface = xdg_wm_base_get_xdg_surface(host.wm_base, host.surface);
host.toplevel = xdg_surface_get_toplevel(host.xdg_surface);
xdg_toplevel_set_title(host.toplevel, "tabbed-wlroots");
if (host.decoration) {
/* Ask the host compositor (labwc) for a server-side title bar, so
the nested window always has a host-drawn decoration above our
own tab content (including full-bleed clients like the test). */
host.decoration_obj = zxdg_decoration_manager_v1_get_toplevel_decoration(
host.decoration, host.toplevel);
zxdg_toplevel_decoration_v1_add_listener(host.decoration_obj,
&host_decoration_listener, NULL);
zxdg_toplevel_decoration_v1_set_mode(host.decoration_obj,
ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE);
}
xdg_surface_add_listener(host.xdg_surface, &host_xdg_surface_listener, NULL);
xdg_toplevel_add_listener(host.toplevel, &host_xdg_toplevel_listener, NULL);
xdg_wm_base_add_listener(host.wm_base, &host_xdg_wm_listener, NULL);
wl_surface_commit(host.surface);
wl_display_roundtrip(host.display);
if (host.seat) {
host.keyboard = wl_seat_get_keyboard(host.seat);
host.pointer = wl_seat_get_pointer(host.seat);
if (host.keyboard)
wl_keyboard_add_listener(host.keyboard, &host_kbd_listener, NULL);
if (host.pointer)
wl_pointer_add_listener(host.pointer, &host_ptr_listener, NULL);
}
bridge_init();
/* Try EGL on the host wayland platform */
pfn_eglGetPlatformDisplayEXT = (PFNEGLGETPLATFORMDISPLAYEXTPROC)
eglGetProcAddress("eglGetPlatformDisplayEXT");
if (!pfn_eglGetPlatformDisplayEXT) {
fprintf(stderr, "tabbed-wlroots: no EGL platform support; "
"falling back to software rendering\n");
host.egl_ok = false;
return true;
}
host.egl_display = pfn_eglGetPlatformDisplayEXT(EGL_PLATFORM_WAYLAND_EXT,
host.display, NULL);
if (host.egl_display == EGL_NO_DISPLAY) {
fprintf(stderr, "tabbed-wlroots: EGL display failed; "
"falling back to software rendering\n");
host.egl_ok = false;
return true;
}
if (!eglInitialize(host.egl_display, NULL, NULL)) {
fprintf(stderr, "tabbed-wlroots: eglInitialize failed; "
"falling back to software rendering\n");
host.egl_ok = false;
return true;
}
eglBindAPI(EGL_OPENGL_ES_API);
const EGLint config_attribs[] = {
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 0,
EGL_NONE
};
EGLint ncfg = 0;
if (!eglChooseConfig(host.egl_display, config_attribs, &host.egl_config,
1, &ncfg) || ncfg < 1) {
fprintf(stderr, "tabbed-wlroots: no EGL config; software fallback\n");
host.egl_ok = false;
return true;
}
const EGLint ctx_attribs[] = {
EGL_CONTEXT_CLIENT_VERSION, 2,
EGL_NONE
};
host.egl_context = eglCreateContext(host.egl_display, host.egl_config,
EGL_NO_CONTEXT, ctx_attribs);
if (host.egl_context == EGL_NO_CONTEXT) {
fprintf(stderr, "tabbed-wlroots: EGL context failed; software fallback\n");
host.egl_ok = false;
return true;
}
host.width = 1280;
host.height = 800;
host.egl_window = wl_egl_window_create(host.surface, host.width, host.height);
host.egl_surface = eglCreateWindowSurface(host.egl_display, host.egl_config,
(EGLNativeWindowType)host.egl_window, NULL);
if (host.egl_surface == EGL_NO_SURFACE) {
fprintf(stderr, "tabbed-wlroots: EGL window surface failed; software fallback\n");
host.egl_ok = false;
return true;
}
if (!eglMakeCurrent(host.egl_display, host.egl_surface, host.egl_surface,
host.egl_context)) {
fprintf(stderr, "tabbed-wlroots: eglMakeCurrent failed; software fallback\n");
host.egl_ok = false;
return true;
}
eglSwapInterval(host.egl_display, 0);
pfn_eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)egl_proc("eglCreateImageKHR");
pfn_eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)egl_proc("eglDestroyImageKHR");
pfn_glEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)
egl_proc("glEGLImageTargetTexture2DOES");
if (!gl_setup()) {
fprintf(stderr, "tabbed-wlroots: GL setup failed; software fallback\n");
host.egl_ok = false;
return true;
}
host.egl_ok = true;
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: EGL (GPU) rendering enabled\n");
return true;
}
/* ===================== Scene buffer -> host blit ===================== */
static bool import_scene_buffer(struct wlr_buffer *buf, GLuint tex) {
struct wlr_dmabuf_attributes attrs;
if (wlr_buffer_get_dmabuf(buf, &attrs)) {
if (attrs.n_planes < 1) return false;
EGLint att[48];
int n = 0;
att[n++] = EGL_WIDTH; att[n++] = attrs.width;
att[n++] = EGL_HEIGHT; att[n++] = attrs.height;
att[n++] = EGL_LINUX_DRM_FOURCC_EXT; att[n++] = (EGLint)attrs.format;
for (int p = 0; p < attrs.n_planes; p++) {
att[n++] = EGL_DMA_BUF_PLANE0_FD_EXT + p * 3; att[n++] = attrs.fd[p];
att[n++] = EGL_DMA_BUF_PLANE0_OFFSET_EXT + p * 3; att[n++] = (EGLint)attrs.offset[p];
att[n++] = EGL_DMA_BUF_PLANE0_PITCH_EXT + p * 3; att[n++] = (EGLint)attrs.stride[p];
}
if (attrs.modifier != DRM_FORMAT_MOD_INVALID) {
att[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT;
att[n++] = (EGLint)(attrs.modifier & 0xffffffff);
att[n++] = EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT;
att[n++] = (EGLint)(attrs.modifier >> 32);
}
att[n++] = EGL_NONE;
EGLImageKHR img = pfn_eglCreateImageKHR(host.egl_display, EGL_NO_CONTEXT,
EGL_LINUX_DMA_BUF_EXT,
(EGLClientBuffer)NULL, att);
if (img == EGL_NO_IMAGE_KHR) {
tabbed_log(TABBED_LOG_DEBUG, "[import] eglCreateImageKHR failed "
"(format 0x%x modifier 0x%llx)\n", attrs.format,
(unsigned long long)attrs.modifier);
return false;
}
glBindTexture(GL_TEXTURE_2D, tex);
pfn_glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, img);
pfn_eglDestroyImageKHR(host.egl_display, img);
return true;
}
/* Fallback for non-dmabuf buffers (on a headless backend the allocator
yields shm buffers). Upload via CPU read so shm clients and the shm
swapchain both render into the host window. */
void *data;
uint32_t fmt;
size_t stride;
if (!wlr_buffer_begin_data_ptr_access(buf, WLR_BUFFER_DATA_PTR_ACCESS_READ,
&data, &fmt, &stride)) {
tabbed_log(TABBED_LOG_DEBUG, "[import] buffer is neither dmabuf nor "
"CPU-readable\n");
return false;
}
glBindTexture(GL_TEXTURE_2D, tex);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
switch (fmt) {
case DRM_FORMAT_XRGB8888:
case DRM_FORMAT_ARGB8888:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, buf->width, buf->height, 0,
GL_BGRA_EXT, GL_UNSIGNED_BYTE, data);
break;
default:
tabbed_log(TABBED_LOG_DEBUG, "[import] unsupported CPU fallback format "
"0x%x\n", fmt);
wlr_buffer_end_data_ptr_access(buf);
return false;
}
wlr_buffer_end_data_ptr_access(buf);
return true;
}
/* Draw one popup surface buffer into the presented window at its on-screen
position. This bypasses wlroots' scene rendering for popups, whose
premultiplied-alpha blend paints nothing in our nested EGL pipeline.
Each popup surface is composited exactly once per frame. A sub-menu is a
popup nested under its parent menu's scene tree, so it is reachable both
from its own tree and from the parent menu's tree; the surfaces-drawn array
prevents it being drawn twice. Position uses wlr_scene_node_coords (the
true absolute offset, including every ancestor), because the iterator's
sx/sy only sums offsets from its own tree root and would place a nested
popup too high/left by the missing ancestor offsets. */
struct popup_draw_ctx {
int win_w, win_h;
struct wlr_surface **drawn;
int ndrawn;
};
static void popup_draw_cb(struct wlr_scene_buffer *buffer,
int sx, int sy, void *user_data) {
struct popup_draw_ctx *ctx = user_data;
if (!buffer->buffer) return;
struct wlr_scene_surface *ss = wlr_scene_surface_try_from_buffer(buffer);
if (!ss) return;
for (int i = 0; i < ctx->ndrawn; i++) {
if (ctx->drawn[i] == ss->surface) return;
}
ctx->drawn[ctx->ndrawn++] = ss->surface;
(void)sx; (void)sy;
int lx = 0, ly = 0;
wlr_scene_node_coords(&buffer->node, &lx, &ly);
if (!import_scene_buffer(buffer->buffer, host.popup_tex)) return;
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
draw_texture_rect(host.popup_tex, buffer->buffer->width,
buffer->buffer->height,
0, 0, buffer->buffer->width, buffer->buffer->height,
lx, ly, ctx->win_w, ctx->win_h);
glDisable(GL_BLEND);
}
static void composite_popups(void) {
struct wlr_surface *drawn[64];
struct popup_draw_ctx ctx = {
.win_w = host.width,
.win_h = host.height,
.drawn = drawn,
.ndrawn = 0,
};
struct tabbed_popup *tp;
wl_list_for_each(tp, &popups, link)
wlr_scene_node_for_each_buffer(&tp->scene->node,
popup_draw_cb, &ctx);
}
/* Pace presents to the host compositor: this fires after the host has shown
the buffer we queued, so we know we may queue the next one. Any frame drawn
while a present is still pending is coalesced into the back buffer and
presented here, keeping exactly one outstanding buffer (no burst, no
tearing on busy repainters like terminals). */
static const struct wl_callback_listener host_frame_listener;
/* Request a host frame callback and present the drawing in the back buffer.
The callback must be requested *before* the swap: frame requests are part
of the state committed by eglSwapBuffers, so requesting it after would wait
on a commit that never comes. */
static void present_to_host(void) {
host.frame_cb = wl_surface_frame(host.surface);
wl_callback_add_listener(host.frame_cb, &host_frame_listener, NULL);
eglSwapBuffers(host.egl_display, host.egl_surface);
host.present_pending = true;
}
static void host_frame_done(void *data, struct wl_callback *callback,
uint32_t callback_data)
{
(void)data; (void)callback; (void)callback_data;
if (host.frame_cb) {
wl_callback_destroy(host.frame_cb);
host.frame_cb = NULL;
}
host.present_pending = false;
if (host.back_dirty) {
host.back_dirty = false;
present_to_host();
}
}
static const struct wl_callback_listener host_frame_listener = {
.done = host_frame_done,
};
/* The scene is rendered by the wlroots GLES2 renderer, which owns its own
EGL display/context, while we sample that buffer from a *different* EGL
context (host.egl_display). Without an explicit barrier the GPU may still
be executing the renderer's draw commands when we import+read the dmabuf,
which shows as flicker/tearing on content that repaints continuously.
Make the renderer's context current on this thread and glFinish() so the
buffer is guaranteed fully rendered before we blit it. */
static void sync_renderer_context(void) {
struct wlr_egl *src = wlr_gles2_renderer_get_egl(server.renderer);
if (!src) return;
EGLDisplay d = wlr_egl_get_display(src);
EGLContext c = wlr_egl_get_context(src);
if (d == EGL_NO_DISPLAY || c == EGL_NO_CONTEXT) return;
if (d == host.egl_display && c == host.egl_context) return;
eglMakeCurrent(d, EGL_NO_SURFACE, EGL_NO_SURFACE, c);
glFinish();
eglMakeCurrent(host.egl_display, host.egl_surface, host.egl_surface,
host.egl_context);
}
static void blit_scene_buffer(struct wlr_buffer *buf) {
int w = host.width, h = host.height;
glViewport(0, 0, w, h);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
bool imported = buf && import_scene_buffer(buf, host.import_tex);
if (imported) {
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
draw_texture_rect(host.import_tex, buf->width, buf->height,
0, 0, buf->width, buf->height, 0, 0, w, h);
glDisable(GL_BLEND);
}
composite_popups();
if (dump_host_req) {
dump_host_req = false;
dump_host_back();
}
if (!imported) {
/* Never present a frame that is only the clear colour: keep the last
good frame on the host. A later frame with a good import presents
normally. */
return;
}
if (host.present_pending) {
/* Host hasn't shown the previous buffer yet: redraw into the back
buffer and coalesce the swap until the frame callback arrives. */
host.back_dirty = true;
} else {
present_to_host();
}
}
static void render_and_present(void) {
if (!host.egl_ok || !host.configured) return;
if (!server.scene_output || !server.swapchain) return;
struct wlr_scene_output_state_options options = {
.swapchain = server.swapchain,
};
struct wlr_output_state state;
wlr_output_state_init(&state);
bool rendered = wlr_scene_output_build_state(server.scene_output, &state,
&options);
if (!rendered || !state.buffer) {
tabbed_log(TABBED_LOG_DEBUG, "[render] build_state failed\n");
wlr_output_state_finish(&state);
goto frame_done;
}
/* [diag] What did wlroots composite? A swapchain-sized buffer means we
composited the scene; anything else implies direct scanout of a client
buffer (which our blit may or may not import). */
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
struct wlr_output *out = server.output;
tabbed_log(TABBED_LOG_DEBUG, "[diag] frame buffer %dx%d (output %dx%d)\n",
state.buffer->width, state.buffer->height,
out ? out->width : 0, out ? out->height : 0);
}
/* Barrier between the wlr renderer's context and our host blit. */
sync_renderer_context();
if (dump_scene_req) {
dump_scene_req = false;
dump_scene_buffer(state.buffer);
}
static int autodump = -1;
if (autodump < 0)
autodump = getenv("TABBED_AUTODUMP") ? 1 : 0;
if (autodump > 0 && client_dumped && --autodump == 0) {
dump_scene_req = true;
dump_host_req = true;
}
blit_scene_buffer(state.buffer);
if (!wlr_output_commit_state(server.output, &state))
tabbed_log(TABBED_LOG_DEBUG, "[render] output commit failed\n");
frame_done:
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
wlr_scene_output_send_frame_done(server.scene_output, &now);
wlr_output_state_finish(&state);
}
/* ===================== Launch client ===================== */
static void launch_client(int idx) {
pid_t pid = fork();
if (pid == 0) {
/* Own process group so we can kill the whole client tree on exit. */
setpgid(0, 0);
const char *xdg_runtime = getenv("XDG_RUNTIME_DIR");
if (!xdg_runtime) xdg_runtime = "/tmp";
setenv("WAYLAND_DISPLAY", tabbed_socket, 1);
setenv("XDG_RUNTIME_DIR", xdg_runtime, 1);
setenv("GDK_BACKEND", "wayland", 1);
setenv("QT_QPA_PLATFORM", "wayland", 1);
unsetenv("DISPLAY");
/* Unless debugging, don't let client chatter spill onto the launching
terminal; the client inherits our stdout/stderr. */
if (tabbed_log_level() < TABBED_LOG_DEBUG) {
int nullfd = open("/dev/null", O_WRONLY);
if (nullfd >= 0) {
dup2(nullfd, STDOUT_FILENO);
dup2(nullfd, STDERR_FILENO);
if (nullfd > STDERR_FILENO) close(nullfd);
}
}
execlp("/bin/sh", "sh", "-c", server.apps[idx]->cmd, NULL);
_exit(127);
}
if (pid > 0)
server.apps[idx]->pid = pid;
}
/* ===================== Crash handler ===================== */
static void crash_handler(int sig, siginfo_t *info, void *ctx) {
(void)sig; (void)info; (void)ctx;
void *bt[64];
int n = backtrace(bt, 64);
write(2, "\n=== BACKTRACE ===\n", 19);
backtrace_symbols_fd(bt, n, 2);
remove_socket_file();
_exit(139);
}
static void sigchld_handler(int sig) {
(void)sig;
int status;
pid_t pid;
if (tabbed_log_level() >= TABBED_LOG_DEBUG) {
while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
fprintf(stderr, "[child] pid %d exited status 0x%x\n", pid, status);
} else {
while (waitpid(-1, &status, WNOHANG) > 0)
;
}
}
/* On SIGTERM or Ctrl-C (SIGINT), take down the clients and exit cleanly
rather than leaving them orphaned when the compositor dies. The clients
live in their own process groups, so a terminal Ctrl-C only reaches us. */
static void close_signal_handler(int sig) {
(void)sig;
shutdown_cleanup();
_exit(0);
}
/* SIGUSR1: save the current scene buffer and host back buffer on the next
frame, for inspecting what the client has actually drawn so far. */
static void sigusr1_handler(int sig) {
(void)sig;
dump_scene_req = 1;
dump_host_req = 1;
needs_redraw = 1;
}
/* ===================== Main ===================== */
int main(int argc, char *argv[]) {
snprintf(tabbed_socket, sizeof(tabbed_socket), "tabbed-wayland-%d",
(int)getpid());
const char *runtime = getenv("XDG_RUNTIME_DIR");
if (!runtime) runtime = "/tmp";
snprintf(socket_path, sizeof(socket_path), "%s/%s", runtime, tabbed_socket);
/* Clear a stale leftover (e.g. from a crashed previous run with the same
name) before we try to bind; harmless if there is none. */
remove_socket_file();
/* Resolve TABBED_LOG up front so signal handlers can read the cached
level without touching non-async-signal-safe functions. */
tabbed_log_level_init();
struct sigaction crash_sa = {
.sa_sigaction = crash_handler,
.sa_flags = SA_SIGINFO | SA_NODEFER,
};
sigaction(SIGSEGV, &crash_sa, NULL);
sigaction(SIGABRT, &crash_sa, NULL);
signal(SIGCHLD, sigchld_handler);
signal(SIGTERM, close_signal_handler);
signal(SIGINT, close_signal_handler);
signal(SIGUSR1, sigusr1_handler);
/* wlroots logs follow TABBED_LOG: quiet keeps only errors, info drops
protocol noise, debug shows everything. */
switch (tabbed_log_level()) {
case TABBED_LOG_DEBUG:
wlr_log_init(WLR_DEBUG, NULL);
break;
case TABBED_LOG_INFO:
wlr_log_init(WLR_INFO, NULL);
break;
default:
wlr_log_init(WLR_ERROR, NULL);
break;
}
server.wl_display = wl_display_create();
if (!server.wl_display) {
fprintf(stderr, "tabbed-wlroots: failed to create Wayland display\n");
return 1;
}
server.loop = wl_display_get_event_loop(server.wl_display);
/* stdin event feed: lets tests/drivers inject raw keyboard events
into the real host key handlers without a host compositor. */
server.ctl_source = wl_event_loop_add_fd(server.loop, STDIN_FILENO,
WL_EVENT_READABLE,
ctl_cb, NULL);
server.backend = wlr_headless_backend_create(server.loop);
if (!server.backend) {
fprintf(stderr, "tabbed-wlroots: failed to create headless backend\n");
return 1;
}
server.renderer = wlr_renderer_autocreate(server.backend);
if (!server.renderer) {
fprintf(stderr, "tabbed-wlroots: no GPU renderer available\n");
return 1;
}
wlr_renderer_init_wl_display(server.renderer, server.wl_display);
server.allocator = wlr_allocator_autocreate(server.backend, server.renderer);
if (!server.allocator) {
fprintf(stderr, "tabbed-wlroots: failed to create allocator\n");
return 1;
}
/* wl_surface at v4: legacy clients (e.g. VLC's Wayland vout) place video
via non-zero wl_surface.attach offsets, which is a protocol violation
from v5 on. Advertise v4 so those offsets are accepted and ignored,
like before the v5 breaking change. */
wlr_compositor_create(server.wl_display, 4, server.renderer);
wlr_subcompositor_create(server.wl_display);
wlr_shm_create_with_renderer(server.wl_display, 1, server.renderer);
wlr_linux_dmabuf_v1_create_with_renderer(server.wl_display, 5, server.renderer);
wlr_data_device_manager_create(server.wl_display);
wlr_data_control_manager_v1_create(server.wl_display);
/* Never let wlroots present a client buffer to the headless output
directly (direct scanout). We must composite into our own swapchain so
the nested frame is exactly what the host window shows; scanout of a
client buffer hands us a buffer our host blit can't keep in sync and the
window goes black (seen with Firefox). The scene field is WLR_PRIVATE,
so the supported way is this env var, parsed at wlr_scene_create(). */
setenv("WLR_SCENE_DISABLE_DIRECT_SCANOUT", "1", 1);
server.scene = wlr_scene_create();
server.xdg_shell = wlr_xdg_shell_create(server.wl_display, 6);
server.seat = wlr_seat_create(server.wl_display, "tabbed");
wlr_seat_set_capabilities(server.seat,
WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_KEYBOARD);
bridge_attach_seat();
/* virtual keyboard fed from the host keyboard */
static const struct wlr_keyboard_impl keyboard_impl = {
.name = "tabbed-keyboard",
};
wlr_keyboard_init(&server.keyboard, &keyboard_impl, "tabbed-keyboard");
/* fallback keymap until the host sends one */
server.xkb_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (server.xkb_ctx) {
xkb_context_set_log_level(server.xkb_ctx, XKB_LOG_LEVEL_DEBUG);
xkb_context_set_log_fn(server.xkb_ctx, tabbed_xkb_log);
struct xkb_rule_names names = {
.rules = NULL, .model = "pc105",
.layout = "us", .variant = NULL, .options = NULL,
};
server.xkb_keymap = xkb_keymap_new_from_names(
server.xkb_ctx, &names, XKB_KEYMAP_COMPILE_NO_FLAGS);
if (server.xkb_keymap)
wlr_keyboard_set_keymap(&server.keyboard, server.xkb_keymap);
}
wlr_seat_set_keyboard(server.seat, &server.keyboard);
server.new_output.notify = handle_new_output;
wl_signal_add(&server.backend->events.new_output, &server.new_output);
server.new_toplevel.notify = handle_new_toplevel;
wl_signal_add(&server.xdg_shell->events.new_toplevel, &server.new_toplevel);
server.new_popup.notify = handle_new_popup;
wl_signal_add(&server.xdg_shell->events.new_popup, &server.new_popup);
/* headless output; new_output fires during backend start */
host.width = 1280;
host.height = 800;
wlr_headless_add_output(server.backend, host.width, host.height);
if (!wlr_backend_start(server.backend)) {
fprintf(stderr, "tabbed-wlroots: failed to start backend\n");
return 1;
}
if (wl_display_add_socket(server.wl_display, tabbed_socket) < 0) {
fprintf(stderr, "tabbed-wlroots: failed to add socket '%s': %s\n",
tabbed_socket, strerror(errno));
return 1;
}
/* Now connect to the host compositor and present inside it. */
if (!host_init()) {
fprintf(stderr, "tabbed-wlroots: could not start nested session\n");
remove_socket_file();
return 1;
}
tabbed_log(TABBED_LOG_INFO, "Wayland socket: %s\n", tabbed_socket);
int total_apps = argc - 1;
if (total_apps == 0) {
fprintf(stderr, "usage: %s <prog> [prog...]\n"
" Each <prog> is one shell command; quote it when it "
"takes args, e.g. \"firefox -no-remote\".\n"
" Arguments written bare (starting with '-') launch a "
"bogus extra tab.\n", argv[0]);
remove_socket_file();
return 1;
}
server.apps = calloc((size_t)total_apps, sizeof(*server.apps));
if (!server.apps) {
fprintf(stderr, "tabbed-wlroots: out of memory for %d apps\n",
total_apps);
remove_socket_file();
return 1;
}
server.apps_cap = total_apps;
for (int i = 0; i < total_apps; i++) {
struct tabbed_app *app = calloc(1, sizeof(*app));
if (!app) {
remove_socket_file();
return 1;
}
app->cmd = strdup(argv[i + 1]);
app->pid = -1;
server.apps[i] = app;
}
server.num_apps = total_apps;
for (int i = 0; i < server.num_apps; i++) {
if (server.apps[i]->cmd[0] == '-') {
if (i > 0) {
fprintf(stderr,
"tabbed-wlroots: warning: %s starts with '-'; it is "
"run as its own tab, not an option to %s\n"
" If intended, quote it: \"%s %s\"\n",
server.apps[i]->cmd, server.apps[i - 1]->cmd,
server.apps[i - 1]->cmd, server.apps[i]->cmd);
} else {
fprintf(stderr,
"tabbed-wlroots: warning: %s starts with '-'; if this "
"is meant for a first command, quote them together, "
"e.g. \"%s\"\n",
server.apps[i]->cmd, server.apps[i]->cmd);
}
}
launch_client(i);
}
tabbed_log(TABBED_LOG_INFO, "tabbed-wlroots: running. "
"Ctrl+PageUp/PageDown switches tabs.\n");
/* Main event loop: service both the server and the host displays */
int server_fd = wl_event_loop_get_fd(server.loop);
int host_fd = wl_display_get_fd(host.display);
for (;;) {
wl_display_dispatch_pending(host.display);
/* Never block in a host read: use the non-blocking prepare/read
pattern so the server loop (headless frame timer) is always
serviced while waiting on the host socket. */
if (wl_display_prepare_read(host.display) == 0) {
wl_display_flush(host.display);
struct pollfd fds[2] = {
{ .fd = server_fd, .events = POLLIN },
{ .fd = host_fd, .events = POLLIN },
};
int ret;
do {
ret = poll(fds, 2, -1);
} while (ret < 0 && errno == EINTR);
if (ret > 0 && (fds[1].revents & (POLLIN | POLLERR)))
wl_display_read_events(host.display);
else
wl_display_cancel_read(host.display);
if (ret > 0 && (fds[0].revents & (POLLIN | POLLERR)))
wl_event_loop_dispatch(server.loop, 0);
} else {
wl_event_loop_dispatch(server.loop, 0);
}
wl_display_flush_clients(server.wl_display);
wl_display_dispatch_pending(host.display);
/* The headless output fires a frame signal at ~60Hz; render then
(also when a redraw was requested directly). */
if (frame_pending || needs_redraw) {
frame_pending = false;
needs_redraw = false;
render_and_present();
}
wl_display_flush(host.display);
}
/* Unreachable in practice: every shutdown path exits via
shutdown_cleanup(). Kept for completeness. */
shutdown_cleanup();
return 0;
}