diff --git a/docs/evidencia/dunst-sway-notificacion-2026-09-07.png b/docs/evidencia/dunst-sway-notificacion-2026-09-07.png new file mode 100644 index 00000000..b3840ec4 Binary files /dev/null and b/docs/evidencia/dunst-sway-notificacion-2026-09-07.png differ diff --git a/docs/state/targets.toml b/docs/state/targets.toml index 4f590813..0ef51d57 100644 --- a/docs/state/targets.toml +++ b/docs/state/targets.toml @@ -677,6 +677,16 @@ paquetes = [ # ⚠ Resuelve la mitad: libnotify no trae daemon, manda `org.freedesktop.Notifications` por D-Bus, # así que la imagen necesita además a alguien escuchando ese nombre. "libnotify", + # `dunst` es LA OTRA MITAD, y sólo hace falta acá: KDE la atiende con plasma-workspace, GNOME con + # gnome-shell y COSMIC con cosmic-notifications; sway no tenía a NADIE escuchando + # `org.freedesktop.Notifications`, así que una página que pedía notificar mandaba el mensaje al bus + # y se lo comía el silencio. Con el `.service` de D-Bus que trae la receta, el daemon lo levanta el + # propio bus la primera vez que alguien pide ese nombre — sin systemd y sin acordarse de lanzarlo + # en la config de sway. + # ⚠ No es `mako` —que es lo que usa media casa wlroots— porque ese nombre YA ESTÁ OCUPADO en el + # corpus por el motor de plantillas de Python de mesa: dos artefactos homónimos en la misma imagen + # es la colisión que la granja ya nos cobró. El porqué largo está en la receta. + "dunst", # ── VISOR DE IMÁGENES, TAMBIÉN EN LAS CUATRO ───────────────────────────────────────────────── # `swayimg` y no `imv`, que era lo que proponía `docs/plan-apps-usuario-final.md`: imv dibuja con # OpenGL de función fija (`glBegin`/`glOrtho`) y esta distro NO tiene proveedor de GL de diff --git a/recipes/incoming-wlr/dunst.toml b/recipes/incoming-wlr/dunst.toml new file mode 100644 index 00000000..018bf043 --- /dev/null +++ b/recipes/incoming-wlr/dunst.toml @@ -0,0 +1,143 @@ +# dunst 1.12.2 — el daemon de notificaciones que le faltaba a la imagen de sway. +# +# ── POR QUÉ EXISTE ESTA RECETA, Y POR QUÉ ACÁ ────────────────────────────────────────────────── +# El SDD 26 §6.10 midió perfil por perfil quién ATIENDE `org.freedesktop.Notifications`, que es la +# otra mitad de tener `libnotify`: +# +# escritorio-kde plasma-workspace ✓ completa +# escritorio-gnome gnome-shell ✓ completa +# escritorio-cosmic cosmic-notifications ✓ completa +# escritorio-sway — ✗ MEDIA: nadie atiende +# +# Con `libnotify` en la imagen y nadie del otro lado, una página que pide notificar no falla: el +# navegador manda el mensaje al bus y no lo recoge nadie. Media función, cero errores — la forma que +# este proyecto ya se cansó de pagar. +# +# ── POR QUÉ dunst Y NO mako, QUE ES EL DE LA CASA wlroots ────────────────────────────────────── +# Por dos razones medidas, no por gusto: +# +# 1. **El nombre `mako` YA ESTÁ OCUPADO en el corpus** por el motor de plantillas de Python que usa +# Mesa para su codegen. Un `recipes/incoming-wlr/mako.toml` no lo pisaría en el disco —la +# resolución es sibling-first— pero el cierre del perfil incluye las herramientas de build, así +# que la imagen de sway terminaría con DOS artefactos llamados `mako` peleando por las mismas +# rutas al hidratar. Es exactamente la colisión de homónimos que la granja ya nos cobró una vez. +# 2. mako habla D-Bus por `sd-bus`, que en musl significa una receta nueva (`basu`). dunst habla por +# **GDBus**, o sea `gio-2.0`, que ya está sellado. Cero recetas nuevas de dependencia. +# +# ── WAYLAND=1 X11=0, Y EL GUARDIÁN QUE LO EXIGE ─────────────────────────────────────────────── +# dunst compila los dos backends por defecto y el `config.mk` avisa de la trampa con todas las +# letras: «Disable dependency on wayland. This will force dunst to use xwayland». O sea que un build +# descuidado sella un binario que en esta distro —Wayland-only, sin GLX y sin Xwayland nativo— NO +# PINTA NADA y tampoco falla al arrancar. Por eso el `X11=0` no alcanza como intención: la fase +# `install` comprueba en el ELF que `libwayland-client` esté entre los NEEDED y que `libX11` NO +# esté. La intención se declara en el flag; el hecho se lee en el binario. +# +# ── EL FICHERO DE SERVICIO D-BUS ES LA MITAD QUE IMPORTA ────────────────────────────────────── +# Sin systemd (`SYSTEMD=0`, que es la postura de la distro) el daemon lo arranca **el bus**: el +# `.service` declara `Name=org.freedesktop.Notifications` con su `Exec`, y cualquier app que pida ese +# nombre lo levanta sola. Sin ese fichero habría que acordarse de lanzar dunst a mano en la config de +# sway, que es justo el tipo de «lo instalé y no anda» que este proyecto trata de no dejar. Se le +# quita la línea `SystemdService=`, que acá no puede significar nada, y la fase comprueba que el +# nombre bien escrito quedó adentro. +# +# ── `DUNSTIFY=0`: EL EMISOR QUE TRAE dunst SEGFAULTEA, Y NO ES CULPA DE dunst ───────────────── +# Entró primero con `DUNSTIFY=0` —parecía gratis, `libnotify` ya está en el corpus— y el binario +# resultante **muere con SIGSEGV hasta en `dunstify --help`**, o sea antes de tocar el bus. Medido en +# la jaula: rc=139 en las tres pruebas. +# +# La causa es el cuadro que este repo ya tiene escrito: **dos GLib en el mismo proceso**. Nuestro +# `libnotify.so.4` es COMPARTIDO y arrastra `libgobject/libglib/libgio/libgdk_pixbuf` `.so` +# (`readelf -d` lo dice), mientras que dunst y sus clientes de esta cola enlazan la GLib **estática** +# del corpus. Un dunstify que mezcla las dos termina con dos copias del sistema de tipos de GObject y +# se cae al arrancar. El daemon NO tiene el problema: no toca libnotify. +# +# Y no se pierde nada, que es lo que hace que ésta sea la salida correcta y no una rendición: el +# emisor de la casa es **`notify-send`, que viene DENTRO del artefacto de `libnotify`** y ya está en +# las cuatro imágenes, enlazado de forma consistente contra la cadena compartida. Un binario que +# segfaultea shipeado en la imagen es peor que no shipearlo. +# +# ── EL BINARIO PESA 46 MB, Y LA PALANCA NO ESTÁ EN ESTA RECETA ─────────────────────────────── +# Anotado porque el reflejo se equivoca dos veces. `config.mk` hardcodea `-g`, así que uno prueba +# `EXTRA_CFLAGS=-g0` esperando bajar decenas de megas: medido, 46.296.624 → 45.933.472 bytes, o sea +# 0,8%. Y sin embargo la grasa SÍ es depuración — `strip --strip-debug` deja el binario en 7.627.464 +# bytes, los mismos con `-g0` y sin él. Son 38 MB de `.debug_*` que vienen de los `.a` de +# glib/gio/pango/cairo/gdk-pixbuf, no de compilar dunst. +# Por eso acá no se toca: strippear en el enlace sería inventar una política en UNA receta cuando el +# corpus entero tiene la misma forma (swaybg 22 MB, fuzzel 10 MB) y ya hay un documento para eso, el +# SDD 23. Lo que corresponde es medirlo y dejarlo escrito donde se ve. +# +# `VERSION` se pasa a mano: el Makefile la saca de `git describe` y cae a un literal si no hay `.git`. +# Con el commit pineado las dos ramas son deterministas, pero cuál de las dos corre depende de si el +# árbol de fuentes trae `.git` — un detalle del build system que no tiene por qué entrar al binario. +name = "dunst" +version = "1.12.2" +license = "BSD-3-Clause" + +[source] +# Por commit y no por `/archive/.tar.gz`, misma razón que `fuzzel` y `foot`: el tarball lo +# genera la forge al vuelo y su sha256 se muere solo cuando GitHub actualiza su git/gzip. +# ⚠ El tag `v1.12.2` es anotado; éste es el commit desreferenciado (`v1.12.2^{}`). +repo = "https://github.com/dunst-project/dunst.git" +commit = "fc78b9f11aa8b63f4d81c96466206a9b63a22f07" + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "dynamic" +zig_version = "0.13.0" + +[build.phases] +configure = ''' +# ── EL `-lexpat` TIENE QUE IR AL FINAL, Y EL Makefile NO DEJA ──────────────────────────────── +# `libfontconfig.a` referencia XML_* y el expat estático hay que darlo DESPUÉS: el linker resuelve +# de izquierda a derecha. En las recetas meson de esta cola eso se hace con `-Dc_link_args=-lexpat`, +# pero acá manda un Makefile que arma la línea como `${LDFLAGS} = DEFAULT + + LIBS`, +# o sea que cualquier `-lexpat` nuestro cae ANTES de fontconfig y no sirve de nada. Sobreescribir +# `LIBS` significaría reproducir a mano toda la salida de pkg-config. +# La salida es un wrapper de un solo token que agrega la librería al final de CADA invocación —el +# mismo truco que usa `recipes/ffmpeg.toml` para sus `--cc/--ar`—. En las compilaciones `-c` el +# `-lexpat` sobra y clang lo ignora con un aviso. +ZW="$PWD/.zwrap"; mkdir -p "$ZW" +printf '#!/bin/sh\nexec %s "$@" -lexpat\n' "$CC" > "$ZW/cc" +chmod +x "$ZW/cc" +''' +compile = ''' +make -j"$(nproc)" CC="$PWD/.zwrap/cc" WAYLAND=1 X11=0 SYSTEMD=0 DUNSTIFY=0 \ + PREFIX=/usr SYSCONFDIR=/etc/xdg VERSION="1.12.2" +''' +install = ''' +make DESTDIR=/out CC="$PWD/.zwrap/cc" WAYLAND=1 X11=0 SYSTEMD=0 DUNSTIFY=0 \ + PREFIX=/usr SYSCONFDIR=/etc/xdg VERSION="1.12.2" install + +# ── GUARDIÁN 1: ¿es el backend de wayland, o quedó el de X11? ──────────────────────────────── +# «Compilé con X11=0» es una intención; esto es el hecho. Un dunst con backend X11 en una distro +# sin Xwayland arranca, toma el bus y no dibuja nunca. +readelf -d /out/usr/bin/dunst | grep -q 'libwayland-client' || { + echo "guardián: dunst NO enlaza libwayland-client — el backend wayland no entró" >&2; exit 1; } +if readelf -d /out/usr/bin/dunst | grep -q 'libX11'; then + echo "guardián: dunst enlaza libX11 — X11=0 no agarró" >&2; exit 1 +fi + +# ── GUARDIÁN 2: el nombre del bus, escrito en el fichero que lo activa ─────────────────────── +SVC=/out/usr/share/dbus-1/services/org.knopwob.dunst.service +[ -f "$SVC" ] || { echo "guardián: no se instaló el .service de D-Bus — nadie lo activaría" >&2; exit 1; } +grep -q '^Name=org.freedesktop.Notifications$' "$SVC" || { + echo "guardián: el .service no reclama org.freedesktop.Notifications" >&2; exit 1; } +# Sin systemd esa línea no puede significar nada; se va para que el bus no la mire siquiera. +sed -i '/^SystemdService=/d' "$SVC" + +test -x /out/usr/bin/dunstctl || { echo "guardián: falta dunstctl" >&2; exit 1; } +# Y que NO se haya colado el emisor roto: si alguien vuelve a poner DUNSTIFY=1 sin leer el porqué +# de arriba, esto lo frena acá y no en la pantalla de un usuario. +if [ -e /out/usr/bin/dunstify ]; then + echo "guardián: se instaló dunstify — segfaultea por las dos GLib; el emisor es notify-send" >&2 + exit 1 +fi +echo "guardián: dunst wayland-only, y el bus lo puede activar por org.freedesktop.Notifications" +''' + +[deps] +build = ["pkgconf", "perl", "wayland", "wayland-protocols", "glib", "pango", "cairo", + "gdk-pixbuf", "libnotify", "pixman", "fontconfig", "freetype", "harfbuzz", "fribidi", + "libpng", "expat", "libffi", "pcre2", "zlib"] +run = ["wayland", "glib", "pango", "cairo", "gdk-pixbuf"] diff --git a/scripts/wlr/dunst-headless.sh b/scripts/wlr/dunst-headless.sh new file mode 100755 index 00000000..250c4fae --- /dev/null +++ b/scripts/wlr/dunst-headless.sh @@ -0,0 +1,120 @@ +#!/bin/sh +# dunst-headless.sh — ¿la imagen de sway ATIENDE una notificación, y se VE? +# +# scripts/wlr/dunst-headless.sh # sale 0 si la notificación se dibuja +# scripts/wlr/dunst-headless.sh --negative-control # borra el .service y exige que NO se vea +# +# ── QUÉ PRUEBA, QUE NO ES LO MISMO QUE «ESTÁ INSTALADO» ─────────────────────────────────────── +# Tener `libnotify` en la imagen es media función: el que la completa es un daemon escuchando +# `org.freedesktop.Notifications`. KDE lo tiene por plasma-workspace, GNOME por gnome-shell, COSMIC +# por cosmic-notifications; sway no tenía a nadie, y por eso entró `dunst`. Que la receta selle no +# prueba nada de eso: hay que ver el bus ACTIVAR el daemon y el daemon DIBUJAR. +# +# El camino que se ejercita es el de verdad, no un atajo: +# +# notify-send → bus de sesión → activación por el .service → dunst arranca → pinta en sway +# +# **Nadie lanza dunst a mano acá**, a propósito: si el `.service` no estuviera, o el `Exec` apuntara +# a otro lado, o dunst no supiera hablar wayland, este guion no vería nada. Es el mismo camino que +# recorre una página web pidiendo notificar en `atuq`. +# +# ── POR QUÉ notify-send Y NO dunstify ───────────────────────────────────────────────────────── +# Porque `dunstify` no se shipea: mezcla la GLib ESTÁTICA del corpus con la COMPARTIDA que arrastra +# `libnotify.so.4` y se cae con SIGSEGV hasta en `--help` (dos copias de GObject en un proceso — el +# cuadro ya conocido de este repo). El emisor de la casa es `notify-send`, que viene dentro del +# artefacto de `libnotify` y enlaza toda la cadena compartida. El porqué largo está en la receta. +# +# ── LA EVIDENCIA ES UN DIFF DE PÍXELES, NO UN LOG ───────────────────────────────────────────── +# Se captura ANTES y DESPUÉS de mandar la notificación y se cuenta cuántos píxeles cambiaron en el +# cuadrante donde dunst dibuja. Un log lleno de líneas verdes es compatible con una pantalla vacía — +# ya pasó en este mismo andamio— y el color solo tampoco alcanza: swaynag pinta su barra de error en +# el MISMO rojo que dunst usa para `critical`, así que «hay rojo» no distingue nada. Lo que no se +# puede falsear es que aparezca algo donde antes no había nada. +set -eu +ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT" +RFS="${RFS:-/mnt/cosecha/escritorios/sway-rootfs}" +ALPINE="$ROOT/.dev-fs/alpine" +CONTROL=0 +[ "${1:-}" = "--negative-control" ] && CONTROL=1 + +[ -d "$RFS" ] || { echo "✗ no está el rootfs en $RFS — corré antes:" >&2 + echo " scripts/hydrate-profile.py escritorio-sway --into $RFS" >&2; exit 1; } +command -v bwrap >/dev/null || { echo "✗ falta bwrap" >&2; exit 1; } + +OUT="$(mktemp -d)" +trap 'rm -rf "$OUT"' EXIT + +cat > "$OUT/dentro.sh" <<'ADENTRO' +#!/bin/sh +export XDG_RUNTIME_DIR=/run/user/0 HOME=/root LANG=C XDG_DATA_DIRS=/usr/share +export WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 WLR_RENDERER=pixman +mkdir -p "$XDG_RUNTIME_DIR" /root; chmod 700 "$XDG_RUNTIME_DIR" +# El control negativo saca el fichero que hace posible la activación. La jaula corre sobre un +# overlay temporal, así que borrar acá no toca el rootfs de verdad. +[ "${CONTROL:-0}" = "1" ] && rm -f /usr/share/dbus-1/services/org.knopwob.dunst.service +( + i=0 + while [ ! -S /run/user/0/wayland-1 ] && [ $i -lt 40 ]; do sleep 1; i=$((i+1)); done + [ -S /run/user/0/wayland-1 ] || { echo "@@ sin socket wayland"; exit 1; } + export WAYLAND_DISPLAY=wayland-1 + /usr/bin/swaybg -c '#1a4b8c' & sleep 3 + # El bus se levanta CON el entorno wayland puesto: lo que él active lo hereda, igual que en una + # sesión de verdad. Al revés, dunst arrancaría sin display y saldría con status 1. + eval "$(/usr/bin/dbus-daemon --session --fork --print-address=1 | { read a; echo "DBUS_SESSION_BUS_ADDRESS=$a"; })" + export DBUS_SESSION_BUS_ADDRESS + /usr/bin/grim /salida/antes.png + echo "@@ dueño ANTES: $(timeout 10 /usr/bin/dbus-send --session --dest=org.freedesktop.DBus \ + --print-reply=literal /org/freedesktop/DBus org.freedesktop.DBus.NameHasOwner \ + string:org.freedesktop.Notifications 2>&1 | tr -d ' \n')" + timeout 25 /usr/bin/notify-send -u critical "hammer" "dunst lo activó el bus" 2>&1 + echo "@@ notify-send rc=$?" + sleep 5 + echo "@@ servidor: $(timeout 10 /usr/bin/dbus-send --session --dest=org.freedesktop.Notifications \ + --print-reply=literal /org/freedesktop/Notifications \ + org.freedesktop.Notifications.GetServerInformation 2>&1 | tr -s ' ')" + /usr/bin/grim /salida/despues.png && echo "@@ capturas listas" + sleep 1 + /usr/bin/swaymsg exit 2>/dev/null +) & +exec /usr/bin/sway -d 2>&1 +ADENTRO + +# `--unshare-pid` para que un `ps` de adentro no vea la máquina entera, y el lab de alpine DEBAJO +# porque el corpus no produce el intérprete musl (`ld-musl-x86_64.so.1`): sin él ni sway arranca. +timeout 180 bwrap --overlay-src "$ALPINE" --overlay-src "$RFS" --tmp-overlay / \ + --bind "$OUT" /salida --dev /dev --proc /proc --tmpfs /run --tmpfs /tmp --unshare-pid \ + --uid 0 --gid 0 --setenv PATH /usr/bin:/bin:/usr/sbin:/sbin --setenv CONTROL "$CONTROL" \ + /bin/sh /salida/dentro.sh > "$OUT/log.txt" 2>&1 || true + +grep -a '@@' "$OUT/log.txt" || true +[ -f "$OUT/antes.png" ] && [ -f "$OUT/despues.png" ] || { + echo "✗ no hubo capturas — mirá $OUT/log.txt" >&2; cp "$OUT/log.txt" work/dunst-headless.log 2>/dev/null || true; exit 1; } + +# El diff se hace acá afuera: dentro de la imagen no hay python ni con qué mirar un PNG. +CAMBIADOS="$(python3 - "$OUT/antes.png" "$OUT/despues.png" <<'PY' +import sys +from PIL import Image, ImageChops +a, b = (Image.open(p).convert("RGB") for p in sys.argv[1:3]) +# Sólo el cuadrante donde dunst dibuja por defecto (arriba a la derecha). Acotarlo saca del medio la +# barra de swaynag y el reloj de swaybar, que cambian solos y darían un falso positivo. +w, h = a.size +caja = (w // 2, h // 12, w, h // 2) +d = ImageChops.difference(a.crop(caja), b.crop(caja)).convert("L") +# Por histograma y no recorriendo píxeles: el bin 0 son los que NO cambiaron. +print(d.width * d.height - d.histogram()[0]) +PY +)" +echo " píxeles cambiados en el cuadrante de dunst: $CAMBIADOS" + +if [ "$CONTROL" = "1" ]; then + if [ "$CAMBIADOS" -lt 500 ]; then + echo "✓ control negativo: sin el .service no se dibuja nada (el guardián SÍ lo vería)"; exit 0 + fi + echo "✗ control negativo: se dibujó una notificación SIN el .service — este guion no prueba lo que dice" >&2 + exit 1 +fi +[ "$CAMBIADOS" -ge 500 ] || { + echo "✗ la notificación no se dibujó ($CAMBIADOS píxeles) — capturas en $OUT" >&2 + cp "$OUT"/*.png "$OUT/log.txt" work/ 2>/dev/null || true; exit 1; } +cp "$OUT/despues.png" work/dunst-headless.png 2>/dev/null || true +echo "✓ el bus activó dunst y la notificación se dibujó (captura en work/dunst-headless.png)"