xprop-sink

lightweight, streaming X11 property updater

git clone https://9o.is/git/xprop-sink.git

commit 04aa8891cd43b293af8a26e484707c3a7a04201a
Author: Jul <jul@9o.is>
Date:   Sat,  7 Feb 2026 03:48:16 -0500

init

Diffstat:
A.gitignore | 2++
AMakefile | 30++++++++++++++++++++++++++++++
AREADME.md | 10++++++++++
Acontrib/user.WindowStatus | 47+++++++++++++++++++++++++++++++++++++++++++++++
Axprop-sink.1 | 23+++++++++++++++++++++++
Axprop-sink.c | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 204 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +/xprop-sink +\ No newline at end of file diff --git a/Makefile b/Makefile @@ -0,0 +1,29 @@ +.POSIX: +.SUFFIXES: + +CC = gcc +CFLAGS = -std=c99 -pedantic -Wall -Wextra -O2 +LIBS = -lX11 +TARGET = xprop-sink +PREFIX = /usr/local +MANPREFIX = $(PREFIX)/share/man + +all: $(TARGET) + +$(TARGET): xprop-sink.c + $(CC) $(CFLAGS) xprop-sink.c -o $(TARGET) $(LIBS) + +install: $(TARGET) + mkdir -p $(DESTDIR)$(PREFIX)/bin + mkdir -p $(DESTDIR)$(MANPREFIX)/man1/ + install -m 0755 $(TARGET) $(DESTDIR)$(PREFIX)/bin/ + install -m 0644 xprop-sink.1 $(DESTDIR)$(MANPREFIX)/man1/ + +uninstall: + rm -f $(DESTDIR)$(PREFIX)/bin/$(TARGET) \ + $(DESTDIR)$(MANPREFIX)/man1/xprop-sink.1 + +clean: + rm -f $(TARGET) + +.PHONY: all install uninstall clean +\ No newline at end of file diff --git a/README.md b/README.md @@ -0,0 +1,10 @@ +# xprop-sink + +A lightweight, streaming X11 property updater. It's useful for updating a rapidly changing property. I currently use it to set a custom, per-window status in my window manager bar. If you happen to be using Qubes like I am, check out the qrexec script `contrib/user.WindowStatus` as an example. + +## Installation + +```sh +make +sudo make install +``` diff --git a/contrib/user.WindowStatus b/contrib/user.WindowStatus @@ -0,0 +1,47 @@ +#!/usr/bin/env sh +set -e +STATUS_PROP=_MY_WIN_STATUS +## +## Allows an AppVM to update an X11 property on its own window via `qrexec` +## without compromising the security of other windows. The map_window function checks +## if the window belongs to the appvm making the request. The window status prop is +## hardcoded in the STATUS_PROP variable. +## +## Usage: +## while true; do +## echo "Counter: $(date +%S)" +## sleep 0.1 +## done | qrexec-client-vm @default user.WindowStatus+$WINDOWID +## +## Policy (if using sys-gui): +## user.WindowStatus * @tag:guivm-sys-gui @default allow target=sys-gui +## + +map_window() { + local target_vm="$1" + local target_xid="$2" + + for win in $(xprop -root _NET_CLIENT_LIST | cut -d'#' -f2 | tr ',' ' '); do + vm=$(xprop -id "$win" _QUBES_VMNAME 2>/dev/null | cut -d'"' -f2) + vmid=$(xprop -id "$win" _QUBES_VMWINDOWID 2>/dev/null | awk -F' # ' '{print $2}') + + if [ "$vm" = "$target_vm" ] && [ "$vmid" = "$target_xid" ]; then + echo "$win" + return 0 + fi + done +} + +if [ -z "$1" ]; then + echo "Error: Missing window ID argument" >&2 + exit 1 +fi + +XID=$(map_window "$QREXEC_REMOTE_DOMAIN" "$(printf '0x%x' $1)") + +if [ -n "$XID" ]; then + xprop-sink "$XID" "$STATUS_PROP" +else + echo "Error: Window mapping failed" >&2 + exit 1 +fi diff --git a/xprop-sink.1 b/xprop-sink.1 @@ -0,0 +1,22 @@ +.TH XPROP-SINK 1 "FEBRUARY 2026" "Linux" "User Commands" +.SH NAME +xprop-sink \- Stream stdin updates to an X11 window property +.SH SYNOPSIS +.B xprop-sink +\fIWINDOW_ID\fR \fIPROPERTY_NAME\fR +.SH DESCRIPTION +.B xprop-sink +reads lines from standard input and sets the specified X11 property on the given window. It is optimized for high-frequency updates, such as status bar indicators or keyboard state tracking. +.PP +The program is line-buffered. If an input line exceeds the internal 1024-byte buffer, the remainder of the line is drained and ignored to prevent property fragmentation. +.SH ARGUMENTS +.TP +.I WINDOW_ID +The X11 window ID (decimal or hex) to target. +.TP +.I PROPERTY_NAME +The name of the X11 atom (property) to update (e.g., _MY_WIN_STATUS). +.SH EXIT STATUS +The program exits if standard input is closed or if the target window becomes invalid (e.g., the window is closed by the user). +.SH SEE ALSO +.BR xprop (1) +\ No newline at end of file diff --git a/xprop-sink.c b/xprop-sink.c @@ -0,0 +1,92 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <X11/Xlib.h> +#include <X11/Xatom.h> + +static int running = 1; + +void +usage(const char *prog) +{ + fprintf(stderr, "Usage: %s <window_id> <property_name>\n", prog); +} + +int +handle_x_errors(Display *dpy, XErrorEvent *ev) +{ + (void)dpy; + if (ev->error_code == BadWindow) + running = 0; + return 0; +} + +int +main(int argc, char **argv) +{ + Display *dpy; + Window win; + XWindowAttributes wa; + Atom atom; + char *endptr, buffer[1024]; + size_t len; + int c; + + if (argc != 3) { + usage(argv[0]); + return 1; + } + + win = (Window) strtol(argv[1], &endptr, 0); + if (*endptr != '\0' || win == 0) { + fprintf(stderr, "Error: Invalid Window ID\n"); + usage(argv[0]); + return 1; + } + + dpy = XOpenDisplay(NULL); + if (!dpy) { + fprintf(stderr, "Error: Cannot open X display\n"); + return 1; + } + + XSetErrorHandler(handle_x_errors); + + atom = XInternAtom(dpy, argv[2], False); + if (atom == None) { + fprintf(stderr, "Error: Cannot create X atom\n"); + return 1; + } + + if (!XGetWindowAttributes(dpy, win, &wa)) { + fprintf(stderr, "Error: Window not found\n"); + return 1; + } + + setvbuf(stdin, NULL, _IONBF, 0); + + while (running && fgets(buffer, sizeof(buffer), stdin)) { + len = strlen(buffer); + + // If line exceeds buffer size, the remainder drained and ignored + if (len == sizeof(buffer) - 1 && buffer[len-1] != '\n') { + while ((c = getchar()) != '\n' && c != EOF); + continue; + } + + len = strlen(buffer); + buffer[strcspn(buffer, "\r\n")] = 0; + + if (!XGetWindowAttributes(dpy, win, &wa)) + break; + + if (len > 0) { + XChangeProperty(dpy, win, atom, XA_STRING, 8, + PropModeReplace, (unsigned char *)buffer, (int)len); + XFlush(dpy); + } + } + + XCloseDisplay(dpy); + return 0; +}