Files
message_bus/reports/0002-2026-06-06-frontend-web-movil.md
T
egutierrez 57a1602e8f chore: auto-commit (1 archivos)
- reports/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 11:42:32 +02:00

7.1 KiB

Report 0002 — Cliente humano de unibus: SPA web + app Android nativa

  • Fecha: 06/06/2026
  • Autor: agente (Claude) — frente frontend
  • Ámbito: mobile/unibus.go, playground/server.go (gateway), web/ (SPA nueva), android/ (app nueva)
  • Estado: done (con gaps documentados)

Resumen

Se construyó el cliente humano de unibus en dos frentes, ambos verificados end-to-end:

  1. Web: SPA de chat (React + Vite + TypeScript + Mantine v9) contra el gateway Go (playground/server.go), que monta un peer real por identidad y emite los mensajes recibidos por SSE. Dos identidades crean una room cifrada E2E, una invita a la otra y ambas chatean en vivo.
  2. Móvil: app Android nativa (Kotlin + Jetpack Compose) sobre el binding gomobile mobile/unibus.go. El cifrado y el transporte NATS corren en el dispositivo (cada teléfono es un peer real del bus). La app genera identidad, conecta al membershipd, crea/se une a una room y envía/recibe mensajes por el bus.

No se reimplementó protocolo ni criptografía en JS ni en Kotlin: todo delega en pkg/client a través del binding y del gateway.

Cambios

Archivo / dir Qué Por qué
mobile/unibus.go Añadidos Card() (exporta la identidad pública del peer como JSON portable), Invite(roomID, peerCard) y Kick(roomID, endpointID) al binding. La UI móvil necesita invitar/expulsar; Card() permite el intercambio de identidad peer-a-peer (paste/QR) sin un gateway.
playground/server.go Añadidos GET /api/rooms?peer= (rooms del peer), GET /api/members?room_id= (proxy al control plane) y middleware withCORS (preflight + headers) para el dev server de Vite. La SPA necesita listar rooms/miembros y llamar al gateway desde otro origen.
web/ SPA nueva: conexión (gateway URL + identidad), navbar (crear/unir/listar rooms), panel central (mensajes en vivo por SSE + composer), panel lateral (miembros, invitar por peer, expulsar si owner). Mantine v9, @tabler/icons-react, sin Tailwind ni CSS manual. Cliente web de chat.
android/ App Kotlin + Compose: pantalla de conexión (Host + NATS + identidad), BusViewModel que dirige el binding (llamadas de red en Dispatchers.IO, frames entrantes vía StateFlow), pantalla de chat (crear/unir room, enviar, recibir). .aar generado con gomobile bind. Cliente móvil con E2E en el dispositivo.

Verificación (evidencia ejecutable)

Builds

# Binding + gateway (Go, sin CGO)
$ CGO_ENABLED=0 go build ./mobile/ ./playground/   → OK
$ CGO_ENABLED=0 go vet  ./mobile/ ./playground/    → OK

# .aar gomobile
$ gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \
    -o android/app/libs/unibus.aar ./mobile        → unibus.aar (24 MB)

# SPA
$ cd web && pnpm build
  ✓ tsc -b && vite build  →  6949 modules transformed, dist/ generado  (exit 0)

# APK
$ cd android && ./gradlew assembleDebug
  BUILD SUCCESSFUL in 1m13s
  → app/build/outputs/apk/debug/app-debug.apk  (41 MB, incluye libgojni.so)

Web — flujo cifrado E2E por curl (determinista)

Gateway (go run ./playground, web :7700) + dos peers ana/leo:

1. POST /api/peer ana → endpoint cc9f8Gm_RQZ76lX2I6C0ZepmiC82AG19M_ajiaDW4P8
   POST /api/peer leo → endpoint yT7GWz97tRcCx3i4st43d4LcGC85Tl4PDZenycUaQ_I
2. POST /api/room {peer:ana, encrypt:true} → room_id 01KTEVZEE7B7AQAD72WSDHH3Y3
3. POST /api/invite {peer:ana, target:leo} → {"status":"invited"}
4. POST /api/join  {peer:leo}              → {"encrypt":true,"subject":"room.general"}
5. SSE leo + POST /api/publish ana "hola leo, mensaje cifrado E2E":
   leo recibe → data: {"sender":"cc9f8...","text":"hola leo, mensaje cifrado E2E","encrypted":true}
6. GET /api/rooms?peer=ana   → [{"encrypt":true,"room_id":"01KTEVZEE...","subject":"room.general"}]
7. GET /api/members?room_id  → [{ana,role:owner}, {leo,role:member}]

Web — UI con dos pestañas (SPA real, pnpm preview :4173)

  • Pestaña ana y pestaña leo conectadas como identidades distintas a la room cifrada.
  • leo → ana: "hola ana, soy leo desde la SPA" (18:31:05) llegó en vivo por SSE a ana.
  • ana → leo: "¡recibido leo! ana responde cifrado" (18:32:28), bucle bidireccional.
  • Panel de miembros: ana OWNER + leo; botón expulsar visible solo para el owner; selector de invitación con los peers conectados.

Móvil — AVD Pixel_API34 (emulador headless)

$ adb install -r app-debug.apk          → Success
$ adb shell am start -n com.unibus.app/.MainActivity
  → proceso vivo, sin FATAL/AndroidRuntime, libgojni.so cargada (lib/x86_64)
  → pantalla "Conectado como android"   (newSession contra membershipd 10.0.2.2:8470/4250)
  → "Room creada · 01KTEWTZ8TB1NYEGNNR7F3MY5G"
  → enviado "peer movil real en el bus" → burbuja recibida con hora 18:39:35

La burbuja con timestamp prueba el camino completo en el dispositivo: el código no añade el mensaje localmente al publicar; solo lo pinta FrameListener.onFrame, así que su aparición demuestra que el frame viajó por NATS y volvió al peer. Captura en reports/assets/unibus_movil.png.

Gaps / pendientes

  • Media (PublishMedia/FetchMedia): el pkg/client ya la soporta, pero NO está expuesta en el binding móvil ni en la SPA. Requiere que FrameListener.onFrame señale frames con Blob (hoy entrega solo texto). Pendiente para v2.
  • ListMembers en el binding móvil: necesita un método público en pkg/client (ListMembers(roomID)); hoy es privado (signerPub lo usa internamente). Dependencia del CORE, no implementada aquí por contrato (no tocar pkg/). La SPA sí lista miembros porque el gateway hace proxy al control plane.
  • Threading/reply/reaction: el otro agente añadió ThreadID/ReplyTo/tipo REACT al Frame (ya en master). Ni la SPA ni la app móvil los usan todavía — gap de UI, no de backend.
  • Invite/Kick en la UI móvil: el binding ya expone Card()/Invite()/Kick(), pero la app Kotlin v1 no tiene aún la pantalla de intercambio de "card" (paste/QR) ni el botón de expulsar. El chat cleartext y E2E self-echo funcionan; el flujo de dos teléfonos invitándose queda para v2.
  • Auth del gateway web: el gateway identifica peers por nombre (modelo del playground), sin autenticación de sesión. Suficiente para uso local/LAN; endurecer es fase posterior (igual que el control plane, que en v1 no tiene TLS ni auth en GETs).
  • .aar no versionado: es un artefacto de ~24 MB; se regenera con gomobile bind (ver android/README.md). Gitignored.

Notas

  • Web: cd web && pnpm install && pnpm dev (o pnpm preview tras pnpm build); conectar a http://localhost:7700 (gateway: go run ./playground).
  • Móvil: ver android/README.md para generar el .aar, compilar el APK y probar en el AVD. Desde el emulador, el host del PC es 10.0.2.2; desde un teléfono físico, la IP LAN del PC.
  • Mantine v9 exige React 19 (peerDependency ^19.2.0); con React 18 la SPA compila pero no monta en runtime (s is not a function). Fijado React 19 en web/package.json.
  • pnpm 10 bloquea los build scripts: web/pnpm-workspace.yaml con allowBuilds: { esbuild: true }.