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:
- Crear una ventana de navegador CEF
- Cargar
https://app.strem.io/shell-v4.4 - Proporcionar un puente de IPC para que la aplicación web controle las funciones nativas
- 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 ICUv8_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ía | Cantidad | Ejemplos |
|---|---|---|
| Sistema de compilación | 4 | Cargo vendor, rutas del sistema |
| Integración con CEF | 6 | Rutas de recursos, ANGLE, zygote |
| Protocolo de IPC | 8 | Objeto transport, formato JSON, initShellComm |
| Correcciones en tiempo de ejecución | 4 | User agent, señales de eventos |
| Apaños de interfaz | 2 | Reemplazo 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ó:
- Se necesitaban enlaces simbólicos de recursos en ubicaciones inesperadas
- ANGLE es obligatorio, no opcional
- La inicialización de los subprocesos difiere de la del proceso principal
- 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).