en debian

Empaquetar el shell GTK4 de Stremio: aventuras de integración con CEF

Nueve días depurando una sola llamada a una función, y otras lecciones de la integración con un motor de navegador

Introducción

Cuando Stremio publicó su nuevo shell basado en GTK4 usando Chromium Embedded Framework (CEF) en lugar de Qt5/QtWebEngine, proporcionó el caso de prueba perfecto para mi camino en el empaquetado de Debian, sobre todo después de que QT5 se haya convertido en software con el ciclo de vida terminado que pronto dejará de tener soporte.

Era el buen caso de uso para probar nuestra librería CEF recién empaquetada. Lo que siguió fue un mes de depuración que reveló la brecha entre «la librería compila» y «la librería funciona».

Este artículo documenta los retos técnicos de integrar CEF con una aplicación del mundo real para el empaquetado en Debian.

La arquitectura de la aplicación

Stremio-gtk es una aplicación relativamente simple en concepto:

  1. Crear una ventana de navegador CEF
  2. Cargar https://app.strem.io/shell-v4.4
  3. Proporcionar un puente de IPC para que la aplicación web controle las funciones nativas
  4. Gestionar la reproducción de vídeo a través de MPV

En la práctica, cada paso reveló complejidad oculta.

Reto 1: descubrimiento de los ficheros de recursos

El problema

Los procesos de CEF necesitan localizar varios ficheros de recursos al arrancar:

  • icudtl.dat – Datos de internacionalización de ICU
  • v8_context_snapshot.bin – Instantánea del motor JavaScript V8
  • Ficheros *.pak – Paquetes de recursos de Chromium
  • locales/*.pak – Datos de localización

En las compilaciones del fuente original, estos ficheros están junto al ejecutable. En las instalaciones que cumplen el FHS, están repartidos por /usr/share/cef/.

La solución obvia es configurar las rutas en la estructura Settings:

let settings = Settings {
    resources_dir_path: "/usr/share/cef".into(),
    locales_dir_path: "/usr/share/cef/locales".into(),
    ..Default::default()
};
cef_initialize(&settings);

Esto funciona para el proceso del navegador. Los subprocesos fallaban de inmediato.

La causa raíz

La arquitectura multiproceso de CEF lanza subprocesos especializados para el renderizado, las operaciones de GPU y las funciones de utilidad. Estos subprocesos llaman a cef_execute_process() como primera acción y salen sin llegar nunca a ver la estructura Settings.

El punto de entrada del subproceso:

fn main() {
    // For subprocesses, this returns immediately with exit code
    let exit_code = cef_execute_process(&args, None, None);
    if exit_code >= 0 {
        std::process::exit(exit_code);
    }

    // Only browser process reaches here
    let settings = Settings { ... };
    cef_initialize(&settings);
}

Cuando Settings se podía aplicar, los subprocesos ya habían fallado al buscar los recursos.

Solución

CEF busca los recursos de forma relativa a libcef.so, no al binario de la aplicación. Unos enlaces simbólicos en el directorio de librerías resuelven el problema:

# In /usr/lib/x86_64-linux-gnu/:
icudtl.dat -> ../../share/cef/icudtl.dat
v8_context_snapshot.bin -> ../../share/cef/v8_context_snapshot.bin

Para la configuración que los subprocesos necesitan, hay que añadir opciones de línea de comandos en el callback on_before_command_line_processing, que se llama para todos los tipos de proceso:

impl App for StremioApp {
    fn on_before_command_line_processing(
        &self,
        _process_type: &str,
        command_line: &mut CommandLine,
    ) {
        command_line.append_switch_with_value(
            "resources-dir-path",
            "/usr/share/cef"
        );
    }
}

Reto 2: fallos del proceso de GPU

El problema

Con los recursos cargando correctamente, el subproceso de GPU ya arrancaba, y fallaba de inmediato. CEF lo reintentó nueve veces antes de rendirse:

[GPU] error_code=1002
GPU process isn't usable. Goodbye.

Sin trazas de pila, sin registros útiles. El proceso de GPU moría antes de producir diagnósticos.

Investigación

Añadir --enable-logging --v=1 reveló que el proceso de GPU fallaba al inicializar los contextos OpenGL. El error sugería problemas con las librerías EGL/GLES.

El proceso de GPU de CEF espera ANGLE (Almost Native Graphics Layer Engine), la implementación de OpenGL ES de Google construida sobre Vulkan y DirectX. No es intercambiable con la implementación EGL de Mesa a pesar de que las APIs se parecen.

Solución

El paquete de CEF debe incluir sus propias librerías ANGLE:

/usr/lib/x86_64-linux-gnu/cef/
├── libEGL.so.1        # CEF's ANGLE-based EGL
├── libGLESv2.so.2     # CEF's ANGLE-based GLES
└── libvk_swiftshader.so  # Software Vulkan fallback

La librería principal se configura con RPATH para encontrar estas librerías privadas antes que el Mesa del sistema.

Para los sistemas sin aceleración de GPU, SwiftShader proporciona renderizado por software. Su registro de ICD de Vulkan requería rutas absolutas:

{
    "file_format_version": "1.0.0",
    "ICD": {
        "library_path": "/usr/lib/x86_64-linux-gnu/cef/libvk_swiftshader.so",
        "api_version": "1.1.0"
    }
}

Las rutas relativas fallaban porque el cargador del ICD no resuelve las rutas de forma relativa al fichero JSON.

Reto 3: la depuración de IPC de nueve días

El síntoma

CEF arrancaba, las páginas cargaban, la interfaz se renderizaba, pero la reproducción de vídeo caía en HTML5 en lugar del reproductor nativo MPV. La aplicación web trataba stremio-gtk como un navegador en lugar de como un shell nativo.

El protocolo Qt WebChannel

La aplicación web de Stremio espera comunicarse con su shell nativo a través del protocolo WebChannel de Qt. El shell Qt5 original crea un objeto transport:

// Qt5 shell (QML):
WebChannel {
    id: webChannel
    registeredObjects: [transport]
}

property QtObject transport: QtObject {
    property string shellVersion: "4.4"
    property string serverAddress: "http://127.0.0.1:11470"
    property bool isFullscreen: false

    signal event(string event, string args)
    function send(data) { ... }
}

La aplicación web accede a esto a través de:

new QWebChannel(qt.webChannelTransport, function(channel) {
    window.transport = channel.objects.transport;
    transport.event.connect(function(name, args) { ... });
});

Callejones sin salida

Días 1-2: «IPC onmessage is not a function»

La aplicación web intentaba llamar a un callback antes del registro. Añadí una cola de mensajes:

struct MessageQueue {
    pending: Vec<String>,
    handler: Option<Box<dyn Fn(String)>>,
}

impl MessageQueue {
    fn send(&mut self, msg: String) {
        match &self.handler {
            Some(h) => h(msg),
            None => self.pending.push(msg),
        }
    }

    fn set_handler(&mut self, h: Box<dyn Fn(String)>) {
        for msg in self.pending.drain(..) {
            h(msg);
        }
        self.handler = Some(h);
    }
}

Los mensajes dejaron de perderse, pero el vídeo seguía sin funcionar.

Días 3-4: formato de mensaje equivocado

QWebChannel espera formatos de array específicos:

// Wrong (what we sent):
{ type: 1, object: "transport", data: { properties: {} } }

// Correct (QWebChannel format):
{ type: 1, object: "transport", data: [
    { }, // signals
    { }, // methods
    { "shellVersion": [1, "4.4"], "serverAddress": [1, "http://..."] }
]}

Las propiedades son tuplas [type, value]. Arreglé la serialización, pero el vídeo seguía sin funcionar.

Día 5: doble serialización JSON

Un bug en el puente de Rust a JavaScript:

// Bug: data was already JSON, gets serialized again
let message = serde_json::to_string(&json!({
    "type": 3,
    "data": already_json_string  // Becomes "\"escaped\"" 
}))?;

Arreglado, pero el vídeo seguía sin funcionar.

Día 6: tipo de respuesta equivocado

Las respuestas de init usaban type: 3 cuando QWebChannel esperaba type: 10:

// Our response:
{ type: 3, id: 1, data: [...] }

// Expected:
{ type: 10, id: 1, data: [...] }

Arreglado, pero el vídeo seguía sin funcionar.

Días 7-8: detección del User-Agent

Examinar el fuente de la aplicación web reveló una comprobación temprana:

if (navigator.userAgent.indexOf('StremioShell') === -1) {
    // Use HTML5 player
    return;
}
// Use native MPV

Añadí «StremioShell» al user agent. El vídeo seguía sin funcionar.

Día 9: la llamada a la función que faltaba

Desesperado, leí el main.qml del shell Qt5 línea por línea:

function injectJS() {
    var injectedJS = "try { initShellComm() } catch(e) { console.error(e) }"
    webView.runJavaScript(injectedJS, function(err) {
        if (err) console.error("Injection failed:", err);
    });
}

Connections {
    target: webView
    onLoadingChanged: {
        if (loadRequest.status === WebEngineLoadRequest.LoadSucceededStatus) {
            injectJS();
        }
    }
}

El shell nativo debe llamar explícitamente a window.initShellComm() después de la carga de la página. La aplicación web define esta función pero nunca la llama automáticamente.

Una línea en el script de preload:

window.addEventListener('load', () => {
    if (typeof initShellComm === 'function') {
        initShellComm();
    }
});

La reproducción de vídeo funcionó.

Lección

Nueve días depurando formatos de IPC, serialización y tipos de mensaje. La corrección real fue una llamada a una función que el shell Qt5 hacía explícitamente. La documentación existía, en código QML que había que leer línea por línea.

Reto 4: renderizado de widgets nativos

El problema

Después de que el IPC funcionara, quedaba un problema menor pero visible: los desplegables <select> de HTML no funcionaban. Hacer clic en ellos no hacía nada.

La causa raíz

El modo de renderizado fuera de pantalla de CEF (el que usa stremio-gtk) renderiza todo a un búfer mediante OnPaint. Los widgets nativos del sistema operativo, como los menús desplegables, requieren manejadores de ventana reales.

CEF proporciona callbacks para el renderizado de ventanas emergentes:

trait RenderHandler {
    fn on_popup_show(&self, browser: &Browser, show: bool);
    fn on_popup_size(&self, browser: &Browser, rect: &Rect);
}

Las aplicaciones deben implementarlos para renderizar ellas mismas las representaciones de los widgets nativos. Stremio-gtk no los había implementado.

Solución pragmática

En lugar de implementar el renderizado completo de ventanas emergentes, la inyección de JavaScript reemplaza los elementos <select> por desplegables propios basados en div:

function replaceSelect(select) {
    const wrapper = document.createElement('div');
    wrapper.className = 'custom-select-wrapper';

    const display = document.createElement('div');
    display.className = 'custom-select-display';
    display.textContent = select.options[select.selectedIndex]?.text || '';

    const dropdown = document.createElement('div');
    dropdown.className = 'custom-select-dropdown';

    Array.from(select.options).forEach((opt, i) => {
        const item = document.createElement('div');
        item.textContent = opt.text;
        item.onclick = () => {
            select.selectedIndex = i;
            select.dispatchEvent(new Event('change'));
            display.textContent = opt.text;
            dropdown.style.display = 'none';
        };
        dropdown.appendChild(item);
    });

    display.onclick = () => {
        dropdown.style.display = 
            dropdown.style.display === 'none' ? 'block' : 'none';
    };

    select.style.display = 'none';
    select.parentNode.insertBefore(wrapper, select);
    wrapper.appendChild(display);
    wrapper.appendChild(dropdown);
}

// Handle dynamic selects
new MutationObserver((mutations) => {
    mutations.forEach(m => {
        m.addedNodes.forEach(node => {
            if (node.tagName === 'SELECT') replaceSelect(node);
            if (node.querySelectorAll) {
                node.querySelectorAll('select').forEach(replaceSelect);
            }
        });
    });
}).observe(document.body, { childList: true, subtree: true });

document.querySelectorAll('select').forEach(replaceSelect);

Los elementos <select> nativos ocultos sincronizan el estado para la compatibilidad con AngularJS.

El recuento final de parches

Después de toda la depuración, stremio-gtk necesitó 24 parches:

CategoríaCantidadEjemplos
Sistema de compilación4Cargo vendor, rutas del sistema
Integración con CEF6Rutas de recursos, ANGLE, zygote
Protocolo de IPC8Objeto transport, formato JSON, initShellComm
Correcciones en tiempo de ejecución4User agent, señales de eventos
Apaños de interfaz2Reemplazo de select, manejo del foco

Observaciones sobre las pruebas

Lo que las pruebas unitarias no detectan

El paquete de CEF pasó todas sus pruebas. Stremio-gtk reveló:

  1. Se necesitaban enlaces simbólicos de recursos en ubicaciones inesperadas
  2. ANGLE es obligatorio, no opcional
  3. La inicialización de los subprocesos difiere de la del proceso principal
  4. Los detalles del protocolo de IPC importan para las aplicaciones reales

El valor de las pruebas de integración

Una aplicación exigente como stremio-gtk (vídeo, IPC, GPU, renderizado fuera de pantalla) estresa funciones que las pruebas simples de «cargar una página web» no tocan nunca.

Descubrimiento de bugs en el fuente original

Existen varios problemas en el stremio-linux-shell del fuente original. El Flatpak probablemente funciona por accidente del empaquetado o de los tiempos. Estas correcciones deberían volver al fuente original.

Conclusión

El empaquetado de stremio-gtk reveló que integrar CEF es más que enlazar contra una librería. La arquitectura multiproceso, el descubrimiento de recursos, los requisitos de GPU y los protocolos de IPC requieren un manejo cuidadoso para el empaquetado en distribuciones.

La depuración de IPC de nueve días produjo una línea de código real. Los otros 23 parches requirieron entender internos que no están documentados en ninguna parte salvo en el código fuente de las implementaciones que funcionan.

Para los empaquetadores que consideren aplicaciones CEF: espera tener que cerrar la brecha entre las suposiciones del fuente original y los requisitos de la distribución. Lee la implementación Qt5 si existe. Prueba pronto y prueba a fondo.


Paquetes en salsa.debian.org. ITP #1119815 (stremio-gtk), resuelve #915400 (CEF).

Etiquetas

  • #debian
  • #qt5
  • #selfhosted
  • #stremio
  • #stremio-gtk
  • #stremio-qt5