# Runbook — COSMIC, el cuarto escritorio

Estado al 2026-08-03: **LA SESIÓN COSMIC ARRANCA ENTERA.** `cosmic-session` —el camino de producción,
no el andamio— levanta la cadena completa sobre el kernel de hammer y arje-zero como PID1:

```
cosmic-comp → cosmic-settings-daemon → cosmic-notifications → cosmic-panel
            → cosmic-osd → cosmic-bg
```

Los que todavía no existen (`cosmic-app-library`, `cosmic-launcher`, `cosmic-workspaces`,
`cosmic-greeter`) sólo dejan una línea de `error!` y **la sesión sigue viva** — que es exactamente lo
que la tabla de `.expect` de más abajo predijo, verificado en vez de supuesto.

![sesión COSMIC en QEMU](../evidencia/cosmic-session-qemu-2026-08-03.png)

Y el paso anterior: `cosmic-comp` arranca sobre el
kernel de hammer con arje-zero como PID1, toma el DRM, expone `wayland-1` y presenta frames; y
`cosmic-bg` se conecta, entrega su buffer y el fondo aparece **con el color exacto que se le
configuró** — `(0.13,0.29,0.53)` → `(33,74,135)`. O sea que la cadena cliente→compositor→KMS funciona
de punta a punta. Este documento se escribe mientras se hace, no después.

![cosmic-bg pintando en QEMU](../evidencia/cosmic-bg-qemu-2026-08-03.png)

Que el color sea **el que se pidió** y no uno cualquiera es lo que hace de esto una medición y no una
impresión: descarta que se esté viendo un buffer sin inicializar o el borrado del compositor.

El paso anterior —el compositor solo, gris `(39,41,42)` y el cursor, 134 colores— quedó en
[`cosmic-comp-qemu-2026-08-03.png`](../evidencia/cosmic-comp-qemu-2026-08-03.png), porque la
diferencia entre las dos imágenes es exactamente la pregunta que el modo `bare` separa.

COSMIC es el escritorio de System76, en Rust sobre **smithay** (compositor) e **iced/libcosmic**
(clientes). Es el cuarto de hammer, tras [mirada](mirada-usb-nvidia.md),
[KDE Plasma 6](kde-qemu-desktop.md) y [GNOME](gnome-qemu-desktop.md).

## Por qué NO se parece a los dos anteriores

KDE y GNOME costaron lo que costaron por la misma razón: una torre de C con Qt o
glib/GObject/introspección debajo, donde cada capa se paga entera antes de ver un píxel. COSMIC no
tiene esa torre. La primera receta —`cosmic-session`— salió **a la primera y sin una sola dep en C**:
ELF estático, cero `NEEDED`.

Lo que sí hereda es lo que ya está pago:

| lo que COSMIC necesita | de dónde sale | costo |
|---|---|---|
| las deps en C de smithay (drm, gbm, seat, input, udev, xkb, pixman) | `mirada-compositor`, que ya es un compositor smithay de este lab | **cero** — misma lista |
| `libdisplay-info` + `hwdata` | copiadas byte a byte de la cola GNOME | **cero** — mismo hash, artefacto ya sellado |
| `bash`, `dbus-run-session`, `hicolor-icon-theme`, mesa-llvmpipe | corpus | cero |
| el andamiaje de arranque en QEMU (`run-qemu-desktop.sh`, base rootfs, console-getty, screendump) | campañas KDE/GNOME | cero |

La deuda propia que sí trae, y que ninguna de las otras campañas tocó, es la del **unwinder de la std
de rustc** cuando el enlace no es estático — la misma que ya está documentada para las recetas Cargo
del corpus.

## El pin es de la SUITE, no de cada paquete

COSMIC versiona **todos** sus repos en lockstep con el tag `epoch-N`. Comprobado repo por repo: los
doce componentes están en `epoch-1.5.0`.

**No mezclar epochs entre piezas.** Comparten protocolos propios (`cosmic-protocols`) y el formato de
configuración (`cosmic-config`); un desajuste ahí no da error de compilación, da un escritorio que
arranca y no se entiende consigo mismo — que es mucho más caro de diagnosticar.

## La cola

`recipes/incoming-cosmic/`, aislada (otro agente comparte el repo; nunca `git add -A`).

Las deps se resuelven **hermano → padre**: desde `incoming-cosmic/` se ven las recetas de
`recipes/`, pero **no** las de `incoming-gnome/` ni `incoming-kde/`. Por eso `libdisplay-info` y
`hwdata` entran copiadas.

**Copiar es gratis sólo si TODA la clausura transitiva resuelve a lo mismo.** El ArtifactHash no
depende de la ruta de la receta, así que un fichero idéntico *cuyas deps resuelven igual* da el mismo
hash ⇒ cache hit sobre el artefacto que ya existe. Se cumplió con `libdisplay-info`, `hwdata` y
`nasm`, y se verificó con `hammer hash` **antes** de copiar (`b3:260f0519`, `b3:bdc36cf9`,
`b3:33383070`), no después. Dónde deja de cumplirse —y por qué importa— está en la sección de
`cosmic-settings-daemon`, más abajo.

## Qué levanta la sesión, y por lo tanto el orden de ataque

`cosmic-session/src/main.rs` es la lista autoritativa —el mismo método que cerró la capa JS de GNOME:
*leé el fichero donde el programa declara lo que arranca, no lo descubras de a uno*:

```
cosmic-comp  →  cosmic-notifications · cosmic-panel · cosmic-app-library
             →  cosmic-launcher · cosmic-workspaces · cosmic-osd · cosmic-bg · cosmic-idle
```

Más `cosmic-settings-daemon` y `xdg-desktop-portal-cosmic` fuera de esa lista.

| pieza | estado | hash |
|---|---|---|
| `cosmic-session` | ✅ sellado | `b3:81f02351` |
| `cosmic-icons` | ✅ sellado (671 SVGs) | `b3:94d06f77` |
| `dav1d` 1.5.4 | ✅ sellado (dep arrastrada, ver gotcha 8) | `b3:aab7a874` |
| **`cosmic-comp`** | ✅ **SELLADO — el gate** (filtra `ScreenReader`, ver atajos) | `b3:e871eee4` |
| `cosmic-bg` (dinámico, PINTA) | ✅ sellado | `b3:b2cfc410` |
| `cosmic-idle` (dinámico) | ✅ sellado | `b3:e1806acc` |
| `pulseaudio` · `pipewire` (cadena del settings-daemon) | ✅ sellados | `b3:e1ff4736` · `b3:338d1d8c` |
| `cosmic-panel` | ✅ sellado | `b3:ac5e48c5` |
| `cosmic-osd` | ✅ sellado | `b3:f15cb9c1` |
| `cosmic-settings-daemon` | ✅ sellado (filtra `ScreenReader`, ver atajos) | `b3:642919a5` |
| `cosmic-notifications` | ✅ sellado | `b3:3e2a5478` |
| `cosmic-launcher` | ✅ sellado | `b3:c17b4956` |
| `cosmic-app-library` | ✅ sellado | `b3:b2a163e7` |
| `cosmic-workspaces-epoch` | ✅ sellado (necesitó `mesa`, ver gotcha 11) | `b3:a031249e` |
| **`cosmic-applets`** (19 binarios en uno) | ✅ **sellado** (necesitó `dbus-shared`, ver gotcha 10) | `b3:3e53a325` |
| `pop-launcher` 1.2.7 (otro repo, ver abajo) | ✅ sellado a la primera | `b3:64a5cbaa` |
| `gmp` 6.3.0 · `mpfr` 4.2.2 (cadena de la calculadora) | ✅ sellados a la primera | `b3:d99a0a5d` · `b3:13982d20` |
| `libqalculate` 5.12.0 (`qalc`) | ✅ sellado — la calculadora del lanzador ANDA (ver abajo) | `b3:edb8d14d` |
| **`cosmic-term`** 1.5.0 — la 1ª APLICACIÓN | ✅ **sellada a la primera** (arrastra `cosmic-files` y `wgpu`) | `b3:d0ea1c1e` |
| `cosmic-files` 1.5.0 — la 2ª aplicación | ✅ sellada, **sin `gvfs` ni `wgpu`** (ver abajo) | `b3:48d2b072` |
| `cosmic-settings` 1.5.0 — la 3ª aplicación | ✅ sellada (necesitó el parche de la doble barra) | `b3:716dac53` |

**La cola está COMPLETA: 33 recetas, `escritorio-cosmic` 71/71.**

**El mínimo viable de una sesión son cuatro**: `cosmic-comp` + `cosmic-settings-daemon` +
`cosmic-notifications` + `cosmic-panel` (los tres `.expect` de la tabla de arriba).

**El orden no es el de la lista de arranque.** `cosmic-bg` va antes que el panel a propósito: habla
Wayland con `smithay-client-toolkit` y **no toca libcosmic ni iced**, mientras que panel, launcher,
osd y workspaces son todos clientes de iced. Con el fondo sellado, «pinta el fondo pero no el panel»
separa la capa de UI del transporte; sin él, las dos preguntas llegan juntas.

### `cosmic-settings-daemon`: por qué queda al final y NO se resuelve copiando

Es el único con cadena propia. Su workspace incluye `audio-server` como dep de path **no opcional**,
que arrastra `cosmic-pipewire` → `pipewire-sys` → `libpipewire-0.3` por pkg-config. Y `pipewire` hoy
vive sólo en las colas GNOME y KDE.

**Acá el truco de copiar deja de ser gratis, y eso corrige lo que dice más arriba.** La regla completa
es: *copiar una receta entre colas es gratis sólo si **toda su clausura transitiva** resuelve a lo
mismo.* Se cumplió con `libdisplay-info`, `hwdata` y `nasm` —sus deps están todas en el corpus—, y
**no** se cumple con pipewire: de sus catorce deps, cinco viven sólo en colas ajenas
(`dbus-shared`, `alsa-lib`, `libsndfile`, `pulseaudio`, `zlib-shared`) y —lo decisivo— **`glib`
resolvería distinto**. Desde `incoming-gnome`, `glib` es la sombra de esa cola (`b3:f6ccdf98`); desde
`incoming-cosmic` sería la del corpus (`b3:93d2cad0`). Son recetas distintas, no la misma en dos
lugares.

O sea que copiar produciría un **segundo pipewire** construido contra otra glib, arrastrando su cadena
entera. Y eso no es sólo caro: es exactamente el cuadro que la campaña GNOME ya midió y evitó —dos
glib distintas con 370 rutas solapadas y dos registros de GType en un proceso—.

Antes de tocar nada se midió con `yupana radio` (la puerta única, nunca `grep`): `pipewire` tiene
radio 1 en GNOME y 2 en KDE, pero `glib` del corpus tiene **21 dependientes transitivos y afecta la
imagen escritorio-gnome**.

**Y después se midió el costo real, que resultó mucho menor que la estimación.** Copiar pipewire a
`incoming-cosmic` necesita **cuatro** recetas hermanas —`alsa-lib`, `dbus-shared`, `libsndfile`,
`pulseaudio`—, no las seis contadas a ojo, y **tres de ellas dan hash idéntico y ya están selladas**.
Sólo `pulseaudio` y `pipewire` hay que construir.

Eso refina la advertencia de arriba en el punto que importa: **el peligro de las dos glib es
mezclarlas en UNA IMAGEN**, y la imagen COSMIC tiene una sola. Acá el costo es de builds, no de
corrección. Lo que sigue vigente es no promover a ciegas entre colas.

Gotcha del copiado, que cuesta un minuto de confusión: `[source] patches` se resuelve **relativo a la
receta**, así que copiar el `.toml` sin su `.patch` da «no pude leer patch» y el hash ni siquiera se
calcula.

## 🚪 EL GATE PASADO: cosmic-comp compila (2026-08-03)

`b3:b8abc52f`, 51 min 39 s de cargo con `CARGO_BUILD_JOBS=1` y `lto = "fat"`. **A la primera**, salvo
el `--bin` del gotcha 1. Sin un solo parche a la fuente.

Lo que dice el enlace es la parte que vale, porque es la medición y no la impresión:

```
NEEDED: libdisplay-info.so.2 · libgbm.so.1 · libseat.so.1 · libudev.so.1
        libinput.so.10 · libpixman-1.so.0 · libxkbcommon.so.0 · libc.so
```

Ocho, y las siete primeras son **exactamente** los backends de smithay que la receta declara. La
octava es musl. **No hay `libgcc_s`** — o sea que el compositor no arrastra la deuda del unwinder que
sí tienen varias recetas Cargo del corpus, porque acá el enlace es dinámico contra musl y no hay
`+crt-static` que forzar. Y **no aparece nada que no se haya declarado**: la clausura de build y la de
runtime coinciden, que es la propiedad que uno quiere y casi nunca se cumple gratis.

Que un compositor Wayland completo —DRM, GBM, EGL, libinput, seat, Vulkan, XWayland, un renderer
multi-GPU y una capa de UI en iced— cierre con ocho librerías compartidas y ningún parche es el
resultado más limpio de las cuatro campañas de escritorio.

## 🖥 EL PRIMER ARRANQUE: qué falló y qué no (2026-08-03)

Tres imágenes y tres causas distintas, todas medidas. Vale dejarlas porque **ninguna estaba en el
camino que uno anticipa**, y las tres se manifestaron a varias capas de distancia de su causa.

**1. `cosmic-session` no es opcional en el sentido que yo asumí — y `cosmic-settings-daemon` tampoco.**
El compositor arrancó perfecto y le devolvió a la sesión su `WAYLAND_DISPLAY=wayland-1`; el gestor
entonces intentó lanzar `cosmic-settings-daemon`, no lo encontró, y **panickeó**:

```
INFO  cosmic_session: got environmental variables from cosmic-comp: [("WAYLAND_DISPLAY", "wayland-1")]
ERROR panic: 'failed to start settings daemon: NotFound' src/main.rs:255
```

Es un `.expect()`, sin feature que lo apague. **Y NO es el único — son TRES**, y esto corrige lo que
esta misma sección afirmaba hace unas horas:

| línea | componente | qué pasa si falta |
|---|---|---|
| `main.rs:255` | `cosmic-settings-daemon` | panic |
| `main.rs:306` | `cosmic-notifications` | panic |
| `main.rs:325` | `cosmic-panel` | panic |

El resto (app-library, launcher, workspaces, osd, bg, idle) va por `start_component()`, que ante un
fallo sólo hace `error!("failed to start …")` y sigue (`main.rs:558`). O sea que **el mínimo viable de
una sesión COSMIC son cuatro binarios**: compositor + esos tres.

**Cómo se equivocó el diagnóstico, que es la parte reusable**: leí `start_component()`, vi que sólo
logueaba, y generalicé a «todos menos el settings-daemon». Pero notifications y panel **no pasan por
esa función** — tienen su propio `.expect()` unas líneas antes. Buscar la función que lanza no es lo
mismo que buscar los `\.expect(` del fichero; **grepear la construcción que mata cuesta lo mismo y no
generaliza de más**. **Esto corrige lo que decía la sección de
cosmic-settings-daemon**: no es que sin él se pierdan el brillo y el tema, es que **no hay sesión**.
Queda por eso el modo `bare` en `cosmic-start` —el camino que upstream ya soporta como
`install-bare-session`—: se lanza el compositor solo y los clientes a mano contra su WAYLAND_DISPLAY.
Separa «¿compone?» de «¿arranca la sesión?», que son dos preguntas y estaban viniendo juntas.

**2. La pantalla negra era `libz.so.1`.** El corpus trae `zlib` estática, que le alcanza a todo el
cierre de COSMIC. Pero `kms_swrast_dri.so` es un `.so` que mesa abre con `dlopen` y NEEDea
`libz.so.1`:

```
MESA-LOADER: failed to open kms_swrast: Error loading shared library libz.so.1
WARN  cosmic_comp::backend::kms: Failed to add device /dev/dri/card0: Failed to initialize GBM device
```

Sin driver de software no hay GBM, y **el compositor arranca igual y presenta una pantalla negra**. La
causa está a tres capas del síntoma. Se arregla agregando `zlib-shared` como raíz del hidratador.

**3. Los clientes NO pueden ser estáticos.** `cosmic-bg` moría con «The wayland library could not be
loaded» **teniendo `libwayland-client.so.0` en el rootfs**. `wayland-client` entra con la feature
`dlopen`: carga libwayland en runtime en vez de enlazarla, y **un binario musl estático no tiene
dlopen funcional**. No faltaba la librería: faltaba poder abrirla. Todos los clientes pasan a
`link = "dynamic"`.

**Lo que NO falló, y conviene decirlo:** el compositor no necesitó un solo parche, ni una perilla de
KMS, ni forzar modeset legacy. Las cuatro perillas de render por software heredadas de KDE y GNOME
(`GBM_ALWAYS_SOFTWARE`, `kms_swrast`, `LP_NUM_THREADS=1`) alcanzaron. GNOME costó cinco muros para
llegar a este punto; COSMIC costó tres, y ninguno era del compositor.

### Dos carreras con /run, la misma causa

`dbus-daemon --system` falló con «Failed to bind socket /run/dbus/system_bus_socket: No such file or
directory» **aunque el directorio se creaba al principio del script**, y dbus-run-session se quejó
por su lado de que `XDG_RUNTIME_DIR` no existía. Entre el arranque del getty y el uso pasan varios
segundos en los que arje-zero sigue montando, y un tmpfs sobre `/run` se lleva puesto lo que hubiera.
**Crear justo antes de usar** es la única forma de no depender de ese orden; comprobar al principio y
usar al final es exactamente cómo se cuela una carrera — la misma forma del bug de PolicyKit1 que se
cerró hoy en GNOME.

## Gotchas medidos (no previstos)

1. **`cargo rustc --` exige UN target.** La fase compile del lab pasa flags tras el `--`, y cualquier
   paquete con binario + lib + ejemplos corta con «extra arguments to `rustc` can only be passed to
   one target». Se arregla con `flags = ["--bin", "<nombre>"]`. Le pasa a cosmic-comp y a cosmic-bg
   (workspace con `config/`), y le va a pasar a casi todos: **ponelo desde el principio.**
2. **`start-cosmic` es bash de verdad, no sh.** Usa `mapfile`, `[[ ]]` y expansión indirecta
   `${!var}`; con el busybox del rootfs no arranca. `bash` ya está en el corpus, pero **tiene que
   entrar en la imagen** — y no lo pide ningún `[deps]`, así que no lo va a traer nadie por accidente.
   Lo mismo con `dbus-run-session` (de `dbus`), que es lo que el script exec-uta al final.
3. **`rust-toolchain.toml` con `channel = "1.93"` es inerte acá** — es una perilla de rustup, y el
   sandbox tiene el rustc de Alpine (1.96) pelado. Conviene saberlo antes de «arreglarlo».
4. **`cosmic.desktop` existe DOS veces** (en cosmic-session y en cosmic-comp) con contenido distinto
   y en la misma ruta. El de cosmic-comp es el de la «bare session» y apunta a `/usr/bin/cosmic-service`,
   que vive en la rama de systemd que acá no va. Se instala **sólo** el de cosmic-session; si entraran
   los dos, quién gana depende del orden de hidratación y el que pierde es el bueno.
5. **`libsystemd` no es systemd.** La feature `default = ["systemd"]` de cosmic-comp trae el crate
   `libsystemd`, que es una **reimplementación en Rust** del protocolo, no un binding a la C. Y
   `logind` habla `org.freedesktop.login1`, que acá lo sirve `arje-logind-compat`. No hay que apagar
   nada: el nombre de la feature es el del protocolo, no el del programa que lo contesta.
6. **Las features de smithay que asustan no cuestan deps de build**: `backend_vulkan` (crate `ash`),
   `backend_x11` (`x11rb` con `dl-libxcb`) y `xwayland` cargan todo por **dlopen**. Son deuda de
   RUNTIME —libxcb y vulkan-loader viven hoy sólo en la cola KDE—, no un muro de compilación.
7. **Todo cliente de la suite lleva las MISMAS CUATRO deps: `libxkbcommon`, `pkgconf`,
   `libudev-zero`, `libinput`** — incluso las piezas sin interfaz. Las tres primeras se descubrieron
   de a una, un build por dep, hasta que la línea de enlace las mostró juntas:

   ```
   -Wl,-Bstatic  -ludev  -linput  -lxkbcommon …
   ```

   **El error dice la que falta primero; el COMANDO dice todas.** Leerlo entero la primera vez habría
   ahorrado tres builds. Los tres llegan por la cadena de libcosmic —enumeración de monitores y de
   dispositivos de entrada—, no por nada que el cliente haga a la vista.

   Y el camino hasta ahí fue de razonamientos buenos y falsos, que es lo que vale dejar escrito:
   - `cosmic-bg` fue sin `[deps]` porque «habla el protocolo en Rust y no toca C». Reventó en el
     `build.rs` de `smithay-client-toolkit` pidiendo `xkbcommon.pc`: **sctk ENLAZA libxkbcommon**; el
     que la dlopea es `winit`, que es otra capa. Hablar Wayland en Rust dice cómo viaja el byte, no
     con qué se interpreta un teclado.
   - `cosmic-idle` fue sin `[deps]` porque *ese* argumento ya no aplicaba —no usa sctk— y un demonio
     de inactividad no interpreta teclas. Reventó en el ENLACE con `-lxkbcommon`, arrastrado por
     **`cosmic-settings-config`**: de ahí sale la tabla de atajos y la usa **todo** componente de la
     suite. La dep no viene de lo que el paquete hace, sino de la librería de configuración común.

   La lección de método vale más que la dep: **la dep no se deduce de la función del paquete.** Está
   escrita en el `build.rs` que panickea o en la línea de enlace, y las dos veces el error dijo
   exactamente quién y por qué. El `link = "static"` sobrevive porque el artefacto de libxkbcommon
   trae `.a` además de `.so`.
8. **Una feature apagada en el paquete puede estar prendida por otro.** `cosmic-bg` declara `image`
   con `default-features = false` y sin AVIF, y aun así compila `dav1d-sys`: **las features de cargo
   se UNIFICAN en el grafo**. Se resolvió trayendo `dav1d` (b3:aab7a874) en vez de parchear el
   Cargo.toml de upstream — si el grafo dice que sabe decodificar AVIF, que lo sepa de verdad.
   Mismo modo de falla que un `.pc Requires` arrastrando una dep que nadie declaró.

9. **`cosmic-wallpapers` no se puede empaquetar del tarball: está en git-lfs.** El default de
   cosmic-bg apunta a `/usr/share/backgrounds/cosmic/orion_nebula_nasa_heic0601a.jpg`, y el archivo
   de GitHub de ese repo pesa **20 KB** — son punteros LFS, no imágenes. Una receta escrita del modo
   normal produciría un paquete de ficheros de texto que además *parecería* correcto: se instalarían
   con el nombre bueno en la ruta buena. Mientras tanto `cosmic-start` escribe la config de USUARIO
   con una fuente `Color`, que cosmic-bg soporta de fábrica — y va en el lanzador, no en el artefacto,
   porque es política de la imagen de prueba y no algo que el paquete prometa.

10. **`build.rs` contento ≠ linker contento: `cosmic-applets` necesita la libdbus COMPARTIDA.**
    Los applets de red, bluetooth y status-area hablan D-Bus por `libdbus-sys` (FFI a la C, no zbus
    como el resto de la suite). Declarar `dbus` a secas alcanza para que su `build.rs` pase —le basta
    el `.pc`, que el paquete del daemon trae— y el fallo aparece **una hora después, en el enlace
    final del multiplexor**: `unable to find dynamic system library 'dbus-1'`. El corpus sólo
    empaqueta `libdbus-1.a`; el `.so` vive en `dbus-shared`. Son dos preguntas distintas y las
    contestan dos herramientas distintas: pkg-config dice si la dep *existe*, el linker si hay algo
    que *enlazar*. Era la única receta de la cola que pedía la estática.

11. **`cosmic-workspaces` necesita `mesa`, y la dep sale de lo que el programa MUESTRA.** La vista de
    espacios de trabajo dibuja miniaturas vivas de las ventanas; el compositor se las pasa como
    DMA-BUF por `zcosmic-toplevel-info` y el cliente importa el fd con GBM ⇒ `-lgbm`. Es el único
    cliente de la suite que toca el pipeline gráfico además del compositor, así que el juego fijo de
    cuatro (`libxkbcommon`, `pkgconf`, `libudev-zero`, `libinput`) no alcanza. Corolario del gotcha
    de siempre: la dep no se deduce de la familia del paquete, se lee de lo que hace.

12. **DOS GPUs en QEMU = el panel dibuja en la pantalla que no mirás.** `-device virtio-gpu-pci` no
    reemplaza a la VGA por defecto de QEMU: la SUMA. El invitado ve `card0` y `card1`, cosmic-comp
    crea dos outputs (`Virtual-1` y `Unknown-1`), `cosmic-bg` pinta el fondo en ambos y el panel se
    ancla en uno solo. El `screendump` captura el otro ⇒ **fondo perfecto, panel invisible, y ni un
    solo error en el log**. Con `-vga none` el escritorio aparece entero a la primera.
    **Y esto corrige un diagnóstico anterior de este mismo runbook**: cuando el panel selló y no
    dibujó, lo atribuí a que le faltaban los applets. Los applets hacían falta —sin ellos el panel
    está vacío— pero **no eran la causa de la pantalla lisa**; la causa era la segunda GPU, que ya
    estaba ahí. Un síntoma puede tener dos causas suficientes y arreglar la primera no prueba nada:
    la prueba es la pantalla, no el razonamiento.

13. **`mkdir: not found` en `cosmic-start`, y la sesión arrancaba igual.** El getty entrega un PATH
    sin `/usr/bin`, y en la imagen fundida `mkdir` sólo vive ahí (`/usr/bin/mkdir → coreutils`; la
    base metal no tendió el symlink `/bin/mkdir → busybox` que sí tienen chmod, mount, sed y tee).
    Los cuatro `mkdir -p` de la preparación fallaban y **no se notaba**, porque los directorios
    venían horneados en la imagen. Es la forma más traicionera de bug: gratis hoy, fatal el día que
    alguien cambie cómo se arma la imagen. Fix: exportar `PATH` como primera línea del script.

## Cómo se construye

```sh
cd ~/hammer
./target/release/hammer --store store hash recipes/incoming-cosmic/<pieza>.toml   # dry-run, ~2ms
./target/release/hammer build recipes/incoming-cosmic/<pieza>.toml --store store
```

## 🏔 EL ESCRITORIO ENTERO, DIBUJADO (2026-08-04)

`docs/evidencia/cosmic-panel-applets-qemu-2026-08-04.png` — 1280×800, **895 colores distintos**:

- **panel superior** (banda `(27,27,27)`, 32 px): botones *Workspaces* y *Applications* a la
  izquierda, reloj al centro, y a la derecha los applets de accesibilidad, audio, red, batería y
  encendido;
- **dock inferior** centrado y redondeado, con lanzador, workspaces, biblioteca de aplicaciones y
  las entradas `.desktop` del rootfs;
- fondo `(33,74,135)` = exactamente el `Color(Single((0.13,0.29,0.53)))` que escribe `cosmic-start`.

48 procesos `cosmic-*` estables a los 30 s, sobre el kernel de hammer con arje-zero como PID1.
Se reproduce así (headless y automatizable; **el `-vga none` no es opcional**, ver gotcha 12):

```sh
COSMIC_MODE=session bash scripts/cosmic/qemu-desktop-image.sh
qemu-system-x86_64 -machine q35,accel=kvm -cpu host -smp 8 -m 8192 \   # 8 vCPU: ver el deadline del panel
  -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
  -drive if=pflash,format=raw,file=/tmp/OVMF_VARS.fd \
  -drive file=work/hammer-cosmic-qemu.img,format=raw,if=virtio \
  -vga none -device virtio-gpu-pci,xres=1280,yres=800 \
  -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0 \
  -display none -serial file:/tmp/cosmic.serial -qmp unix:/tmp/qmp.sock,server,nowait
# y después, por QMP: {"execute":"screendump","arguments":{"filename":"/tmp/shot.ppm"}}
```

El veredicto es **contar colores distintos del PPM**, no leer el log: con dos GPUs el log salía
impecable y la pantalla estaba lisa.

## 🖱 LA ENTRADA: se usa, no sólo se ve (2026-08-04)

**Hay que usar USB HID, no virtio-input.** El kernel de hammer no trae `VIRTIO_INPUT` (mirá el
`-e …` de `recipes/linux-generic.toml`), así que `-device virtio-keyboard` no produce ni un
`/dev/input/event*`. Sí trae `USB_XHCI_HCD` + `USB_HID` + `INPUT_EVDEV`, y además es lo que se va a
usar en metal, así que la prueba es más fiel:

```
-device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 -device usb-tablet,bus=xhci.0
```

`usb-tablet` y no `usb-mouse`: reporta coordenadas ABSOLUTAS, y QMP sólo sabe mandar eventos `abs`
(el eje va de 0 a 32767 sobre el ancho/alto de la pantalla, no en píxeles).

**Se maneja entero por QMP**, sin ver la pantalla y sin humano:

```jsonc
// mover el puntero a (155,16) — el botón «Applications»
{"execute":"input-send-event","arguments":{"events":[
  {"type":"abs","data":{"axis":"x","value":3968}},
  {"type":"abs","data":{"axis":"y","value":655}}]}}
// click
{"execute":"input-send-event","arguments":{"events":[{"type":"btn","data":{"down":true,"button":"left"}}]}}
{"execute":"input-send-event","arguments":{"events":[{"type":"btn","data":{"down":false,"button":"left"}}]}}
// una tecla
{"execute":"input-send-event","arguments":{"events":[
  {"type":"key","data":{"down":true,"key":{"type":"qcode","data":"c"}}},
  {"type":"key","data":{"down":false,"key":{"type":"qcode","data":"c"}}}]}}
```

**El veredicto también es de píxeles, y conviene medirlo por REGIÓN.** El click en *Applications*
llevó el color dominante de `(33,74,135)` (fondo, 91 %) a `(27,27,27)` (84 %): la biblioteca de
aplicaciones se abrió a pantalla casi completa. Y para el teclado, contar colores del rectángulo del
buscador (440..840 × 80..112) bastó: 155 → 118 colores al reemplazarse el placeholder por lo tecleado.
Evidencia: `cosmic-app-library-click-qemu-2026-08-04.png` y `cosmic-teclado-busqueda-qemu-2026-08-04.png`.

**La biblioteca sale vacía y está BIEN.** Los 20 `.desktop` de la imagen son `NoDisplay=true` —son
applets y componentes de sesión, no aplicaciones—, así que no hay nada que listar. Que la búsqueda no
devuelva resultados no es un fallo del escritorio: es que la distro todavía no tiene ni una sola
aplicación gráfica. Se sabrá arreglado cuando entre `cosmic-files` o un terminal.

### 🔑 Los atajos: dos ficheros envenenados por la misma línea

`Super+A` abre la biblioteca y `Super+W` la vista de espacios de trabajo
(`cosmic-super-w-workspaces-qemu-2026-08-04.png`, con **miniatura viva** del escritorio dentro — o
sea que el camino DMA-BUF→GBM del gotcha 11 anda de verdad). Para llegar ahí hubo que sanear DOS
ficheros de datos, no uno:

| fichero | lo instala | qué liga |
|---|---|---|
| `…Shortcuts/v1/defaults` | `cosmic-comp` | tecla → acción |
| `…Shortcuts/v1/system_actions` | `cosmic-settings-daemon` | acción → comando |

Los dos traen una entrada `ScreenReader`, una variante que `cosmic-settings-config` —el crate que los
parsea, pineado por cargo en la rev `8c54bbbc`— todavía no tiene en su enum. **RON no ignora lo que
no conoce: falla la deserialización del MAPA COMPLETO.** Con `defaults` roto no hay ligaduras; con
`system_actions` roto hay ligadura pero ningún comando que ejecutar. Hacen falta los dos `grep -v`.

**Y acá me equivoqué de fichero teniendo el dato exacto delante.** El error decía
`3:5-3:17: Unexpected variant named 'ScreenReader'` — con línea y columna. Busqué el *token* en vez
de preguntarme *qué fichero lo tiene en esa posición*, di con `keybindings.ron` (que también lo
traía, en la línea 94), lo arreglé, reconstruí `cosmic-comp` **cuarenta minutos** y el error salió
igualito. Era otro fichero, de otro paquete. *Cuando el error trae coordenadas, la primera pregunta
es de qué fichero son, no dónde más aparece esa palabra.*

**Es el riesgo del pin lockstep asomando desde adentro de upstream**: el dato y el parser del *mismo*
epoch no coinciden.

**`Super` abre el lanzador** — pero hizo falta un paquete más, y de otro repo: ver abajo.

### 🔎 `pop-launcher`: el lanzador es DOS programas

`Super` no abría nada y el único rastro era una línea que no menciona ninguna tecla:
`ERROR pop-launcher failed to start: No such file or directory`. **`cosmic-launcher` es sólo la
interfaz**: dibuja la lista y manda lo tecleado por stdin a un proceso hijo, `pop-launcher`, que es
quien busca. Sin ese binario la ventana no tiene qué mostrar, así que no se muestra — el mismo modo
de falla que el panel sin applets: *lo que falta es un HIJO, no una librería, y el log del padre se
lee sano.*

Vive en **otro repo** (`pop-os/launcher`), no versiona por `epoch-N` y no entra en el pin lockstep.
**La versión la fija el `Cargo.lock` de cosmic-launcher** —rev `a332a3a7` de la 1.2.7—, no el último
tag del repo: el lock es lo que garantiza que hablen el mismo protocolo. El tarball se pide por
commit (`/archive/<sha>.tar.gz`), porque no hay tag que nombre esa rev. Selló a la primera
(`b3:64a5cbaa`) con **un solo `NEEDED`: `libc.so`**.

Es otro **multiplexor por symlink**, como cosmic-applets: un binario, y cada plugin es un enlace con
su nombre dentro de su propio directorio junto al `plugin.ron` que lo describe. El servicio enumera
`plugins/*/`; si está el `.ron` y falta el enlace —o al revés— el plugin no existe para él, **sin
error**. Ojo con el `sed 's/_/-/'` de upstream: cambia sólo el primer guión bajo, así que el
directorio es `desktop_entries` y el ejecutable de adentro `desktop-entries`. Son dos nombres
distintos a propósito.

Verificado de punta a punta (`cosmic-super-lanzador-`, `cosmic-pop-launcher-plugins-`,
`cosmic-pop-launcher-calc-qemu-2026-08-04.png`): `Super` abre el lanzador, `?` lista los seis plugins
con su sintaxis —o sea que la enumeración y el IPC JSON-sobre-stdio andan— y `=2+2` **lanza el
plugin, que contesta «qalc command is not installed»**. Esa respuesta es la mejor prueba que había
disponible: el proceso hijo corrió, evaluó la consulta y devolvió una fila. Lo que falta ahora es
`libqalculate` (el binario `qalc`), que es un paquete, no un problema.

### 🧮 `libqalculate`: la calculadora del lanzador, y un `stdin` que era `stdout`

**`=123*456` → `56088` en el lanzador** (`cosmic-calculadora-qemu-2026-08-04.png`). La cadena entera
desde una tecla está en el catálogo: `Super` → cosmic-launcher → pop-launcher → plugin `calc` →
`qalc` → **mpfr → gmp**.

Pero el camino hasta ahí tuvo dos muros que vale la pena dejar escritos, porque ninguno se parecía a
lo que era.

**1) `stdin` apuntaba al descriptor 1.** El plugin no le pasa la cuenta por argv: arranca `qalc` SIN
expresión, con stdin en tubería, le escribe la expresión y cierra. En ese modo el binario **no leía
nada** — ni error ni resultado, la fila del lanzador salía en blanco. El síntoma acusaba a readline,
al toolchain y a la versión, y no era ninguno:

```
DIAG-main isatty=0 feof=0 ferror=0 fd=1        ← fileno(stdin) == 1
nm -S qalc | grep -E ' (stdin|stdout|stderr)$'
  1847148 8 B stderr
  1847148 8 B stdin                             ← las TRES en la misma dirección
  1847148 8 B stdout
```

La causa: **el binario salía DINÁMICO aunque la receta dice `link = "static"`, porque libtool se come
el `-static` que inyecta el harness** ([[link-static-mentira-libtool]] otra vez, cuarto caso). Y en
un musl dinámico las tres variables de stdio se resolvieron por *copy relocation* a **una sola ranura
de BSS compartida**: la última que escribe el loader gana, así que el `stdin` del programa era el
`stdout`. De ahí la asimetría que parecía absurda: `qalc -f /dev/stdin` funcionaba —abre el
descriptor por RUTA, sin tocar el `FILE*` roto— y `qalc` leyendo `stdin` no.

El fix es el par que ya usa `libxml2`: `make LDFLAGS="-all-static -no-pie"`. Con él el binario queda
estático de verdad y los tres símbolos vuelven a tener direcciones distintas, en `.rodata`:

```
  11e71c8 8 R stderr
  11e71d0 8 R stdin
  11e71d8 8 R stdout
```

**La regla, y es de las caras: `link = "static"` en la receta no garantiza un binario estático.**
Cuando algo se comporta como si su `stdin`/`stdout` estuvieran cruzados, `readelf -d` y
`nm -S | grep ' std'` contestan en dos segundos lo que el log no dice nunca.

Barrido de la cola entera con ese criterio, después del fix: de las 7 recetas que declaran
`link = "static"` (`cosmic-session`, `gmp`, `mpfr`, `nasm`, `libqalculate` y las dos que en realidad
son `dynamic`), **ninguna tiene `NEEDED` indebido y ninguna tiene los símbolos de stdio aliasados**.
Era un caso, no un patrón — pero el patrón que lo causa (libtool comiéndose el `-static`) ya tiene
cuatro víctimas anteriores en el corpus.

**2) La pregunta de primer arranque se come la cuenta, SIEMPRE.** Antes de leer nada, `qalc` pregunta
si activar `autocalc`. El plugin le manda la expresión y cierra: la expresión se consume como
respuesta, no es un sí/no válido, y qalc **no llega a guardar config** ⇒ la próxima vez vuelve a
preguntar. No es «falla la primera vez»: no anda nunca. Se siembra
`~/.config/qalculate/qalc.cfg` con `calculate_as_you_type=0` desde `cosmic-start` — política de la
imagen, como el color de fondo; una distro de verdad lo pondría en `/etc/skel`.

**Gotchas de empaquetado de la cadena:**
- **`gmp` va con `--disable-assembly`.** Elige rutinas en ensamblador mirando la CPU de quien
  compila, o sea hornearía la ISA del laptop en el artefacto y el hash dejaría de significar lo mismo
  en la granja. Es una calculadora de escritorio: la implementación en C portable sobra.
- **El configure de libqalculate no encuentra readline** porque prueba `-lreadline` junto a
  `-lncurses`, `-lcurses`, `-ltermcap` y `-ltinfo`, y el corpus **sólo empaqueta `libncursesw.a`**.
  Que el paquete se llame `ncurses` no significa que provea `-lncurses`. Los alias van en un
  directorio LOCAL (`.curses-compat/`) y no en `/usr/lib`: esa vista es la evidencia de qué se
  declaró, y ensuciarla para que pase un test deja mintiendo justo al mecanismo que existe para no
  mentir. Ojo: hay que repetir el `-L` en el `make`, porque el `LDFLAGS` del `make` PISA al del
  `configure`.

### 🖥 `cosmic-term`: la primera APLICACIÓN, y la biblioteca deja de estar vacía

`docs/evidencia/cosmic-terminal-qemu-2026-08-04.png`:

```
bash-5.3# uname -srm; qalc -t '6*7'
Linux 6.16.12 x86_64
42
```

Una ventana de verdad —con decoración, *File/Edit/View*, minimizar/maximizar/cerrar—, `bash` corriendo
adentro, el **kernel propio de hammer** contestando, y el `qalc` que empaquetamos una hora antes
devolviendo 42. Se abre haciendo click en su icono en la biblioteca de aplicaciones
(`cosmic-biblioteca-con-app-qemu-2026-08-04.png`), que hasta hoy salía vacía **con razón**: los 20
`.desktop` de la imagen eran `NoDisplay=true`. Éste no lo es, y es el primero.

**Selló a la primera, y eso es noticia por lo que arrastra**: su `Cargo.toml` depende de
**`cosmic-files`** —el gestor de ficheros entra como LIBRERÍA, por el selector y el
arrastrar-y-soltar—, así que compilar la terminal compila medio gestor de ficheros. Mismo patrón que
`cosmic-settings-config` en los applets: **en esta suite las «aplicaciones» se usan unas a otras como
crates**, y el grafo de deps de Cargo no se parece al mapa de componentes.

`wgpu` viene en `default`, al revés que el resto de los clientes que pintan con tiny-skia. Se dejó
como está: `wgpu` y `naga` ya se habían compilado enteros para `cosmic-workspaces`, así que el costo
estaba pago, y apartarse de las features por defecto es apartarse de la única combinación que
upstream prueba. Cierra con **dos `NEEDED`**: `libxkbcommon.so.0` y `libc.so`.

**Terminfo, para la próxima:** la ncurses del corpus es *libs-only* y no instala la base de terminfo,
pero trae `--with-fallbacks=…,xterm-256color,…` compilados. A la terminal no le hace falta —el que
necesita terminfo es lo que corre ADENTRO— y el hijo acá es el `sh` de busybox, que no lo usa. El día
que entren `vim` o `htop` en la imagen, hará falta un paquete de datos de terminfo.

### 📁 `cosmic-files`: «ya está compilado» era falso por una línea del vecino

`docs/evidencia/cosmic-files-qemu-2026-08-04.png`: el gestor abre desde la biblioteca y **lista el
sistema de ficheros real de hammer** — `bin` (72 ítems), `dev` (129), `ente`, `etc`, `lib`,
`lost+found`—, con su barra lateral de Recientes/Carpeta personal/Papelera.

Entró creyendo que era casi gratis, porque `cosmic-term` **ya compila cosmic-files como crate**. No
lo era, y el motivo cabe en una línea del `Cargo.toml` del vecino: `cosmic-term` lo declara con
`default-features = false`. **Lo que un paquete cuesta depende de con qué features lo pide quien lo
usa**, así que «ya se compiló» puede ser falso.

**Se apagaron dos features por defecto, y las dos por una razón medida:**

- **`gvfs`** trae los crates `gio`/`glib`, y el enlace final pide las glib **compartidas**
  (`unable to find dynamic system library 'gio-2.0'`, y detrás gobject, glib, ffi, pcre2, z). El
  corpus las empaqueta **sólo estáticas**: haría falta una cadena `-shared` entera, que es una
  campaña aparte y mete una segunda glib en la imagen. Se pierden los **montajes remotos** (SFTP,
  MTP), no la navegación local. ⚠ Y por eso tampoco se construye `cosmic-files-applet`: su
  `Cargo.toml` fija `gvfs` a mano, así que apagarla en el paquete raíz no lo alcanza.
- **`wgpu`** desbordó el filesystem: `No space left on device` creando un temp dir en `/src/target`,
  **dos veces**, con wgpu+naga+ash+glow+spirv a `codegen-units=1`. Sin él el árbol de build queda en
  ~2 GB. No se pierde nada: todos los demás clientes de la suite pintan con tiny-skia y la imagen no
  tiene GPU.

**El intento con `glib` declarado dejó una regla que vale para toda la cola:** el error fue
`Package 'libpcre2-8', required by 'glib-2.0', not found` — o sea que **el que falla no es la dep
sino lo que su `.pc` declara en `Requires`**. Declarar una dep trae su artefacto, no su clausura de
pkg-config. Se lee en dos segundos con `grep ^Requires` sobre los `.pc` del artefacto, ANTES de
gastar el build: `glib-2.0.pc → libpcre2-8`, `gobject-2.0.pc → libffi`, `gio-2.0.pc → zlib`.

### ⚙ `cosmic-settings`: un panel de control que NO arrastra medio sistema

`docs/evidencia/cosmic-settings-qemu-2026-08-04.png`: abre desde el lanzador (`Super`, teclear
«settings») con la barra lateral entera —Red, Bluetooth, Accesibilidad, Escritorio, Pantallas,
Sonido, Energía, Dispositivos de entrada, Aplicaciones, Fecha y hora, Sistema y cuentas— y la página
de Escritorio poblada. **Tarda ~20 s en aparecer** con render por software; no está colgado.

**La sorpresa buena: no arrastra NetworkManager, ni libpulse, ni udisks, ni accountsservice.** Habla
con los daemons por D-Bus vía `zbus`, que es Rust puro. Se verificó ANTES de escribir la receta
listando los crates `-sys` del `Cargo.lock` —que es donde vive la verdad sobre qué C hace falta—, y
salieron sólo: `drm-sys`, `input-sys`, `libudev-sys`, `dav1d-sys`, `wayland-sys` (dlopen) y
`gettext-sys` (trae su propio C). O sea el juego fijo de todo cliente + `libdrm` + `dav1d`.
**Regla barata que ahorra builds: `grep '^name = ".*-sys"' Cargo.lock | sort -u` antes de adivinar
deps por lo que el programa *hace*.**

**Volvió a morder la doble barra de `cosmic-protocols//`**, igual que en `cosmic-applets`: el
manifiesto usa esa forma para que cargo trate la fuente como distinta y el `[patch]` no se apunte a
sí mismo, GitHub le contesta 404, y el build muere en el *fetch* con tres reintentos de red y
`failed to load source for dependency 'cosmic-client-toolkit'`. Mismo fix: mover la barra al medio
(`pop-os//cosmic-protocols`) en `Cargo.toml` **y** `Cargo.lock`. **Segunda víctima ⇒ es un patrón de
la suite, no una rareza de un repo**: conviene mirarlo apenas un `cargo vendor` da 404.

Instala **32 `.desktop`**, uno por página, así que cada sección del panel aparece como entrada propia
en la biblioteca. Y `resources/default_schema/` va con `find`, no con `cp` plano: es el árbol
`<Componente>/v1/<clave>` de cosmic-config, donde **cada clave es un fichero cuyo nombre es la
clave** — aplanarlo deja el escritorio sin esa configuración, callado.

**Deuda que deja anotada su propio log:** `org.freedesktop.locale1 was not provided by any .service
files` — nadie sirve `localed` en la imagen, así que la página de idioma no va a poder cambiar nada.
Es del mismo tipo que `login1` (que sí resuelve arje-logind-compat), y se arregla del mismo modo.

### ⏱ El panel tiene un DEADLINE de arranque: con 4 vCPU sale mutilado

Medido: con `-smp 4` el panel arranca **sin ala izquierda, sin reloj y sin dock** —sólo la bandeja
derecha— y el único rastro es `Failed to setup panel dbus server deadline has elapsed`. Con
`-smp 8 -m 8192`: **4 arranques de 4 con el escritorio completo y cero deadlines vencidos**.
La causa es la estampida del arranque: el panel registra su servidor D-Bus mientras catorce clientes
de iced escanean fuentes al mismo tiempo.

**CORRECCIÓN medida el 2026-08-04 validando `cosmic-edit`: la carga del anfitrión TAMBIÉN cuenta, y
`-smp 8` no la compensa.** Acá decía «no es carga del anfitrión (falla con el anfitrión ocioso)» —
cierto para el caso de 4 vCPU, pero se leía como que con 8 el anfitrión daba igual, y no. Dos
arranques de la MISMA imagen con la MISMA línea de QEMU (`-smp 8 -m 8192`):

| anfitrión | colores | veredicto |
|---|---|---|
| `load average` ≈ 6 (otro agente compilando) | **130** | mutilado, y `deadline has elapsed` en el log |
| ocioso (`load` ≈ 1,8) | **1671** | completo |

Tiene sentido y conviene decirlo entero: **este anfitrión tiene 8 núcleos** (`nproc`), así que
`-smp 8` le entrega la máquina entera a la VM y cualquier otra cosa que compile compite por los
mismos hilos. La receta práctica: antes de creerle a un arranque mutilado, mirá `/proc/loadavg` y
repetí con el anfitrión quieto — es un minuto y evita perseguir una regresión que no existe, que es
exactamente el error que ya está documentado dos párrafos más abajo.

**Y una lección de medición que costó horas: el conteo de procesos NO sirve como veredicto.**
Perseguí una regresión inexistente comparando «48 procesos» contra «31», revirtiendo uno por uno
tres cambios míos que no tenían nada que ver — cuando un escritorio completo y sano muestra **32**.
El número de procesos no distingue «arrancó» de «dibujó». El framebuffer sí, y sin ambigüedad:
**894 colores = completo, 130 = mutilado.** (Con las cuatro aplicaciones en el dock el escritorio
completo da ~1670: el umbral es «cientos vs. miles», no un número exacto.)

## ✎ LA CUARTA APLICACIÓN: cosmic-edit (2026-08-04)

`docs/evidencia/cosmic-edit-qemu-2026-08-04.png` y `…-escribiendo-…png` — 1280×800, **2105 colores**:
ventana con menús *File/Edit/View*, pestaña *New document*, numeración de líneas, y el icono del
editor en el dock con el punto de «en ejecución». La segunda captura tiene `hammer edita` escrito
por teclado sobre la línea 1 y el punto de modificado en la pestaña: **se escribe, no sólo se ve**.

Se llegó por el lanzador, sin tocar el ratón para elegir: `Super` → teclear `text` → el primer
resultado es *«Text editor for the COSMIC desktop»* (`Ctrl+1`) → `Enter`. Que el editor sea lo que
responde a «text» es efecto del `.desktop` que genera `xdgen`, con sus `Keywords` y su
`MimeType=text/plain` — el mismo fichero que lo hace manejador por defecto de los `.txt` en el
gestor.

**Lo que la receta enseñó, y que corrige un borde de la regla del `Cargo.lock`:** leer los crates
`-sys` del lock sigue siendo lo primero que hay que hacer, pero **el lock lista lo POSIBLE, no lo
ENCENDIDO**. En cosmic-edit aparecen `gio-sys`, `glib-sys` y `gobject-sys` —que en cualquier otro
paquete mandarían a declarar glib, y de ahí a la campaña de la cadena compartida— y entran sólo por
la feature `gvfs`, que se apaga. El lock dice el universo; las features dicen el recorte. La receta
no declara ninguna glib y su binario enlaza **sólo `libxkbcommon.so.0` y `libc.so`**: dos NEEDED, el
mínimo de la suite.

## 🛍 LA QUINTA: cosmic-store, y el catálogo que hubo que fabricar (2026-08-05)

`docs/evidencia/cosmic-store-qemu-2026-08-05.png` — la tienda abierta con *Popular apps* poblada
(App Library, Files, Launcher, Settings) y *Recently updated*; y `…-busqueda-…png` con la búsqueda
`text` devolviendo *COSMIC Text Editor*. Sellada en `b3:a06437f7`, NEEDED = `libxkbcommon` + `libc`.

**Es la primera aplicación de la suite que choca con que hammer es OTRA distro.** Una tienda es la
cara de un gestor de paquetes. De sus cuatro backends (`src/backend/`, todos tras `#[cfg(feature)]`):
`flatpak` pide `libflatpak` (GObject ⇒ glib compartida + ostree/libsoup/gpgme: la torre de C que esta
campaña evita); `packagekit` es Rust puro pero necesita el DEMONIO en el bus; `rpm-ostree` es
irrelevante; **`pkgar` es el único que arranca** — ni demonio ni enlace.

**ALCANCE: navega, no instala.** Nada está cableado al `.swm` (Etapa F). El camino barato para
arreglarlo quedó identificado: servir `org.freedesktop.PackageKit` sobre `.swm` y encender la feature
`packagekit`. Es un shim de D-Bus, mismo patrón que `arje-logind-compat`, y **no cuesta un solo `.so`
de C**.

### El catálogo AppStream: dos trampas encadenadas

**1. `pkgar` NO lee `/usr/share/metainfo/`.** Lee el CATÁLOGO —
`{/usr/share,/var/lib,/var/cache}/{swcatalog,app-info}/{xml,yaml}/` — que en una distro normal genera
`appstreamcli compose` agregando los metainfo en un `<components version=… origin=…>`. Sin ese
fichero la tienda **abre perfecta y no lista NADA**. Lo genera ahora `qemu-desktop-image.sh` con doce
líneas de python; no hace falta AppStream ni glib.

**2. Hay que SACAR el `<provides>`.** Los metainfo que genera `xdgen` lo escriben con los envoltorios
`<mimetypes>`/`<binaries>`, y el crate `appstream` espera los hijos planos del esquema de catálogo.
Con el envoltorio muere con `The tag provide doesn't have a value` y **descarta el componente entero,
callado**: listaba 2 de 7, justo los dos sin `<provides>` (App Library y Launcher).

**Cómo se diagnosticó, que es lo reutilizable:** desde el lanzador la salida del proceso NO llega al
log de la sesión. Hay que abrir `cosmic-term` DENTRO de la VM y correr
`rm -rf ~/.cache/cosmic-store; RUST_LOG=info cosmic-store 2>/tmp/s.log` — el `rm` no es opcional, la
tienda cachea el parseo en `~/.cache/cosmic-store/*.bitcode` y si no se borra vuelve a mostrar el
resultado viejo. Que la imagen tenga terminal deja de ser una comodidad y pasa a ser instrumental.

### Y el deadline del panel es una CARRERA, no un umbral

Con el anfitrión ocioso y `-smp 8`, un arranque de esta tanda salió igual mutilado (130 colores) con
33 procesos vivos y el `deadline has elapsed` en el log. O sea: 8 vCPU + anfitrión quieto **sube
mucho la probabilidad pero no garantiza**. Si sale mutilado y la carga estaba baja, **rearrancá antes
de buscar la causa en tu cambio**.

## 📷 LA SEXTA: cosmic-screenshot, sellada y ROTA A PROPÓSITO (2026-08-05)

`b3:8b57ec9b`, 9 minutos de build, **`NEEDED` = `libc.so` y nada más** — un solo NEEDED, y el único
`[deps] build = []` de la campaña: 130 líneas de Rust, ni `libxkbcommon` ni `libinput`, porque no
dibuja ni habla wayland. Todo su trabajo es `Screenshot::request()` de `ashpd`.

**Se incluye en la imagen sabiendo que falla, para que el hueco sea medible.** El error exacto está
en `docs/evidencia/cosmic-screenshot-sin-portal-2026-08-05.png`, corrido desde `cosmic-term` dentro
de la VM:

```
panicked at src/main.rs:76:10: failed to send screenshot request: Portal(ZBus(MethodError(
  OwnedErrorName("org.freedesktop.DBus.Error.ServiceUnknown"),
  Some("The name org.freedesktop.portal.Desktop was not provided by any .service files"))))
```

Misma forma que la deuda de `org.freedesktop.locale1` que anota el log de settings: **un nombre de
D-Bus que nadie sirve**.

### La cadena del portal son TRES eslabones, no dos

Medido en el fuente de `xdg-desktop-portal-cosmic`: `static DBUS_NAME =
"org.freedesktop.impl.portal.desktop.cosmic"` (`src/main.rs:27`). Es un **backend**; no toma el
nombre que los clientes buscan. La cadena es:

`cosmic-screenshot` → `org.freedesktop.portal.Desktop` → **xdg-desktop-portal** (frontend, C sobre
GIO) → `org.freedesktop.impl.portal.desktop.cosmic` → **xdp-cosmic** (backend, Rust)

**Faltan los dos**, y ninguno está en ninguna cola (`xdg-desktop-portal-gnome` sí está, pero es otro
backend). Lo que sí hay para el backend: `pipewire`, `gbm` (mesa) y `wayland` ya en el corpus. Su
único bloqueo real es que declara `cosmic-files` con la feature `gvfs` fijada a mano — el mismo
patrón que ya bloqueó `cosmic-files-applet`, y acá es **un parche de una línea**.

### ⚠ CORRECCIÓN: la cadena glib COMPARTIDA YA EXISTE Y ESTÁ SELLADA

Este documento decía que `gvfs` exigía «una campaña propia». **Es falso desde que la campaña KDE
selló las piezas.** Medido con `hammer hash` desde las dos colas, que es el método correcto para
saber si una receta se puede compartir:

| receta | cola | ¿resuelve igual desde `incoming-cosmic`? |
|---|---|---|
| `glib-shared` 2.88.1 | `incoming-kde` | **sí** — `b3:0584ce1f`, ya sellada |
| `pcre2-shared` | `incoming-kde` | **sí** — `b3:f254abaa`, ya sellada |
| `zlib-shared` | ya en `incoming-cosmic` | — |

Hash idéntico ⇒ **cero rebuild**: los artefactos sirven tal cual. `libffi` entra estático dentro de
la propia `.so`, así que no hace falta variante compartida (su `libffi` aparece en
`Requires.private`, que pkg-config ignora fuera del modo estático).

**Pero la decisión sigue teniendo costo y no es técnica:** encender `gvfs` mete una SEGUNDA glib en
la imagen —justo lo que la campaña GNOME midió y evitó— y re-hashea `cosmic-files`, que arrastra a
`cosmic-term` y `cosmic-edit` porque lo usan como crate.

## 🚪 LA CADENA DEL PORTAL, CERRADA (2026-08-05)

`docs/evidencia/cosmic-portal-captura-2026-08-05.png` — `cosmic-screenshot` corrido desde
`cosmic-term` dentro de la VM devuelve una ruta en vez de un panic, y el fichero existe:

```
/root/Pictures/Screenshots/Screenshot_2026-08-05_10-18-14.png
-rw-r--r-- 1 root root 42256 Aug  5 10:18 Screenshot_2026-08-05_10-18-14.png
0000000 211   P   N   G  \r  \n 032  \n          ← la firma PNG, no un fichero vacío
```

**El veredicto es el fichero, no el código de salida**: que el binario no panickee sólo prueba que
encontró el servicio; que haya 42 KB con la firma `\x89PNG` prueba que el compositor entregó píxeles
por la cadena entera.

Cinco recetas nuevas, en el orden en que hicieron falta:

| receta | hash | por qué |
|---|---|---|
| `fuse3` 3.18.2 | `b3:3a84af86` | dep dura del frontend (document portal) |
| `json-glib` 1.10.8 | `b3:dc8c8e78` | dep dura; variante SIN introspección (ver abajo) |
| `xdg-desktop-portal` 1.18.4 | `b3:f4adb8c9` | **el frontend**: registra `org.freedesktop.portal.Desktop` |
| `clang18` | `b3:62c6bfb5` | sólo `libclang.so`, para el bindgen de `libspa-sys` |
| `xdg-desktop-portal-cosmic` | `b3:7ad0f124` | **el backend**: las 5 interfaces `impl.portal.*` |

Son **dos raíces** del perfil y de la hidratación porque son **dos procesos** que se encuentran por
D-Bus en runtime: ningún `[deps]` los relaciona, igual que los componentes de la sesión.

### Lo que costó cada decisión

**Pin a 1.18.4 por gstreamer.** Desde 1.20 el meson del frontend pide `gstreamer-pbutils-1.0` sin
`required:` —valida sonidos de notificación— y eso arrastra gstreamer + gst-plugins-base. El día que
se quiera 1.22, ése es el precio.

**El choque de `gvdb`.** El portal vendoriza gvdb y glib también lo lleva adentro (GSettings): con
glib estática el `.a` expone los símbolos y hay doble definición. Los dos caminos obvios se
descartaron POR MEDICIÓN: `--allow-multiple-definition` **zig-cc no lo acepta** (`unsupported linker
arg`, que meson reporta como `Compiler cannot compile programs` y manda a buscar donde no está), y
`glib-shared` habría abierto la cadena entera (json-glib y gdk-pixbuf también enlazan glib). Se
resolvió renombrando los diez símbolos del lector con `-D`: el renombrado es total porque todos los
usos pasan por `gvdb-reader.h`, y el binario queda con lector+constructor de la MISMA copia — que
importa, porque glib empaqueta sólo el lector.

**`libclang` no salió de COSMIC.** El backend declara `pipewire`/`libspa-sys` como deps duras y
`libspa-sys/build.rs` corre `bindgen` incondicional, sin bindings pregenerados de reserva (a
diferencia de `aws-lc-sys`, que sí cae a `cargo:rustc-cfg=universal` y por eso cruzó a musl sin cmake
ni perl). `clang18` NO recompila LLVM: el artefacto `llvm18` ya publica headers y `lib/cmake/llvm`.
Instala sólo `libclang.so` y `clang-c/` — el compilador de esta distro sigue siendo zig-cc.

**El parche de una línea:** el backend fija `cosmic-files` con `features = ["gvfs", "wayland"]` a
mano. El `sed` saca `gvfs` y queda la misma combinación que ya compilaron term y edit. Se pierden los
montajes remotos dentro del selector del portal, no el selector.

### ⚠ Gotchas de andamiaje que costaron tiempo real

**`--offline` SIN `--locked`** cuando la receta parchea el manifiesto: el `sed` corre en la fase
*compile*, o sea DESPUÉS del `cargo vendor`, así que cargo ve el `Cargo.toml` cambiado y con
`--locked` se niega. Quitar una feature sólo ENCOGE el conjunto de crates; la hermeticidad la da
`--offline` + el árbol vendorizado.

**El worker NO puede construir la cola COSMIC.** El snapshot golden es del 2026-07-15 y toda esta
cola es posterior, así que allá nada es cache-hit: intentó reconstruir `pipewire` y murió buscando
glib. Es la regla de «rootfs laptop ≠ worker» pero con el **STORE**. Corolario: mandar a la granja
sólo lo que no dependa de artefactos recientes — `clang18` sí (sus deps son viejas), el backend no.

**`farm-down.sh` cosecha `/opt/hammer/store`, pero `farm-up.sh` ancla el store del worker en
`/mnt/cosecha/store`.** Son directorios distintos, NO el mismo por symlink: una cosecha normal no
trae lo que el worker construyó ahí. Bajar el artefacto a mano ANTES de destruir nada.

**Y no pedir un `farm-down` completo por un artefacto de 57 MB**: el pull de los 616 artefactos del
store horneado dejó 67 G en `store/.dmerge` y llenó `/home` al 100 %. (La protección sí funcionó: al
fallar el pull, no destruyó el worker.)

**`pipewire` no lleva `--wrap-mode=nodownload`** ⇒ ante una dep faltante intenta bajarse un
subproyecto de internet y, sin red, el error no dice «te falta glib» sino `Unhandled python
exception / This is a Meson bug`. **Deuda anotada.**

### Las cinco interfaces, sondeadas por D-Bus

`docs/evidencia/cosmic-portal-interfaces-2026-08-05.png`. Del lado del CLIENTE, contra
`org.freedesktop.portal.Desktop`:

| interfaz | versión |
|---|---|
| `Screenshot` | 2 |
| `FileChooser` | 3 |
| `ScreenCast` | 5 |
| `Settings` | 2 |
| `Access` | **no existe, y está bien** |

**`org.freedesktop.portal.Access` no es una interfaz de cliente**: `Access` vive sólo como
`org.freedesktop.impl.portal.Access`, o sea del lado del BACKEND — es como el frontend le pide al
escritorio que muestre el diálogo de permiso. Pedirla al frontend devuelve `No such interface`, que
es el comportamiento correcto y no un hueco. (El sondeo inicial la pidió mal; el error era del
sondeo, no del portal.)

Del lado del BACKEND, contra `org.freedesktop.impl.portal.desktop.cosmic`: `Screenshot` 2,
`ScreenCast` 4, `Settings` 2, y `Access` + `FileChooser` responden
`UnknownProperty 'version'`. **Ese error prueba que la interfaz ESTÁ**: si faltara, D-Bus contestaría
`No such interface`; `UnknownProperty` significa que la encontró y que esa implementación no publica
la propiedad `version`.

Y el `ListNames` del bus muestra `org.freedesktop.impl.portal.desktop.cosmic` **activado** junto a
`org.freedesktop.impl.portal.PermissionStore`: el frontend levantó al backend por D-Bus activation,
que es justamente el eslabón que el `.service` con `@libexecdir@` sustituido hace posible.

⚠ **Alcance honesto de esta medición**: prueba DISPONIBILIDAD y ENRUTADO, no los flujos completos.
La única interfaz ejercida de punta a punta —con fichero en disco— es `Screenshot`.

### 📂 FileChooser EJERCIDO — y por qué NO se puede desde cosmic-store

`docs/evidencia/cosmic-portal-filechooser-2026-08-05.png` y `…-nav-…png`.

**Primero, el camino que NO existe.** `cosmic-store` se compila con la feature `xdg-portal`, pero su
ÚNICO uso del selector está en `src/update.rs:505`, dentro de
`Message::RepositoryAddDialog`, y arranca con:

```rust
if backend_name == BackendName::FlatpakUser {
    #[cfg(feature = "xdg-portal")]
    ...file_chooser::open::Dialog::new()...
```

O sea que el diálogo sólo se abre para **añadir un repositorio flatpak**, y esta imagen no construye
el backend flatpak (ver arriba: sólo `pkgar`). La feature está compilada y el punto de llamada es
inalcanzable. **Tener la feature encendida no es tener el flujo disponible.**

**El camino que sí ejerce la interfaz** es llamarla directo, que además prueba los tres eslabones sin
depender de qué aplicación la use:

```sh
dbus-send --session --print-reply --dest=org.freedesktop.portal.Desktop \
  /org/freedesktop/portal/desktop org.freedesktop.portal.FileChooser.OpenFile \
  string:"" string:Prueba dict:string:variant:handle_token,string:hammer1 &
```

Resultado medido: el frontend devuelve el objeto Request con el token que le pasamos
(`/org/freedesktop/portal/desktop/request/1_113/hammer3`) y el **backend dibuja el diálogo real** —
título `Prueba`, el FS de verdad (`run`, `tmp`, `dev`, `proc`, `sys`), panel de detalles
(`inode/directory`, 963 items) y botones Cancel/Open. La navegación anda: doble clic en `dev` cambia
la miga de pan a `Filesystem › dev` y lista sus 129 entradas; seleccionar y pulsar *Open* lo cierra.

⚠ **Lo que NO quedó capturado, y por qué**: la URI devuelta. El `Response` de la especificación de
portales es una señal **unicast al llamador**, y `dbus-send` termina apenas recibe el *method return*,
así que ya no está para recibirla; `dbus-monitor` con un match rule normal tampoco la ve. Capturarla
pide un cliente que implemente el ciclo Request/Response — que es justo lo que hace `ashpd` dentro de
las aplicaciones. **El diálogo está probado; la entrega del fichero a la aplicación, no.**

### 🎥 ScreenCast: dos bloqueos distintos, y el segundo es el que importa

**Bloqueo 1 — `dbus-send` no puede manejarlo, al revés que FileChooser.** El selector se abre con
UNA llamada; ScreenCast necesita un apretón de manos de tres pasos con estado (`CreateSession` →
`SelectSources` → `Start`), y la sesión queda atada a la **conexión** que la creó. Cada `dbus-send`
es una conexión distinta que muere al terminar, así que el paso 2 nunca encuentra la sesión del paso
1. No es un límite del portal sino de la herramienta: hace falta un cliente `ashpd` de verdad.

Lo que sí se midió con `dbus-send`: `CreateSession` **funciona** — el frontend acepta y devuelve
`/org/freedesktop/portal/desktop/request/1_106/sc1`, con el `handle_token` que le pasamos.

**Bloqueo 2 — NADIE ARRANCA EL DEMONIO DE PIPEWIRE**, y éste no lo arregla ningún cliente.
`docs/evidencia/cosmic-screencast-sin-pipewire-2026-08-05.png`:

```
$ ls /usr/bin/ | grep -i pipewire      →  pipewire, pipewire-aes67, pipewire-avb, pipewire-pulse
$ ps ax | grep -c pipewire             →  1     (el propio grep: NO hay demonio)
```

Los binarios están en la imagen —el backend del portal enlaza `libpipewire-0.3.so`— pero el
**demonio** no corre: `cosmic-start` no lo lanza y no hay systemd de usuario que lo active. O sea que
aunque se escribiera el cliente `ashpd`, `Start` no tendría con quién negociar el stream.

Arreglo: lanzarlo desde `cosmic-start`. (Para AUDIO además falta `wireplumber`, que ni siquiera está
construido — ver la decisión de audio del proyecto.)

### ✅ HECHO: el demonio arranca y el portal se conecta a él (2026-08-05)

`cosmic-start` lanza `/usr/bin/pipewire` justo después de asegurar `XDG_RUNTIME_DIR`, y espera **el
socket, no el pid** — la misma disciplina que el compositor, y por el mismo motivo: un demonio que
muere al instante deja el pid «existiendo» un rato y esperar por él da un OK falso.

Va en ese punto del script a propósito: pipewire **no necesita D-Bus** para arrancar, sólo el
runtime dir donde abre su socket. Ponerlo ahí lo hace válido para los dos modos —`bare` y
`session`— sin duplicar el bloque.

Lo medido dentro de la VM, en este orden, porque cada paso responde una pregunta distinta:

```
== cosmic-qemu :: pipewire OK (socket pipewire-0, pid 189)   ← ¿abre socket?

$ pw-cli info 0
  type: PipeWire:Interface:Core/4      version: "1.2.7"      ← ¿SIRVE a un cliente?
  core.daemon = "true"    core.name = "pipewire-0"

$ pw-cli ls Factory | grep factory.name
  … "client-node" …                                          ← ¿tiene lo que ScreenCast usa?

$ pw-cli ls Client | grep application.name
  application.name = "pw-cli"
  application.name = "xdg-desktop-portal"                     ← ¡EL PORTAL YA ESTÁ CONECTADO!
```

La última línea es el veredicto. **El frontend del portal aparece como cliente del demonio de
pipewire** — una conexión que antes no podía existir, y que es exactamente la que `Start` necesita
para negociar el nodo del stream. `client-node` es la factory con la que el backend lo crea.

**Y sin regresión, medido con la cadena entera**: `cosmic-screenshot` desde `cosmic-term` levanta la
**UI interactiva del portal** —un toolbar con los tres modos (región, ventana, pantalla), `Capture` y
`Save to Pictures`—, y el click en `Capture` deja un PNG de 53.590 bytes en
`/root/Pictures/Screenshots/`. Evidencia: `docs/evidencia/cosmic-portal-ui-captura-2026-08-05.png`.
Esa UI no estaba documentada: el registro anterior sólo probaba la ruta no-interactiva.

El escritorio completo con pipewire corriendo:
`docs/evidencia/cosmic-escritorio-con-pipewire-2026-08-05.png` (2414 colores distintos — el panel
mutilado da 130).

**Lo que sigue faltando, y son dos cosas separadas:**

- **El handshake de ScreenCast de punta a punta** necesita un cliente **persistente** — resuelto
  abajo con `portal-probe`.
- **`wireplumber`**, que es lo que separa «hay stream de video» de «hay audio». Sin gestor de sesión
  pipewire arranca y sirve, pero no descubre tarjetas ni enruta. Para ScreenCast alcanza; para sonido
  no. Ni siquiera está construido.

## 🔌 portal-probe: la SONDA, y hasta dónde llega ScreenCast de verdad (2026-08-05)

`recipes/incoming-cosmic/portal-probe.toml` → **`b3:af49d32d`**. La única receta de la campaña cuyo
producto no es una pieza del escritorio sino **un instrumento para medirlo**.

### Por qué no alcanzaba `dbus-send`, y por qué tampoco hacía falta `ashpd`

Los portales no son RPC: son **asíncronos en dos tiempos**. Cada método devuelve al instante un
object path de `Request`, y el resultado llega DESPUÉS como señal `Request.Response` sobre ese path.
`dbus-send` ya salió para entonces. Y hay un segundo muro, más duro: **el portal ata la sesión al
SENDER**, así que `CreateSession` con un `dbus-send` y `SelectSources` con otro se rechazan por venir
de nombres únicos distintos. Hace falta UNA conexión viva durante todo el intercambio.

`ashpd` era el camino obvio, pero el árbol fuente de una receta de código propio es el repo del
**propio hammer** (no hay modo `path` en `[source]`: sólo git y tarball) ⇒ habría sumado ~150 crates
al `Cargo.lock` de la herramienta de build por una sonda de diagnóstico. En C sobre `libdbus` cuesta
**cero deps nuevas**, sale **estática (0 NEEDED)** —un instrumento no debe depender de lo que mide— y
sobre todo: **el protocolo se ve**. Una sonda cuyo valor es documentar un handshake no debería
esconderlo tras una biblioteca que lo abstrae.

Detalle que la especificación regala y hay que usar: **el path del `Request` se PREDICE**
(`/…/request/<sender>/<token>`, con `sender` = nombre único sin `:` y con `.`→`_`). Existe justo para
poder suscribirse ANTES de llamar; esperar al path devuelto para recién ahí hacer `AddMatch` es una
carrera real, no teórica.

### Lo medido: el stream SE CREA, y el `Response` es lo único que falta

```
portal-probe version        ScreenCast v5 · Screenshot v2 · FileChooser v3 · Settings v2
                            Access y RemoteDesktop AUSENTES
portal-probe screencast
  [1/4] CreateSession   → Response 0, session_handle = /…/session/1_108/hammersess   ✓
  [2/4] SelectSources   → Response 0                                                 ✓
  [3/4] Start           → el backend ABRE su diálogo «Share your screen», con
                          miniatura EN VIVO del framebuffer y el output `Virtual-1`;
                          se elige, se pulsa Share… y NO llega Response en 60s        ✗
```

El log del backend (`RUST_LOG=debug`) da la línea exacta:

```
xdg_desktop_portal_cosmic::screencast_thread: state-changed 'Connecting' -> 'Paused'
```

Y `pw-cli ls Node` durante la espera da el veredicto independiente:

```
node.name   = "cosmic-screencast"
media.class = "Video/Source"
```

**El nodo de video EXISTE en el grafo de pipewire.** O sea: la cadena entera —cliente → frontend →
backend → compositor → demonio de pipewire— funciona hasta **crear y negociar el stream**. Lo único
que no ocurre es que el backend emita el `Response` de vuelta al cliente tras quedar en `Paused`.

Ese es un lugar muy distinto del que ocupaba el frente esta mañana («no hay demonio con quien
negociar»). El siguiente paso es el `Paused → Streaming`, y la sospecha razonable es
**`wireplumber`**: sin gestor de sesión nadie mueve el nodo de `Paused`, y el backend parece
esperarlo. Es una hipótesis, no una medición — construir `wireplumber` la confirma o la mata.

### Dos gotchas que costaron corridas

- **`NameHasOwner` no activa nada.** Los portales son servicios ACTIVABLES: el bus los arranca al
  pedirlos. La primera versión de la sonda rechazaba con «no hay frontend de portal en el bus» sobre
  un sistema donde el portal andaba perfecto, sólo que dormido. Se arregló con `StartServiceByName`
  (commit `ea210ba`) — y distingue «arrancado ahora» de «ya estaba corriendo», que no es lo mismo
  cuando lo que se investiga es una carrera.
- **Matar el backend se lleva puesto al frontend.** `kill` sobre `xdg-desktop-portal-cosmic` dejó
  también sin dueño a `org.freedesktop.portal.Desktop`. Para diagnosticar con log hay que relanzar el
  backend Y forzar la reactivación del frontend.
- **El lanzador busca por NOMBRE VISIBLE, no por binario**: `cosmic-term` no matchea nada,
  `Terminal` abre COSMIC Terminal. Escribir el nombre del ejecutable abre otra cosa (settings).
- Y uno de shell, al automatizar por QMP: **`| head -N` bufferiza** y deja la terminal en blanco hasta
  que el programa termina. Parece que la sonda colgó cuando en realidad está esperando.

## 🔇 wireplumber: construido, corriendo… y NO era el bloqueo (2026-08-05)

`recipes/incoming-cosmic/wireplumber.toml` 0.5.15 → **`b3:b8f3baf0`**, selló a la primera. Arranca
desde `cosmic-start` (`wireplumber OK (pid 192) — hay gestor de sesión`), se conecta al grafo (dos
clientes en `wpctl status`) y `wpctl` pasa a mostrar una sección **Video** que antes no existía.

**Y el `Start` de ScreenCast sigue sin `Response` a los 60s. Exactamente el mismo resultado.**

La hipótesis de la sección anterior —«falta el gestor de sesión que mueva el nodo de `Paused` a
`Streaming`»— **queda MATADA por medición**. Era mía y era razonable; no era cierta. Se deja escrita
junto con su refutación porque una hipótesis descartada con evidencia vale más que una lista de
sospechosos: acota el próximo paso a lo que queda, que es el propio backend.

El dato nuevo, que es por dónde seguir: con wireplumber corriendo, `wpctl status` lista
`Video → Streams` **vacío** durante el handshake. O sea que el nodo `cosmic-screencast` que sí existe
en `pw-cli ls Node` **no llega a wireplumber como stream gestionado**. El próximo movimiento es el log
del backend con `RUST_LOG=trace` alrededor de `screencast_thread`, no otra dep.

## 🧊 LA CAUSA REAL DE ScreenCast: EGL POR SOFTWARE, no el portal (2026-08-05)

Con `RUST_LOG=xdg_desktop_portal_cosmic=trace` el log del backend tiene **5 líneas en total** y la
última es la de siempre:

```
INFO xdg_desktop_portal_cosmic::screencast_thread: state-changed 'Connecting' -> 'Paused'
```

**Después del `Paused` el backend no escribe NADA MÁS** — ni error, ni panic, ni progreso. O sea que
el trace no aportó: `screencast_thread` no tiene más instrumentación. Buscar ahí estaba agotado.

La pieza que faltaba mirar era **el compositor**, que es quien tiene que entregar los frames.
`/tmp/cosmic-session.log`:

```
15:19:02.301568Z WARN smithay::backend::egl::error: Erroneous EGL call didn't set EGLError
15:19:02.310127Z INFO …screencast_thread: state-changed 'Connecting' -> 'Paused'
                 ↑ 8 MILISEGUNDOS de diferencia
```

**Y la correlación no es casual**: el contador de `Erroneous EGL` sube **de a dos por cada intento de
screencast** (uno al abrir el diálogo con su miniatura en vivo, otro al pulsar Share) y no se mueve
en ningún otro momento. Seis acumulados tras tres intentos.

`ls /usr/lib/dri/` da `iris_dri.so`, `kms_swrast_dri.so`, `swrast_dri.so`. En QEMU con
`virtio-gpu-pci` (sin virgl) el que carga es **`kms_swrast`**: mesa por software, **sin exportación
dmabuf real**. Y eso es exactamente lo que un stream de vídeo continuo necesita y una captura de una
sola toma no — por eso `cosmic-screenshot` SÍ funciona (va por shm) y ScreenCast no.

### El frente no está bloqueado por una receta, está bloqueado por el ENTORNO

Esto reencuadra todo el día: **pipewire no era, wireplumber no era, el backend del portal no era**.
Las tres piezas están construidas, corriendo y haciendo su trabajo —el nodo `cosmic-screencast`
existe en el grafo—, y el que no puede cumplir es el compositor sobre una GPU de software.

Es el MISMO muro que documenta el frente KDE («GBM/EGL de software; mesa es softpipe, falta llvmpipe
o virgl»), llegando por otro camino.

**No se puede verificar en este QEMU**: `qemu-system-x86_64 -display help` da sólo `none`, `gtk` y
`sdl`, y no existe `virtio-gpu-gl-pci` — este binario no se compiló con virgl, aunque
`libvirglrenderer.so.1` esté instalada en el host. Las dos salidas reales son:

1. **Metal**, donde `iris_dri.so` (que YA está en la imagen) habla con la Intel de verdad. Es el
   viaje de [[metal-dual-desktop-en-curso]] y probaría ScreenCast de paso.
2. **Un QEMU con virgl** (`-device virtio-gpu-gl-pci -display egl-headless`), que hoy hay que
   construir o instalar aparte.

Hasta entonces, lo honesto es que ScreenCast está **verificado hasta donde el entorno permite**: la
cadena completa de D-Bus funciona, el stream se crea y se negocia, y el último tramo —los píxeles—
depende de una GPU que esta VM no tiene.

### Por qué es una receta propia y no la de GNOME

La de `incoming-gnome` está sellada y funciona, pero su binario NEEDea `libglib-2.0.so.0`,
`libgobject-2.0.so.0` y `libpipewire-0.3.so.0` **de la isla dinámica de GNOME**: traerla tal cual
metería una segunda glib y una segunda pipewire en la imagen. De las 19 deps, **14 dan hash idéntico**
y las que divergen divergen a propósito — sobre todo `pipewire`, que acá apunta a la de COSMIC
(`338d1d8c`), la que el escritorio realmente arranca. Un gestor de sesión contra otra pipewire que la
que corre no gestiona nada.

**El cambio de fondo es `glib` → `glib-shared`.** COSMIC usa la glib ESTÁTICA del corpus, que le
alcanza a `xdg-desktop-portal` porque es un ejecutable. Acá no: wireplumber produce
`libwireplumber-0.5.so` y 17 módulos, y `libglib-2.0.a` del corpus tiene **44.275 reubicaciones
`R_X86_64_32/32S`** ⇒ no es PIC y no entra en un `.so`. (Regla del frente GNOME: `readelf -r` ANTES de
gastar el build — acá ahorró uno.)

La salida no fue una campaña nueva sino una **medición**: `incoming-kde/glib-shared.toml` copiada a
esta cola resuelve al **mismo hash** (`0584ce1f`) y **ya estaba sellada** ⇒ cero rebuild. Idem
`pcre2-shared` (`f254abaa`), `lua` (`e6c35f98`) y `libelogind` (`f1a690b2`). Las dos glib **coexisten
sin pisarse** en el rootfs: `.a` y `.so` son ficheros distintos, y el portal conserva sus 3 NEEDED.

Cierre: `escritorio-cosmic` pasa de 84 a **89 recetas**, 0 faltantes.

### El `-L/usr/lib` que pkg-config no da

`pkg-config` **omite los `-L` de directorios que considera estándar del sistema**, asumiendo que el
linker ya los busca. Cierto para un `cc` nativo; **falso para `zig cc` cruzando a
`x86_64-linux-musl`**, que trae sus propias rutas. Resultado: `--libs --static` devuelve `-ldbus-1`
pelado y zig responde `unable to find static system library 'dbus-1' … searched paths: none` — un
error que suena a «falta la librería» cuando lo que falta es la ruta.

**Y de paso se corrigió un comentario desactualizado de `cosmic-start`** que decía que
`cosmic-settings-daemon` «todavía no se puede construir acá (arrastra pipewire)». Es falso hace
tiempo: ese daemon está sellado y corre. Un comentario viejo manda a buscar un bloqueo que ya no
existe, y eso cuesta igual que un bug.

## 🔩 EL VIAJE A METAL: qué hacer con el USB en la mano (2026-08-05)

`scripts/cosmic/metal-desktop-image.sh` → `work/hammer-cosmic-metal.img`, quemada a `/dev/sda`.
**No es «lo mismo pero en metal»**: es la única forma de saber si ScreenCast está bien, porque el
`mesa` del corpus se construye con `-Dgallium-drivers=iris` y en QEMU ese driver nunca se usaba.

**Validado en OVMF ANTES de quemar, y con PANTALLA** —la regla de oro que costó un USB en el primer
viaje de KDE—, con `-serial null` para simular un metal sin puerto serie: `arje-zero` PID 1, las
cuatro particiones montadas, el motd VISIBLE y el prompt `/ #` en pantalla (o sea que el getty de
`tty1` hace su trabajo). El shell responde: `iris_dri.so` presente, `/dev/dri/card0`, y
`cosmic-start` / `portal-probe` / `wireplumber` en PATH.
Evidencia: `docs/evidencia/cosmic-metal-boot-pantalla-2026-08-05.png`.

### La secuencia en la máquina física, en orden

```sh
ls /dev/dri                       # card0 + renderD128 ⇐ renderD128 es la novedad: NO existe con swrast
dmesg | grep -iE 'i915|drm'       # que i915 haya hecho modeset, no simpledrm
cosmic-start                      # el MISMO script que corre en QEMU
# …y desde cosmic-term, dentro de la sesión:
portal-probe version              # ScreenCast v5
portal-probe screencast           # ⇐ LO QUE VINIMOS A VER
```

**El veredicto es el `[4/4] OpenPipeWireRemote` con su fd**, no que el diálogo aparezca: eso ya
pasaba en QEMU. Si `Start` devuelve `Response: 0` y la sonda imprime `✓✓ HAY REMOTO DE PIPEWIRE`, el
frente cierra. Si vuelve a colgarse en `Start`, mirar `/tmp/cosmic-session.log`: si **ya no** aparece
`Erroneous EGL call`, entonces la causa era otra y el diagnóstico de hoy estaba incompleto.

### 🩺 1er viaje físico: se congelaba en el USB, y NO estaba colgado (2026-08-05)

Booteando en **otra máquina**, la pantalla llegaba hasta esto y se quedaba ahí:

```
GPT: Use Parted to correct GPT errors      ← inofensivo: el backup GPT no está al final del USB
sdc: sdc1 sdc2 sdc3 sdc4
sd 6:0:0:0: [sdc] Attached SCSI removable disk
```

**La prueba de qué pasaba es una AUSENCIA**: con `ignore_loglevel` en el cmdline, montar una ext4
imprime `EXT4-fs (sdc2): mounted filesystem` en pantalla sí o sí. No aparecía ⇒ el root nunca se
montó ⇒ el fallo estaba en el initramfs de pivote, no en el kernel ni en el escritorio.

Dos causas encadenadas, y la segunda es la que hizo perder el viaje:

1. **CARRERA contra la enumeración USB.** El `rdinit` arranca apenas se desempaqueta el initramfs,
   pero el bus USB enumera **asíncrono** y tarda segundos (reset de hub, *settling* de 1 s por
   dispositivo, scan SCSI). El pivote reintentaba `findfs LABEL=hammer-root` 5 veces con 1 s. En esa
   máquina el disco es `sd 6:0:0:0` —séptimo host SCSI— y no llegó a tiempo. **En QEMU el disco es
   virtio y está desde el instante cero, así que esta carrera NO SE PUEDE VER en validación**: hay
   que arrancar la imagen como `usb-storage` para reproducirla. Es el `rootwait` que no podemos usar,
   porque el descubrimiento es por LABEL y no hay `root=` en el cmdline. Ahora espera **60 s**.

2. **CEGUERA.** El cmdline horneado es `console=tty0 console=ttyS0,115200 …` y el kernel hace
   `/dev/console` = la **última** `console=` ⇒ el serial. El `exec sh` de rescate del pivote nacía en
   un puerto serie que esa máquina no tiene: pantalla muerta, indistinguible de un kernel colgado.
   **Es el MISMO bug que costó el 1er viaje de KDE, una capa más temprano**: allá se arregló para
   *después* del `switch_root` (el `tty1-getty`) y se dio el caso por cerrado, pero el initramfs
   seguía ciego. Ahora el log del pivote y el marcador `HAMMER-EFI-INIT-OK` van también a
   **`/dev/tty0`**, y el rescate abre un shell en **tty1** listando los bloques visibles.

**La lección general** (vale para toda imagen booteable, no sólo COSMIC): *cada* capa del arranque
tiene que escribir a `/dev/tty0`, no al `console` heredado. Arreglar una sola capa deja el agujero
tapado y la siguiente vuelve a caer.

Reproducir el escenario de metal sin salir del laptop:

```sh
qemu-system-x86_64 -m 4096 -enable-kvm -cpu host -display none -vga std -serial null \
  -drive if=pflash,unit=0,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
  -drive if=pflash,unit=1,format=raw,file=work/.efi-vars-usbtest.fd \
  -drive if=none,id=usbstick,format=raw,file=work/hammer-cosmic-metal.img \
  -device qemu-xhci,id=xhci -device usb-storage,bus=xhci.0,drive=usbstick \
  -qmp unix:/tmp/qmp.sock,server,nowait     # el veredicto es el screendump, no el log
```

De paso: el rebuild avisa `! sin hammer musl` ⇒ **el CLI `hammer` no está en la imagen** y
`hammer boot menu` nunca corre. Igual lleva `timeout 15`, porque corre ANTES del `exec` de arje-zero
y colgarse ahí deja un arranque sin PID1 ni pantalla — el `|| true` protege del fallo, no del bloqueo.

### 🖥 2º viaje: la imagen forzaba SOFTWARE — no podía ganar ni en el mejor caso (2026-08-05)

Máquina: **Dell OptiPlex 3060, Coffee Lake, UHD 630**. Arrancó entero (arje-zero PID 1, hammerd,
sshd escuchando) y `cosmic-start` no levantó el escritorio.

**La GPU estaba perfecta.** Del `dmesg` sacado del propio USB:

```
[2.570] i915 0000:00:02.0: [drm] Found coffeelake (device ID 3e92) integrated display version 9.00
[3.179] [drm] Initialized i915 1.6.0 for 0000:00:02.0 on minor 0
[3.875] fbcon: i915drmfb (fb0) is primary device
[3.949] Console: switching to colour frame buffer device 240x67
```

El `Failed to load DMC firmware i915/kbl_dmc_ver1_04.bin` es un *warning*: el propio kernel dice que
sólo desactiva gestión de energía. No era la causa (se arregló igual, es gratis).

**La causa era el script de arranque.** `cosmic-start-qemu.sh` exportaba, sin condición:

```sh
export LIBGL_ALWAYS_SOFTWARE=1
export MESA_LOADER_DRIVER_OVERRIDE=kms_swrast
```

Correcto sobre virtio-gpu. Letal sobre Intel real: la mesa del corpus va con
`-Dgallium-drivers=iris -Dllvm=disabled` y **no existe ningún `kms_swrast_dri.so`** ⇒ el override no
degrada a software, hace fallar EGL. **Y en el mejor caso habría sido peor**: si hubiera arrancado,
habría compuesto por software y ScreenCast habría fallado por el mismo motivo que en QEMU — o sea que
**el viaje no podía contestar la pregunta que lo motivaba**.

El origen fue un comentario en `metal-desktop-image.sh` que afirmaba que el script *«no tiene nada
específico del emulador»*. El nombre del fichero decía lo contrario y se ignoró.
**Regla: un artefacto que se comparte entre QEMU y metal no puede llevar el entorno hardcodeado —
se detecta.** Hoy el modo de GL se decide con dos patas (driver DRM del kernel **y** `.so` de mesa
presente) y la imagen falla el build si el script vuelve a forzar `kms_swrast`.

**Y correr `cosmic-start` sobre la imagen de metal por PRIMERA VEZ destapó lo de fondo**: le faltaba
entera la preparación de sesión que sólo tenía `qemu-desktop-image.sh`. Sin grupo `video` no arranca
`seatd`; sin usuario `messagebus` no arranca el **bus de sistema**; y **sin bus de sistema no hay
portal**, o sea que `portal-probe screencast` no habría tenido con quién hablar ni con el GL perfecto.
La imagen de metal se armó copiando el *arranque* de la de QEMU pero no su *preparación*.

**Lección de método**: validar que una imagen «llega al prompt» no valida nada del escritorio. Hay que
correr `cosmic-start` en la imagen que se va a quemar, aunque el GL no se pueda ejercer en QEMU: los
fallos de sesión (seatd, dbus, logind) sí se ven, y son los que cuestan los viajes.

### Qué mirar si la pantalla queda negra

Es el fallo que ya nos pasó una vez y tiene una causa aburrida. En orden de probabilidad:

1. **No es un cuelgue, es el prompt invisible.** Ya está arreglado (`tty1-getty` + el log del pivote
   a `tty0`), pero si vuelve: los printk van a las dos consolas y el userspace a una sola.
2. **`ls /usr/lib/dri`** — si sólo hay `iris_dri.so` y la GPU no es Intel, no hay GL. Esta imagen es
   Intel-only a propósito; para otra máquina va la imagen dual de KDE.
3. **`cosmic-comp` sin DRM master**: `dmesg | grep -i drm` y `ls -l /dev/dri/card0`. Sin `seatd` o
   sin el shim de logind el compositor no toma el master.

### Gotchas del quemado, medidos

- **NUNCA a un NVMe**, y **nunca por nombre**: los nombres cambian entre arranques — hoy mismo,
  después del reboot, `nvme0n1` y `nvme1n1` se intercambiaron respecto de lo anotado en julio.
  Identificar por `lsblk -dpo NAME,SIZE,TYPE,TRAN,MODEL` y confirmar `TRAN=usb`.
- **El progreso NO se mide con `kill -0`**: `sudo dd` corre como root, así que desde el usuario da
  EPERM y parece que terminó. Se mide con `awk '{print $7}' /sys/block/sda/stat` (sectores escritos).
- **No correr `os-prober` sobre el device que estás dd-eando**: monta la partición con `grub-probe`
  mientras dd la sobreescribe y el dd cae a KB/s.
- **`pkill -f <patrón>` mata su propio shell** si el patrón aparece en su cmdline (exit 144). Pasó
  otra vez hoy. Usar `pgrep -x` o un patrón que no se auto-matchee.

## Lo que falta, en orden

1. **ScreenCast: en el USB, esperando el viaje físico.** pipewire ✅, cliente persistente ✅
   (`portal-probe`, `b3:af49d32d`), `wireplumber` ✅ (`b3:b8f3baf0`) — y **ninguno era el bloqueo**.
   La causa es **EGL por software en el compositor** (`kms_swrast`, sin dmabuf), medida por
   correlación de 8 ms entre el `Erroneous EGL call` de smithay y el `Paused` del backend. No es
   verificable en QEMU (sin virgl), así que **la imagen de metal con `iris` ya está construida,
   validada en OVMF y quemada** — ver la sección del viaje. Falta bootearla (acción física) y correr
   `portal-probe screencast`. Y `portal-probe filechooser`, que ya puede capturar la URI.
2. **Decidir `gvfs`** (ver la corrección de arriba: las piezas ya están selladas, lo que falta es la
   decisión sobre la segunda glib). Destrabaría los montajes remotos de cosmic-files y su applet.
3. **Enchufar la tienda a `.swm`**: servir `org.freedesktop.PackageKit` y encender la feature
   `packagekit`, que es Rust puro. Mismo patrón que `arje-logind-compat`.
4. `cosmic-greeter` (hoy `cosmic-session` lo lanza y falla con ENOENT, sin consecuencias) y
   `cosmic-osd` con teclas de brillo/volumen reales.
5. Metal: la imagen de QEMU y la de metal comparten todo menos el driver; el viaje físico es el
   mismo que el de `metal-dual-desktop-en-curso`.
