Appearance
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:
| Alternativa | Razón de rechazo |
|---|---|
| Agente Electron propio | Costo de desarrollo alto, mantener compatibilidad con APIs de impresora de cada SO, sin comunidad de soporte |
| Extensión de navegador | Capacidades limitadas para acceso a impresoras, dependencia de APIs experimentales, instalación por usuario por navegador |
| API nativa del navegador | window.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-trayen 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:
| Alternativa | Razó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.crtelimina 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:
- Generar par de claves RSA 2048-bit con OpenSSL (una vez)
- Clave privada → servidor backend (env var
QZ_PRIVATE_KEY_PATH, nunca en repo) - Certificado público → servido via
GET /backend/qz/certificate - Mismo certificado → desplegado como
override.crten 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.crten cada máquina (una vez) - Rotación de claves requiere redistribuir
override.crta 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:
| Alternativa | Razón de rechazo |
|---|---|
| Event bus | Complejidad adicional, debugging difícil, acoplamiento temporal |
| Dynamic import de TS desde JS | Incompatible con el bundling actual; el servicio necesita estado compartido |
| Instancias per-componente | No mantienen estado de conexión WebSocket compartido |
Razones de la decisión:
- Patrón existente: El codebase ya usa
window.URL_APP,window.Swaly 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:
PrintProvidersetea 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.jsse 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 impresoraImplementación backend:
GET /backend/qz/certificate— sirve el certificado PEM (sin autenticación*; es dato público)POST /backend/qz/sign— firma conopenssl_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.crta 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 Tray | Comportamiento del sistema |
|---|---|
| Instalado y corriendo | Botón "Imprimir Directo" visible; impresión silenciosa disponible |
| Instalado pero detenido | Botón oculto; flujo existente intacto |
| No instalado | Botón oculto; flujo existente intacto |
| Conexión perdida mid-session | Toast de error; botón se oculta; flujo existente disponible |
Mecanismo:
PrintService.isConnected() === false→ botón "Imprimir Directo" no se renderizausePrinter().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:
- Remover
PrintProviderdemain.tsx window.__printServicequedaundefinedpreview-pdf.jsno renderiza botón (guard?.isConnected())- 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
| Riesgo | Probabilidad | Impacto | Mitigación |
|---|---|---|---|
| Proxy/firewall corporativo bloquea WebSocket localhost | Baja | Media | wss://localhost no se proxea típicamente. Fallback a impresión por navegador siempre disponible. |
| QZ Tray requiere Java (~80MB JRE bundled) | Media | Baja | JRE incluido en instalador. Opción USB para internet lento. |
Redistribución de override.crt ante rotación de claves | Media | Media | Guía paso a paso en español. Rotación infrecuente (certificado válido 10 años). |
| Proyecto QZ Tray discontinuado | Baja | Alta | Open-source (fork posible), 10+ años de historia, alternativas podrían evaluarse. |
| Exposición de clave privada RSA | Baja | Alta | Env var, .gitignore, nunca en repo. Gestión estándar de secretos. |
Referencias
- QZ Tray — Sitio oficial
- qz-tray npm package
- QZ Tray Wiki — Signing
- Proposal interno:
openspec/changes/qz-tray-direct-printing/proposal.md - Design interno:
openspec/changes/qz-tray-direct-printing/design.md
Historial de Cambios
| Fecha | Versión | Autor | Descripción |
|---|---|---|---|
| 2026-03-16 | 1.0 | Sistema | Creación del documento. Consolidación de 6 decisiones arquitectónicas para integración QZ Tray. |