Skip to content

Integración QZ Tray para Impresión Directa

Tipo: Arquitectural Alcance: Frontend (bautista-app), Backend (bautista-backend), Infraestructura cliente Estado: Propuesto Fecha: 2026-03-16


Descripción General

Este documento registra las decisiones arquitectónicas para integrar impresión directa silenciosa al Sistema Bautista mediante QZ Tray, una aplicación open-source que actúa como puente entre el navegador y las impresoras locales vía WebSocket.

Problema

Los usuarios deben abrir PDFs en una pestaña nueva y usar Ctrl+P manualmente para imprimir informes. Para usuarios de alto volumen (cajeros, administración) que imprimen decenas o cientos de reportes diarios, esta fricción es significativa.

Solución

Agregar un botón "Imprimir Directo" en el modal de vista previa PDF que envía el documento directamente a la impresora seleccionada del usuario, sin diálogos intermedios. La funcionalidad es puramente aditiva: no modifica ningún flujo existente y es invisible cuando QZ Tray no está instalado.


Decisiones Arquitectónicas

DA-1: QZ Tray como agente de impresión (vs. agente propio)

Decisión: Usar QZ Tray (open-source, LGPL) como agente de impresión local en lugar de desarrollar una solución propia.

Contexto: El sistema necesita comunicarse con impresoras locales desde el navegador. Las APIs de impresión nativas del navegador (window.print()) no permiten impresión silenciosa ni selección de impresora programática.

Alternativas rechazadas:

AlternativaRazón de rechazo
Agente Electron propioCosto de desarrollo alto, mantener compatibilidad con APIs de impresora de cada SO, sin comunidad de soporte
Extensión de navegadorCapacidades limitadas para acceso a impresoras, dependencia de APIs experimentales, instalación por usuario por navegador
API nativa del navegadorwindow.print() siempre muestra diálogo, no permite selección de impresora ni configuración programática

Razones de la decisión:

  • Madurez: 10+ años de desarrollo activo, comunidad establecida
  • Cobertura de SO: Windows, macOS, Linux con APIs nativas de cada plataforma
  • SDK JavaScript: Paquete qz-tray en npm con API bien documentada
  • Licencia: LGPL — uso gratuito con certificado auto-firmado
  • Mantenimiento: Comunidad open-source activa; el equipo no asume mantenimiento del agente

Consecuencias positivas:

  • Cero costo de desarrollo y mantenimiento del agente de impresión
  • Soporte inmediato para cualquier impresora reconocida por el SO
  • Actualizaciones del agente independientes del sistema

Consecuencias negativas:

  • Dependencia de un proyecto externo (mitigado por ser open-source y maduro)
  • Requiere Java Runtime (~80MB bundled en el instalador)
  • Requiere instalación manual en cada máquina cliente

DA-2: Certificado auto-firmado con override.crt (vs. licencia paga)

Decisión: Usar un certificado RSA 2048-bit auto-firmado desplegado como override.crt en el directorio de instalación de QZ Tray, en lugar de adquirir una licencia comercial.

Contexto: QZ Tray requiere verificación de certificado para autorizar comunicación WebSocket. Sin certificado válido, muestra diálogos de "Allow/Deny" en cada conexión. Existen tres opciones: licencia paga (~$490/año), certificado auto-firmado con override, o sin certificado.

Alternativas rechazadas:

AlternativaRazón de rechazo
Licencia comercial QZ ($490/año)Costo recurrente innecesario; override.crt logra el mismo resultado (cero diálogos)
Sin certificado (unsigned)Diálogos "Allow/Deny" en cada conexión — inaceptable para uso de producción

Razones de la decisión:

  • override.crt elimina todos los diálogos de QZ Tray a costo cero
  • El certificado se despliega una sola vez por máquina (C:\Program Files\QZ Tray\override.crt)
  • La licencia comercial no agrega funcionalidad adicional relevante para este caso de uso

Flujo de certificación:

  1. Generar par de claves RSA 2048-bit con OpenSSL (una vez)
  2. Clave privada → servidor backend (env var QZ_PRIVATE_KEY_PATH, nunca en repo)
  3. Certificado público → servido via GET /backend/qz/certificate
  4. Mismo certificado → desplegado como override.crt en cada máquina cliente

Consecuencias positivas:

  • Cero costo de licenciamiento
  • Cero diálogos de autorización para el usuario final
  • Control total sobre la infraestructura de certificados

Consecuencias negativas:

  • Requiere despliegue manual de override.crt en cada máquina (una vez)
  • Rotación de claves requiere redistribuir override.crt a todos los clientes
  • La clave privada debe protegerse como cualquier secreto del servidor

DA-3: Comunicación WebSocket localhost entre navegador y QZ Tray

Decisión: Comunicar el navegador con QZ Tray mediante WebSocket seguro (wss://localhost:8181), con conexión lazy y reconexión automática.

Contexto: QZ Tray expone un servidor WebSocket local en el puerto 8181. El navegador necesita un canal de comunicación bidireccional con la aplicación local.

Patrón de comunicación:

Browser (bautista-app)

    │  wss://localhost:8181

QZ Tray (aplicación local)

    │  APIs nativas del SO

Impresora (USB/Red/WiFi)

Estrategia de conexión: Lazy connect — la conexión se establece solo cuando el componente PrintProvider se monta por primera vez (cuando la UI de impresión se renderiza), no al inicio de la aplicación.

Razones de la decisión:

  • Usuarios sin QZ Tray no pagan ningún costo (sin intentos de conexión fallidos al boot)
  • WebSocket provee comunicación bidireccional necesaria para descubrimiento de impresoras y estado
  • Localhost no atraviesa proxies ni firewalls corporativos (tráfico local)
  • wss:// (con TLS via certificado auto-firmado) cumple requisitos de seguridad del navegador

Reconexión: Backoff exponencial (2s, 4s, 8s, máx 30s) ante desconexiones.

Consecuencias positivas:

  • Comunicación en tiempo real con la aplicación local
  • Descubrimiento automático de impresoras disponibles
  • Sin impacto en rendimiento para usuarios sin QZ Tray

Consecuencias negativas:

  • Puerto 8181 debe estar libre (conflicto teórico con otras aplicaciones)
  • Algunos navegadores pueden restringir conexiones WebSocket a localhost en futuras versiones (riesgo bajo; QZ Tray tiene 10+ años de compatibilidad)

DA-4: Puente Legacy JS ↔ React via window global

Decisión: Exponer PrintService como window.__printService (seteado por PrintProvider al montarse) para que código legacy JS pueda acceder a la funcionalidad de impresión gestionada por React.

Contexto: El modal preview-pdf.js es código vanilla JS (Bootstrap/jQuery) que recibe el blob PDF y muestra botones de acción. Este código no puede usar hooks de React ni acceder a Context. Necesita una forma de invocar la impresión directa.

Alternativas rechazadas:

AlternativaRazón de rechazo
Event busComplejidad adicional, debugging difícil, acoplamiento temporal
Dynamic import de TS desde JSIncompatible con el bundling actual; el servicio necesita estado compartido
Instancias per-componenteNo mantienen estado de conexión WebSocket compartido

Razones de la decisión:

  • Patrón existente: El codebase ya usa window.URL_APP, window.Swal y otros globals para comunicar legacy JS con módulos modernos
  • Simplicidad: if (window.__printService?.isConnected()) es directo e idiomatic
  • Cero configuración: 40+ archivos legacy pueden consumir el servicio sin cambios de bundling
  • Lifecycle correcto: PrintProvider setea el global al montarse y lo limpia al desmontarse

Patrón:

React (PrintProvider)                    Legacy JS (preview-pdf.js)
        │                                         │
        │── mount → window.__printService = svc   │
        │                                         │
        │                    window.__printService?.isConnected()
        │                    window.__printService?.print(blob)
        │                                         │
        │── unmount → window.__printService = null │

Consecuencias positivas:

  • Integración inmediata sin refactorizar código legacy
  • Consistente con patrones existentes del proyecto
  • El servicio singleton mantiene una única conexión WebSocket

Consecuencias negativas:

  • Polución del namespace global (aceptable dado el patrón ya establecido)
  • Deuda técnica: se eliminará cuando preview-pdf.js se migre a React
  • Sin type safety en el lado JS legacy (mitigado con optional chaining)

DA-5: Firmado RSA server-side para cada solicitud de impresión

Decisión: Cada solicitud de impresión requiere que QZ Tray obtenga una firma RSA SHA-512 del backend, verificada contra el certificado público.

Contexto: QZ Tray implementa un modelo de seguridad donde cada operación de impresión debe ser autorizada mediante firma criptográfica. Esto previene que sitios web arbitrarios usen QZ Tray para imprimir sin autorización.

Flujo de firmado:

1. Usuario clickea "Imprimir Directo"
2. PrintService → qz.print(config, data)
3. qz-tray JS internamente:
   a. GET /backend/qz/certificate → recibe certificado PEM público
   b. POST /backend/qz/sign {data: hash} → backend firma con clave privada RSA SHA-512
   c. QZ Tray verifica firma contra certificado
   d. Verificación exitosa → envía a impresora

Implementación backend:

  • GET /backend/qz/certificate — sirve el certificado PEM (sin autenticación*; es dato público)
  • POST /backend/qz/sign — firma con openssl_sign() SHA-512 (requiere autenticación JWT)

*Nota: el endpoint de certificado debe ser accesible sin JWT porque QZ Tray lo llama desde localhost fuera del contexto de sesión del navegador.

Gestión de claves:

  • Clave privada: archivo en servidor, referenciado por env var, en .gitignore
  • Certificado público: servido via endpoint, no contiene datos sensibles
  • Rotación: regenerar par, redistribuir override.crt a máquinas cliente

Consecuencias positivas:

  • Seguridad criptográfica: solo el backend autorizado puede firmar solicitudes
  • Modelo estándar de QZ Tray — bien documentado y probado
  • La clave privada nunca sale del servidor

Consecuencias negativas:

  • Cada impresión requiere un round-trip al servidor para firmado (latencia mínima en red local)
  • Si el servidor está caído, la impresión directa no funciona (pero el flujo legacy sí)

DA-6: Degradación graceful — funcionalidad puramente aditiva

Decisión: La integración con QZ Tray es completamente aditiva. Cuando QZ Tray no está instalado o no está corriendo, el sistema se comporta exactamente como antes, sin errores ni cambios visibles.

Contexto: No todos los usuarios necesitan o tendrán QZ Tray instalado. La adopción será gradual, máquina por máquina.

Comportamiento por estado:

Estado de QZ TrayComportamiento del sistema
Instalado y corriendoBotón "Imprimir Directo" visible; impresión silenciosa disponible
Instalado pero detenidoBotón oculto; flujo existente intacto
No instaladoBotón oculto; flujo existente intacto
Conexión perdida mid-sessionToast de error; botón se oculta; flujo existente disponible

Mecanismo:

  • PrintService.isConnected() === false → botón "Imprimir Directo" no se renderiza
  • usePrinter().isAvailable === false → componentes React no muestran opciones de impresión directa
  • Ningún error, warning o degradación visible para usuarios sin QZ Tray

Rollback:

  1. Remover PrintProvider de main.tsx
  2. window.__printService queda undefined
  3. preview-pdf.js no renderiza botón (guard ?.isConnected())
  4. Cero impacto en funcionalidad existente

Consecuencias positivas:

  • Adopción gradual sin riesgo de regresiones
  • Rollback trivial y sin pérdida de datos
  • No modifica ningún flujo de impresión existente
  • Testing simplificado: sin QZ Tray = comportamiento idéntico al actual

Consecuencias negativas:

  • Ninguna identificada. El carácter puramente aditivo es la principal fortaleza de este diseño.

Visión de Arquitectura

┌─────────────────────────────────────────────────────────────┐
│  Browser (bautista-app)                                     │
│                                                             │
│  ┌──────────────┐    ┌──────────────────┐                   │
│  │ preview-pdf  │───▶│  PrintService.ts │◀── usePrinter()   │
│  │ (legacy JS)  │    │  (singleton)     │◀── PrintProvider  │
│  └──────────────┘    └───────┬──────────┘                   │
│       window.__printService  │ WSS :8181                    │
│                              ▼                              │
│                     ┌────────────────┐                      │
│                     │   QZ Tray App  │──▶ Impresora local   │
│                     └────────┬───────┘                      │
│                              │ verificar cert + firma       │
└──────────────────────────────┼──────────────────────────────┘
                               │ HTTPS
                    ┌──────────▼───────────┐
                    │  bautista-backend     │
                    │  GET /qz/certificate  │
                    │  POST /qz/sign        │
                    │  CRUD /print-config   │
                    └──────────────────────┘

Riesgos

RiesgoProbabilidadImpactoMitigación
Proxy/firewall corporativo bloquea WebSocket localhostBajaMediawss://localhost no se proxea típicamente. Fallback a impresión por navegador siempre disponible.
QZ Tray requiere Java (~80MB JRE bundled)MediaBajaJRE incluido en instalador. Opción USB para internet lento.
Redistribución de override.crt ante rotación de clavesMediaMediaGuía paso a paso en español. Rotación infrecuente (certificado válido 10 años).
Proyecto QZ Tray discontinuadoBajaAltaOpen-source (fork posible), 10+ años de historia, alternativas podrían evaluarse.
Exposición de clave privada RSABajaAltaEnv var, .gitignore, nunca en repo. Gestión estándar de secretos.

Referencias


Historial de Cambios

FechaVersiónAutorDescripción
2026-03-161.0SistemaCreación del documento. Consolidación de 6 decisiones arquitectónicas para integración QZ Tray.