gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

🔌 portal-probe: la sonda del portal XDG, en C sobre libdbus

Los portales NO son RPC: son un protocolo asíncrono en dos tiempos. Cada método
devuelve al instante un object path de `Request`, y el resultado llega DESPUÉS como
señal `Response` sobre ese path. `dbus-send` ya salió para entonces — por eso la URI
del FileChooser nunca se pudo capturar desde el shell.

Y hay una razón más dura: el portal ata la sesión al SENDER. CreateSession con un
dbus-send y SelectSources con otro llegan de nombres únicos distintos y el segundo se
rechaza. Hace falta UNA conexión viva durante todo el intercambio.

El path del Request se PREDICE (/…/request/<sender>/<token>, sender = nombre único
sin ':' y con '.'→'_'). Eso existe a propósito: permite suscribirse ANTES de llamar.
Esperar al path devuelto para recién ahí hacer AddMatch es una carrera real — el
portal puede haber emitido ya la señal.

En C y no con ashpd: el árbol fuente de esta receta es el repo de la propia
herramienta de build, así que ashpd significaría sumar ~150 crates al Cargo.lock de
hammer por una sonda de diagnóstico. libdbus ya está en la imagen y en el corpus
(y con `libdbus-1.a`, así que la sonda sale ESTÁTICA, sin un solo NEEDED). Sobre
todo: acá el protocolo se VE. Una sonda cuyo valor es documentar un handshake no
debería esconderlo tras una biblioteca que lo abstrae.

Cuatro modos: `version` (qué interfaces sirve y en qué versión), `screencast` (el
handshake de 4 pasos hasta el fd de pipewire), `filechooser` (captura las uris) y
`screenshot`. El impresor de valores es recursivo y NO filtra: si el portal devuelve
un campo que este programa no conoce, sale igual por pantalla — que es exactamente lo
que uno quiere de una sonda.

Validado en el host contra el portal real: compila sin warnings y lee las versiones
(Screenshot v2, FileChooser v4, Settings v2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

6ec556decc2d037a3e942d02c9d25ccf3e46fc36 · por sergio · (hace 2 meses) · padre 28dc473ac · ver el árbol en este commit

Delta semántico · 16

tools/portal-probe/portal-probe.c

1 archivos · +446 −0

diff --git a/tools/portal-probe/portal-probe.c b/tools/portal-probe/portal-probe.c
new file mode 100644
index 00000000..2b843525
--- /dev/null
+++ b/tools/portal-probe/portal-probe.c
@@ -0,0 +1,446 @@
+/* portal-probe — sonda del XDG Desktop Portal: ejerce el handshake COMPLETO y lo IMPRIME.
+ *
+ * ── POR QUÉ EXISTE, Y POR QUÉ NO ES UN `dbus-send` ──────────────────────────────────────────────
+ * Los portales no son llamadas RPC: son un protocolo ASÍNCRONO en dos tiempos. Cada método devuelve
+ * al instante un object path de `Request`, y la respuesta de verdad llega DESPUÉS como señal
+ * `org.freedesktop.portal.Request.Response` sobre ese path. `dbus-send` ya salió para entonces, así
+ * que devuelve el path y nunca el resultado — por eso la URI del FileChooser no se pudo capturar
+ * nunca desde el shell.
+ *
+ * Y hay una segunda razón, más dura: el portal ata la sesión al **sender**. `CreateSession` con un
+ * `dbus-send` y `SelectSources` con otro llegan desde nombres únicos DISTINTOS y el portal rechaza
+ * el segundo. Hace falta UNA conexión que viva durante todo el intercambio. Eso es este programa.
+ *
+ * ── POR QUÉ EN C Y NO CON `ashpd` ───────────────────────────────────────────────────────────────
+ * `ashpd` es el cliente de portales del ecosistema Rust y es lo que usan cosmic-screenshot y
+ * cosmic-files. Meterlo acá significaría sumar ~150 crates al `Cargo.lock` del propio hammer (el
+ * árbol fuente de esta receta es el repo de la herramienta de build) para una sonda de diagnóstico.
+ * `libdbus` ya está en la imagen y en el corpus, cuesta cero deps nuevas… y sobre todo: acá el
+ * protocolo se ve. Una sonda cuyo valor es DOCUMENTAR un handshake no debería esconderlo tras una
+ * biblioteca que lo abstrae.
+ *
+ * ── EL PATH DEL REQUEST SE PUEDE PREDECIR, Y HAY QUE ESCUCHAR ANTES DE LLAMAR ────────────────────
+ * La especificación fija el path: /org/freedesktop/portal/desktop/request/<SENDER>/<TOKEN>, donde
+ * SENDER es el nombre único propio sin el ':' inicial y con los '.' pasados a '_'. Existe a
+ * propósito: permite suscribirse ANTES de emitir la llamada. Si uno espera a que el método devuelva
+ * el path para recién ahí hacer `AddMatch`, el portal puede haber emitido ya la señal y se pierde
+ * para siempre. Es una carrera real, no teórica.
+ *
+ * Uso:  portal-probe screencast [--multiple] [--window]
+ *       portal-probe filechooser [--save]
+ *       portal-probe screenshot
+ *       portal-probe version           (qué interfaces sirve el portal, y en qué versión)
+ */
+
+#include <dbus/dbus.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define PORTAL_BUS   "org.freedesktop.portal.Desktop"
+#define PORTAL_PATH  "/org/freedesktop/portal/desktop"
+#define IFACE_REQ    "org.freedesktop.portal.Request"
+
+static int  g_token = 0;
+static char g_sender[256];
+
+/* ── helpers de escritura: a{sv} a mano, que es lo que todo método de portal pide ─────────────── */
+
+static void dict_entry(DBusMessageIter *arr, const char *key, int type,
+                       const char *sig, const void *val)
+{
+    DBusMessageIter ent, var;
+    dbus_message_iter_open_container(arr, DBUS_TYPE_DICT_ENTRY, NULL, &ent);
+    dbus_message_iter_append_basic(&ent, DBUS_TYPE_STRING, &key);
+    dbus_message_iter_open_container(&ent, DBUS_TYPE_VARIANT, sig, &var);
+    dbus_message_iter_append_basic(&var, type, val);
+    dbus_message_iter_close_container(&ent, &var);
+    dbus_message_iter_close_container(arr, &ent);
+}
+
+static void opt_str(DBusMessageIter *a, const char *k, const char *v)
+{ dict_entry(a, k, DBUS_TYPE_STRING, "s", &v); }
+
+static void opt_u32(DBusMessageIter *a, const char *k, dbus_uint32_t v)
+{ dict_entry(a, k, DBUS_TYPE_UINT32, "u", &v); }
+
+static void opt_bool(DBusMessageIter *a, const char *k, dbus_bool_t v)
+{ dict_entry(a, k, DBUS_TYPE_BOOLEAN, "b", &v); }
+
+/* Token único por llamada. El portal lo usa para construir el path del Request, así que dos
+ * llamadas con el mismo token colisionarían sus señales. */
+static void next_token(char *out, size_t n) { snprintf(out, n, "hammer%d", ++g_token); }
+
+static void request_path(char *out, size_t n, const char *token)
+{ snprintf(out, n, "%s/request/%s/%s", PORTAL_PATH, g_sender, token); }
+
+/* ── impresión recursiva de cualquier valor D-Bus ─────────────────────────────────────────────── */
+/* El valor de la sonda está acá: no filtra lo que muestra. Si el portal devuelve un campo que este
+ * programa no conoce, igual sale por pantalla — que es exactamente lo que uno quiere de una sonda. */
+
+static void print_iter(DBusMessageIter *it, int depth)
+{
+    for (;;) {
+        int t = dbus_message_iter_get_arg_type(it);
+        if (t == DBUS_TYPE_INVALID) return;
+        for (int i = 0; i < depth; i++) fputs("  ", stdout);
+
+        switch (t) {
+        case DBUS_TYPE_STRING: case DBUS_TYPE_OBJECT_PATH: case DBUS_TYPE_SIGNATURE: {
+            const char *s; dbus_message_iter_get_basic(it, &s);
+            printf("\"%s\"\n", s); break;
+        }
+        case DBUS_TYPE_UINT32: { dbus_uint32_t v; dbus_message_iter_get_basic(it, &v); printf("%u\n", v); break; }
+        case DBUS_TYPE_INT32:  { dbus_int32_t  v; dbus_message_iter_get_basic(it, &v); printf("%d\n", v); break; }
+        case DBUS_TYPE_UINT64: { dbus_uint64_t v; dbus_message_iter_get_basic(it, &v); printf("%llu\n", (unsigned long long)v); break; }
+        case DBUS_TYPE_BOOLEAN:{ dbus_bool_t   v; dbus_message_iter_get_basic(it, &v); printf("%s\n", v ? "true" : "false"); break; }
+        case DBUS_TYPE_DOUBLE: { double        v; dbus_message_iter_get_basic(it, &v); printf("%f\n", v); break; }
+        case DBUS_TYPE_UNIX_FD:{ dbus_int32_t  v; dbus_message_iter_get_basic(it, &v); printf("<fd %d>\n", v); break; }
+        case DBUS_TYPE_VARIANT: {
+            DBusMessageIter sub; dbus_message_iter_recurse(it, &sub);
+            printf("variant:\n"); print_iter(&sub, depth + 1); break;
+        }
+        case DBUS_TYPE_ARRAY: case DBUS_TYPE_STRUCT: case DBUS_TYPE_DICT_ENTRY: {
+            DBusMessageIter sub; dbus_message_iter_recurse(it, &sub);
+            printf("%s\n", t == DBUS_TYPE_ARRAY ? "[" : "(");
+            print_iter(&sub, depth + 1);
+            for (int i = 0; i < depth; i++) fputs("  ", stdout);
+            printf("%s\n", t == DBUS_TYPE_ARRAY ? "]" : ")");
+            break;
+        }
+        default: printf("<tipo '%c'>\n", (char)t);
+        }
+        if (!dbus_message_iter_next(it)) return;
+    }
+}
+
+/* ── esperar el Response de un Request ────────────────────────────────────────────────────────── */
+/* Devuelve el código de respuesta (0 ok, 1 cancelado, 2 otro) o -1 si venció el plazo. Si
+ * `session_out` no es NULL, copia ahí el `session_handle` de los results cuando aparece. */
+
+static int wait_response(DBusConnection *bus, const char *path, int timeout_s,
+                         char *session_out, size_t session_n)
+{
+    printf("   ← esperando Response en %s (hasta %ds)\n", path, timeout_s);
+    for (int elapsed = 0; elapsed < timeout_s * 10; elapsed++) {
+        dbus_connection_read_write(bus, 100);
+        DBusMessage *msg;
+        while ((msg = dbus_connection_pop_message(bus))) {
+            if (dbus_message_is_signal(msg, IFACE_REQ, "Response") &&
+                !strcmp(dbus_message_get_path(msg), path)) {
+                DBusMessageIter it;
+                dbus_message_iter_init(msg, &it);
+                dbus_uint32_t code = 2;
+                dbus_message_iter_get_basic(&it, &code);
+                printf("   ← Response: %u (%s)\n", code,
+                       code == 0 ? "OK" : code == 1 ? "CANCELADO por el usuario" : "otro error");
+                dbus_message_iter_next(&it);
+
+                /* Buscar session_handle antes de imprimir, si nos lo pidieron. */
+                if (session_out) {
+                    DBusMessageIter arr;
+                    dbus_message_iter_recurse(&it, &arr);
+                    while (dbus_message_iter_get_arg_type(&arr) == DBUS_TYPE_DICT_ENTRY) {
+                        DBusMessageIter ent, var; const char *k;
+                        dbus_message_iter_recurse(&arr, &ent);
+                        dbus_message_iter_get_basic(&ent, &k);
+                        dbus_message_iter_next(&ent);
+                        dbus_message_iter_recurse(&ent, &var);
+                        if (!strcmp(k, "session_handle") &&
+                            dbus_message_iter_get_arg_type(&var) == DBUS_TYPE_STRING) {
+                            const char *s; dbus_message_iter_get_basic(&var, &s);
+                            snprintf(session_out, session_n, "%s", s);
+                        }
+                        dbus_message_iter_next(&arr);
+                    }
+                }
+                printf("   ← results:\n");
+                print_iter(&it, 3);
+                dbus_message_unref(msg);
+                return (int)code;
+            }
+            dbus_message_unref(msg);
+        }
+    }
+    printf("   !! sin Response en %ds — el portal aceptó la llamada y no contestó\n", timeout_s);
+    return -1;
+}
+
+/* ── llamada bloqueante que devuelve el object path del Request ───────────────────────────────── */
+
+static int call_request(DBusConnection *bus, DBusMessage *m, const char *what)
+{
+    DBusError err; dbus_error_init(&err);
+    DBusMessage *r = dbus_connection_send_with_reply_and_block(bus, m, 10000, &err);
+    dbus_message_unref(m);
+    if (!r) {
+        printf("   !! %s FALLÓ: %s: %s\n", what, err.name ? err.name : "?",
+               err.message ? err.message : "?");
+        dbus_error_free(&err);
+        return 0;
+    }
+    const char *p = NULL;
+    if (dbus_message_get_args(r, &err, DBUS_TYPE_OBJECT_PATH, &p, DBUS_TYPE_INVALID))
+        printf("   → %s aceptado, Request = %s\n", what, p);
+    else
+        printf("   → %s aceptado (sin object path en la respuesta)\n", what);
+    dbus_message_unref(r);
+    return 1;
+}
+
+static DBusMessage *new_call(const char *iface, const char *method)
+{ return dbus_message_new_method_call(PORTAL_BUS, PORTAL_PATH, iface, method); }
+
+static void subscribe(DBusConnection *bus, const char *path)
+{
+    char rule[512];
+    snprintf(rule, sizeof rule,
+             "type='signal',interface='%s',member='Response',path='%s'", IFACE_REQ, path);
+    DBusError err; dbus_error_init(&err);
+    dbus_bus_add_match(bus, rule, &err);
+    dbus_connection_flush(bus);
+    if (dbus_error_is_set(&err)) {
+        printf("   !! AddMatch falló: %s\n", err.message);
+        dbus_error_free(&err);
+    }
+}
+
+/* ── la propiedad `version` de una interfaz del portal ────────────────────────────────────────── */
+
+static void probe_version(DBusConnection *bus, const char *iface)
+{
+    DBusMessage *m = dbus_message_new_method_call(PORTAL_BUS, PORTAL_PATH,
+                                                  "org.freedesktop.DBus.Properties", "Get");
+    const char *prop = "version";
+    dbus_message_append_args(m, DBUS_TYPE_STRING, &iface, DBUS_TYPE_STRING, &prop, DBUS_TYPE_INVALID);
+    DBusError err; dbus_error_init(&err);
+    DBusMessage *r = dbus_connection_send_with_reply_and_block(bus, m, 5000, &err);
+    dbus_message_unref(m);
+    if (!r) {
+        printf("  %-46s AUSENTE (%s)\n", iface, err.name ? err.name : "?");
+        dbus_error_free(&err);
+        return;
+    }
+    DBusMessageIter it, var; dbus_uint32_t v = 0;
+    if (dbus_message_iter_init(r, &it) &&
+        dbus_message_iter_get_arg_type(&it) == DBUS_TYPE_VARIANT) {
+        dbus_message_iter_recurse(&it, &var);
+        dbus_message_iter_get_basic(&var, &v);
+    }
+    printf("  %-46s v%u\n", iface, v);
+    dbus_message_unref(r);
+}
+
+/* ── ScreenCast: el handshake de CUATRO pasos ─────────────────────────────────────────────────── */
+
+static int do_screencast(DBusConnection *bus, int multiple, int window)
+{
+    const char *IF = "org.freedesktop.portal.ScreenCast";
+    char tok[64], path[512], session[256] = {0};
+
+    /* 1. CreateSession — el único paso que además crea un objeto Session que sobrevive a la Request */
+    puts("\n[1/4] CreateSession");
+    next_token(tok, sizeof tok);
+    request_path(path, sizeof path, tok);
+    subscribe(bus, path);
+    {
+        DBusMessage *m = new_call(IF, "CreateSession");
+        DBusMessageIter it, arr;
+        dbus_message_iter_init_append(m, &it);
+        dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "{sv}", &arr);
+        opt_str(&arr, "handle_token", tok);
+        opt_str(&arr, "session_handle_token", "hammersess");
+        dbus_message_iter_close_container(&it, &arr);
+        if (!call_request(bus, m, "CreateSession")) return 1;
+    }
+    if (wait_response(bus, path, 15, session, sizeof session) != 0) return 1;
+    if (!session[0]) { puts("   !! sin session_handle en los results"); return 1; }
+    printf("   ✓ session_handle = %s\n", session);
+
+    /* 2. SelectSources — qué se va a capturar. `types` es un bitfield: 1=monitor, 2=ventana. */
+    puts("\n[2/4] SelectSources");
+    next_token(tok, sizeof tok);
+    request_path(path, sizeof path, tok);
+    subscribe(bus, path);
+    {
+        DBusMessage *m = new_call(IF, "SelectSources");
+        DBusMessageIter it, arr;
+        dbus_message_iter_init_append(m, &it);
+        const char *sp = session;
+        dbus_message_iter_append_basic(&it, DBUS_TYPE_OBJECT_PATH, &sp);
+        dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "{sv}", &arr);
+        opt_str(&arr, "handle_token", tok);
+        opt_u32(&arr, "types", window ? 2 : 1);
+        opt_bool(&arr, "multiple", multiple ? TRUE : FALSE);
+        opt_u32(&arr, "cursor_mode", 2);   /* 1=oculto 2=embebido 4=metadatos */
+        dbus_message_iter_close_container(&it, &arr);
+        if (!call_request(bus, m, "SelectSources")) return 1;
+    }
+    if (wait_response(bus, path, 15, NULL, 0) != 0) return 1;
+
+    /* 3. Start — acá es donde el backend abre su UI de elección y, al aceptar, NEGOCIA EL NODO
+     *    contra el demonio de pipewire. Es el paso que no podía funcionar sin demonio corriendo. */
+    puts("\n[3/4] Start  (el backend abre su selector; los `streams` traen el node id)");
+    next_token(tok, sizeof tok);
+    request_path(path, sizeof path, tok);
+    subscribe(bus, path);
+    {
+        DBusMessage *m = new_call(IF, "Start");
+        DBusMessageIter it, arr;
+        dbus_message_iter_init_append(m, &it);
+        const char *sp = session, *parent = "";
+        dbus_message_iter_append_basic(&it, DBUS_TYPE_OBJECT_PATH, &sp);
+        dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &parent);
+        dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "{sv}", &arr);
+        opt_str(&arr, "handle_token", tok);
+        dbus_message_iter_close_container(&it, &arr);
+        if (!call_request(bus, m, "Start")) return 1;
+    }
+    if (wait_response(bus, path, 60, NULL, 0) != 0) return 1;
+
+    /* 4. OpenPipeWireRemote — devuelve un FD, no un path. Es la prueba final de que hay demonio:
+     *    sin pipewire corriendo, el backend no tiene remoto que abrir. */
+    puts("\n[4/4] OpenPipeWireRemote  (devuelve un fd al demonio de pipewire)");
+    {
+        DBusMessage *m = new_call(IF, "OpenPipeWireRemote");
+        DBusMessageIter it, arr;
+        dbus_message_iter_init_append(m, &it);
+        const char *sp = session;
+        dbus_message_iter_append_basic(&it, DBUS_TYPE_OBJECT_PATH, &sp);
+        dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "{sv}", &arr);
+        dbus_message_iter_close_container(&it, &arr);
+        DBusError err; dbus_error_init(&err);
+        DBusMessage *r = dbus_connection_send_with_reply_and_block(bus, m, 15000, &err);
+        dbus_message_unref(m);
+        if (!r) {
+            printf("   !! OpenPipeWireRemote FALLÓ: %s: %s\n",
+                   err.name ? err.name : "?", err.message ? err.message : "?");
+            dbus_error_free(&err);
+            return 1;
+        }
+        int fd = -1;
+        if (dbus_message_get_args(r, &err, DBUS_TYPE_UNIX_FD, &fd, DBUS_TYPE_INVALID)) {
+            /* Que el fd sea >= 0 no basta: el kernel siempre da un número. Lo que prueba que es un
+             * remoto vivo es que sea un SOCKET abierto, así que se le pregunta al propio fd. */
+            char link[256] = {0}, proc[64];
+            snprintf(proc, sizeof proc, "/proc/self/fd/%d", fd);
+            ssize_t n = readlink(proc, link, sizeof link - 1);
+            printf("   ✓ fd = %d  →  %s\n", fd, n > 0 ? link : "(sin readlink)");
+            puts("   ✓✓ HAY REMOTO DE PIPEWIRE: el stream se puede consumir desde este fd");
+            close(fd);
+        } else {
+            printf("   !! la respuesta no traía un fd: %s\n", err.message ? err.message : "?");
+            dbus_error_free(&err);
+            dbus_message_unref(r);
+            return 1;
+        }
+        dbus_message_unref(r);
+    }
+    return 0;
+}
+
+/* ── FileChooser: un solo paso, pero el resultado SÓLO llega por la señal ─────────────────────── */
+
+static int do_filechooser(DBusConnection *bus, int save)
+{
+    const char *IF = "org.freedesktop.portal.FileChooser";
+    char tok[64], path[512];
+    next_token(tok, sizeof tok);
+    request_path(path, sizeof path, tok);
+    subscribe(bus, path);
+
+    printf("\n[1/1] %s  (elegí algo en el diálogo; las `uris` llegan por la señal)\n",
+           save ? "SaveFile" : "OpenFile");
+    DBusMessage *m = new_call(IF, save ? "SaveFile" : "OpenFile");
+    DBusMessageIter it, arr;
+    dbus_message_iter_init_append(m, &it);
+    const char *parent = "", *title = "portal-probe";
+    dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &parent);
+    dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &title);
+    dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "{sv}", &arr);
+    opt_str(&arr, "handle_token", tok);
+    if (save) opt_str(&arr, "current_name", "portal-probe.txt");
+    dbus_message_iter_close_container(&it, &arr);
+    if (!call_request(bus, m, save ? "SaveFile" : "OpenFile")) return 1;
+
+    /* 120s: hay un humano (o un click por QMP) del otro lado. */
+    return wait_response(bus, path, 120, NULL, 0) == 0 ? 0 : 1;
+}
+
+static int do_screenshot(DBusConnection *bus)
+{
+    const char *IF = "org.freedesktop.portal.Screenshot";
+    char tok[64], path[512];
+    next_token(tok, sizeof tok);
+    request_path(path, sizeof path, tok);
+    subscribe(bus, path);
+
+    puts("\n[1/1] Screenshot");
+    DBusMessage *m = new_call(IF, "Screenshot");
+    DBusMessageIter it, arr;
+    dbus_message_iter_init_append(m, &it);
+    const char *parent = "";
+    dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &parent);
+    dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "{sv}", &arr);
+    opt_str(&arr, "handle_token", tok);
+    opt_bool(&arr, "interactive", FALSE);
+    dbus_message_iter_close_container(&it, &arr);
+    if (!call_request(bus, m, "Screenshot")) return 1;
+    return wait_response(bus, path, 60, NULL, 0) == 0 ? 0 : 1;
+}
+
+int main(int argc, char **argv)
+{
+    const char *cmd = argc > 1 ? argv[1] : "version";
+    int multiple = 0, window = 0, save = 0;
+    for (int i = 2; i < argc; i++) {
+        if (!strcmp(argv[i], "--multiple")) multiple = 1;
+        else if (!strcmp(argv[i], "--window")) window = 1;
+        else if (!strcmp(argv[i], "--save")) save = 1;
+    }
+
+    DBusError err; dbus_error_init(&err);
+    DBusConnection *bus = dbus_bus_get(DBUS_BUS_SESSION, &err);
+    if (!bus) {
+        fprintf(stderr, "sin bus de sesión: %s\n", err.message ? err.message : "?");
+        return 2;
+    }
+    /* Que este proceso NO muera cuando el bus se desconecte: queremos ver el error, no un exit(1)
+     * silencioso de libdbus. */
+    dbus_connection_set_exit_on_disconnect(bus, FALSE);
+
+    /* El sender sanitizado: nombre único sin ':' y con '.' → '_'. Es lo que el portal usa para
+     * construir el path del Request, y calcularlo nosotros es lo que permite escuchar ANTES. */
+    const char *uniq = dbus_bus_get_unique_name(bus);
+    snprintf(g_sender, sizeof g_sender, "%s", uniq && uniq[0] == ':' ? uniq + 1 : uniq);
+    for (char *p = g_sender; *p; p++) if (*p == '.') *p = '_';
+    printf("bus de sesión OK · nombre único %s · sender para el path del Request: %s\n",
+           uniq ? uniq : "?", g_sender);
+
+    if (!dbus_bus_name_has_owner(bus, PORTAL_BUS, &err)) {
+        printf("!! nadie posee %s — no hay frontend de portal en el bus\n", PORTAL_BUS);
+        return 3;
+    }
+    puts("portal presente en el bus\n");
+
+    int rc;
+    if (!strcmp(cmd, "version")) {
+        puts("interfaces del portal:");
+        probe_version(bus, "org.freedesktop.portal.ScreenCast");
+        probe_version(bus, "org.freedesktop.portal.Screenshot");
+        probe_version(bus, "org.freedesktop.portal.FileChooser");
+        probe_version(bus, "org.freedesktop.portal.Settings");
+        probe_version(bus, "org.freedesktop.portal.Access");
+        probe_version(bus, "org.freedesktop.portal.RemoteDesktop");
+        rc = 0;
+    } else if (!strcmp(cmd, "screencast"))   rc = do_screencast(bus, multiple, window);
+    else if (!strcmp(cmd, "filechooser"))    rc = do_filechooser(bus, save);
+    else if (!strcmp(cmd, "screenshot"))     rc = do_screenshot(bus);
+    else {
+        fprintf(stderr, "uso: portal-probe {version|screencast|filechooser|screenshot} [--multiple|--window|--save]\n");
+        rc = 2;
+    }
+    printf("\n== portal-probe %s: %s ==\n", cmd, rc == 0 ? "OK" : "FALLÓ");
+    return rc;
+}