Compare commits

..

118 commits

Author SHA1 Message Date
hacklab
d267d2befe bienvenida: paso Perfil (workflow) de un toque, Activismo primero; Ajustes con la misma lista; fuera Personal/Social de la barra de arriba 2026-10-19 16:00:31 +02:00
hacklab
92a8dc3853 docs: tipo de portada de un toque, en el pad 2026-10-19 15:39:58 +02:00
hacklab
3238520891 bienvenida y Ajustes: el tipo de portada se guarda al tocarlo, sin boton de guardar; en Ajustes va justo despues del idioma 2026-10-19 15:39:01 +02:00
hacklab
05027031ef docs: fuente del PDF de la guia para el dev 2026-10-19 15:06:58 +02:00
hacklab
d3e799e592 docs: pruebas en tamaños y sistemas (Android 9, 11 y 16), y WebView viejos en la guia 2026-10-19 15:06:09 +02:00
hacklab
9f8317baa3 menu: al ir a un modulo desde la hoja se quita antes su entrada del historial; volver atras ya no reabre la hoja 2026-10-19 15:04:38 +02:00
hacklab
9e2716f69b WebView viejos (Android 9-11): fichas sin gap, hoja del menu sin inset ni dvh, cabecera sin sticky y No molestar debajo de la hoja 2026-10-19 14:52:50 +02:00
hacklab
3f8839d9a8 tabletas: el limite de ancho no ganaba a otra regla; ahora si 2026-10-19 14:35:46 +02:00
hacklab
0a8935fe16 tamaños: tabletas centradas y el boton de No molestar fuera del menu
Probado en el emulador a 360x540, 412 (Pixel 9), 800dp (tableta) y con la
letra al 130%: sin desbordes. Arreglos:
- en pantallas de 700dp o mas, el contenido y la hoja del menu a 720/640px
  centrados; a 800dp todo se estiraba
- con el menu abierto se esconde el boton de No molestar, que se montaba
  encima de la X de la hoja
2026-10-19 14:34:26 +02:00
hacklab
1beb53568c menu: el boton atras de Android cierra la hoja
En el emulador, con la hoja abierta, atras cambiaba de pagina. Un script
minimo (public/js/mobile_menu.js, servido desde /js como permite la CSP)
apunta una entrada en el historial al abrirla y la quita al cerrarla; sin
el script el menu funciona igual. El estudio de navegacion recoge lo que
salio en la prueba.
2026-10-19 14:29:07 +02:00
hacklab
062d49c367 menu: todos los modulos caben en la hoja sin deslizar
En el emulador la hoja dejaba ver 690px y la rejilla medía 1.200: Tribus
quedaba fuera y hacia falta deslizar. Dentro de la hoja: cinco iconos por
fila y mas pequeños, y los grupos de uno o dos modulos de dos en dos. Ahora
caben los 20 del workflow acampada a 412 y a 360px. Con el menu abierto solo
la pestaña Menu lleva la pastilla.
2026-10-19 14:26:02 +02:00
hacklab
382d903bc5 navegacion: al abrir, la actividad; los modulos en la pestaña Menu
docs/ESTUDIO_NAVEGACION.md compara cuatro formas de moverse por la app
(lanzador en portada, boton de menu arriba, pestaña Menu abajo, hamburguesa)
y se queda con la pestaña de abajo, el patron Mas de Android y de las apps
grandes: al alcance del pulgar y en todas las pantallas.

- la portada es la actividad; el lanzador ya no se pinta alli
- quinta pestaña fija en la barra de abajo, Menu, que abre desde abajo una
  hoja con todos los modulos por secciones; se cierra con la X, tocando fuera
  o volviendo a tocar Menu. Sin JS: una casilla y CSS
- textos en los 11 idiomas; caben cinco pestañas a 360px
- capturas, portada y esquema de la interfaz al dia
2026-10-19 14:23:43 +02:00
hacklab
6eafe5ee18 coherencia con la guia: lo de movil del dev vuelve a ser suyo
Comparando con el nodejs-project de la APK oficial (la referencia buena para
el movil, no el git del dev) salia que parte de lo que se saco de backend.js
era codigo de movil del propio dev: apagar los modulos que no van, cortar
rutas, retocar el HTML, las descargas de la copia de seguridad y ocultar
tema y workflow en la bienvenida.

- backend.js vuelve a ser su version de movil mas dos lineas (montar nuestro
  router); modules_view.js, su version de movil
- mobile_layer.js se queda con lo nuestro: avatar, filtro y lanzador
- la guia lista las 17 diferencias que quedan con la APK oficial (561 lineas)
  y explica que hay que comparar con la APK, no con el git
2026-10-19 14:19:54 +02:00
hacklab
b65dde1263 salas: estando dentro, los controles arriba y en rejilla
Silenciar, camara, levantar la mano, salir... quedaban debajo de toda la
ficha de la sala, a 1.100px. Con la sala en directo el panel va primero, los
controles en rejilla de dos y Salir en rojo. Solo CSS.
2026-10-19 14:14:09 +02:00
hacklab
6bacafea64 android: la APK se monta sobre la oficial del dev
La app Android de epsylon trae desde la 1.3.x un puente nativo de audio y
camara (.audio_bridge) para el Telefono y las Salas, y no tiene el codigo
publicado. android/scripts/sobre_oficial.sh coge su APK de la misma version,
le cambia el backend por el de la rama (con su node_modules), le pone el
paquete net.laenre.oasis (y los authorities, el permiso propio y las acciones
de llamada) para que conviva con la oficial, y la monta y firma. apktool va
con version y huella fijas.

Probado en el emulador: el Telefono ofrece llamadas y en una sala el
microfono graba y se corta al salir. README, guia, android/README y los
esquemas lo cuentan; el proyecto Gradle propio queda sin uso.
2026-10-19 14:12:18 +02:00
hacklab
34265c2ed0 docs: tercera ronda de usabilidad, en un emulador limpio 2026-10-19 13:55:28 +02:00
hacklab
10e47f9bc1 llamadas: lo que hay de verdad; SSB_server.js como en la APK oficial
Revisado en el emulador: la APK oficial 1.3.3 del dev pide microfono y camara
y trae un puente nativo (.audio_bridge) que graba, reproduce, saca la camara y
emite. Nuestra app Android no lo tiene, asi que en esta APK el Telefono dice
que no hay llamadas.

- README, guia, android/README y los dos esquemas lo cuentan asi
- SSB_server.js queda identico al de la APK oficial (su version de movil
  enchufa ya phone_*_android.js); fuera el enganche duplicado de main.js
- el aviso de bienvenida va encima del lanzador: en el primer arranque
  quedaba a 1.300px y no se veia
2026-10-19 13:55:16 +02:00
hacklab
e6ea3f4b58 readme: capturas y dibujos nuevos, sin Karvan
- capturas de un nodo de prueba con contenido de acampada: lanzador, tribus,
  votacion de semaforo, modulos, ajustes, barra, salas y el tema del dev
- portada y esquema de la interfaz al dia; dos esquemas nuevos: como va la
  rama encima de Oasis y como funcionan las llamadas
- el README cuenta las novedades de esta version y explica la rama

Arreglos que salieron al hacer las capturas:
- el boton de No molestar del Telefono ocupaba una fila entera arriba de cada
  pagina; ahora va en la barra de arriba, junto al avatar
- Ajustes marcaba English aunque el idioma fuera otro (settings_view se queda
  con el idioma del arranque): el selector lo lee al pintar
- barras de la votacion juntas y con las etiquetas en el mismo tipo
- Personalizar la barra: una fila por atajo, sin sangria
2026-10-19 13:49:21 +02:00
hacklab
d4e6b6c8e8 gitignore: el enlace a node_modules y el estado de la llamada 2026-10-19 13:39:53 +02:00
hacklab
096a7d110b fuera Karvan: se usan el Telefono y las Salas del dev
El dev ya trae Telefono (llamadas uno a uno, buzon de voz) y Salas (hasta 50,
con video, grabacion y emision). La rama quita su modulo propio de salas y
llamadas: modelo, vista, JS de cliente, CSS, rutas, credenciales TURN, pruebas
y textos.

- el router propio pasa a llamarse fork_routes.js, sin Karvan
- el audio, la camara y la emision de Android se enchufan desde main.js con
  setAudioFactory/setVideoFactory/setStreamFactory, que exporta el propio dev;
  SSB_server.js vuelve a tener su linea original
- Clear-SNH.css y modules_view.js vuelven a ser identicos a los del dev
- el workflow acampada y la config de serie cambian Karvan por Telefono y Salas
- README y guia: las llamadas son las del dev; antes de hacer un modulo propio,
  mirar si el dev ya lo tiene
2026-10-19 13:39:41 +02:00
hacklab
3937ddab1e css: fuera los selectores que no apuntan a nada
153 selectores de mobile_base.css, hive_acampada.css y Acampada.css que
apuntaban a clases que ya no pinta ninguna vista (287 lineas). Comparadas 14
pantallas pixel a pixel antes y despues: identicas.
2026-10-19 13:34:26 +02:00
hacklab
ea35ca7ca1 docs: fase C en el informe de frontera 2026-10-19 13:31:39 +02:00
hacklab
1148e6a472 backend: lo del movil sale de backend.js a mobile_layer.js
De 20 trozos nuestros en backend.js quedan 9 (21 lineas). Pasan a
src/backend/mobile_layer.js, que monta el router propio delante de las rutas
del dev:
- blockexplorer, actualizar con git y presets de modulos, cortados en el movil
- los modulos que no van en el movil, apagados al arrancar y al guardar
- avatar de la barra de arriba, filtro y lanzador
- fotograma en los videos y pestaña actual marcada
- descargas de la copia de seguridad por GET
El cambio de tema del movil lo atiende karvan_routes.js.
2026-10-19 13:31:24 +02:00
hacklab
e7892c4bf9 workflows: activists vuelve a ser el del dev; karvan y acampada desde fork_workflows
- el workflow activists del dev queda como en la 1.3.3 (unos 45 modulos)
- karvan se anade a las listas del dev al arrancar, desde fork_workflows.js
- la config de serie del movil enciende tribus, parlamento, votaciones y
  eventos: coincide con acampada y sale marcado en Ajustes
2026-10-19 13:28:44 +02:00
hacklab
35fef607d7 OasisMobile.css vuelve a ser la del dev; la maqueta pasa a mobile_base.css
themes/OasisMobile.css es un tema de epsylon que cambia en 21 de cada 39
versiones y nuestra copia diferia en 4.873 lineas. Ahora es identica a la
1.3.3 y nuestra maqueta vive en styles/mobile_base.css, que el movil carga
siempre antes del skin. Con Acampada se ve igual que antes; con OasisMobile,
el tema del dev encima de la maqueta.
2026-10-19 13:27:03 +02:00
SITO
5bce7d38ed docs: la guia escrita como notas, sin adornos 2026-10-19 13:17:28 +02:00
SITO
abae8132a4 docs: guia de desarrollo con lo que se toca y lo que no
GUIA_DE_DESARROLLO.md en la raiz: la regla de oro, el semaforo (rojo:
backend, middleware, modelos, server, CSP, traducciones y hojas del dev;
ambar: una linea de enganche en las vistas del dev; verde: hojas, vistas,
router, workflows y textos propios), como hacer cada tipo de cambio, lo que
hay que comprobar antes de cada commit, como integrar una version nueva del
dev, como probar y la deuda que queda. Enlazada al principio del README.
2026-10-19 13:16:27 +02:00
SITO
087b86a184 movil: botones de crear iguales, listas vacias, buzon y busqueda compactos
Todo en mobile_ux.css (mas un SVG propio para las listas vacias), sin tocar
vistas ni backend del dev:
- el boton principal de cada modulo, igual en todos
- listas vacias con icono y recuadro
- el pie abajo en paginas cortas
- buzon: remitente y fecha en una linea; fuera Para, Tasa ECO y Cifrado
- busqueda: el campo primero, los filtros despues
- nodos: acciones en rejilla
- fuera los selectores a clases que no existen
2026-10-19 13:08:46 +02:00
SITO
d04d99ece2 frontera con upstream: lo del movil sale de los ficheros del dev
docs/FRONTERA_UPSTREAM.md mide como cambia upstream (39 releases de la 1.0.1 a
la 1.3.3; backend.js en 34, main_views en 31, OasisMobile.css en 21) y fija que
se toca: backend, middleware, modelos, server y traducciones de upstream, no;
vistas de upstream, solo una linea de gancho; hojas, vistas, router y modelos
propios, si.

Fase A, lo del 19/10:
- backend.js, workflows_model.js y modules_view.js vuelven a como estaban
- seccion apagada, /modules/enable y ?saved=1: en el router propio, que envuelve
  las redirecciones de upstream en vez de cambiar checkMod
- Modulos del movil en views/mobile_modules_view.js, servida por el router propio
- workflow propio "acampada" registrado desde models/fork_workflows.js en vez de
  redefinir "activists"
- votaciones, tribus, ajustes, borrar, pie y bienvenida: la logica en
  views/mobile_ui.js; en cada vista de upstream queda la llamada
2026-10-19 13:04:38 +02:00
SITO
9b391cfc3d docs: lo arreglado y lo pendiente en la prueba de usabilidad 2026-10-19 12:44:44 +02:00
SITO
f29540e17e movil: barra de abajo marca la seccion abierta; llamada de voz sin video vacio
- la pestaña actual de la barra de abajo lleva la pastilla de color
- en una llamada solo de voz no se ensena el video propio (era un recuadro
  gris con el icono de play) y Llamar se atenua mientras dura
- la barra de confirmar borrado con su marco rojo
2026-10-19 12:43:28 +02:00
SITO
c1f4ba75de docs: prueba de usabilidad en el emulador (para el pad) 2026-10-19 12:37:58 +02:00
SITO
4dd573662c movil: usabilidad tras la prueba en el Pixel
Lo que salio de recorrer la app como alguien de la acampada:

- borrar (tribus y todo lo que usa renderContentActions) pide un segundo
  toque; la X va la ultima de la fila
- Karvan: Llamar empieza solo con voz (si se negaba la camara no arrancaba
  nada); botones con icono y nombre; textos traducidos; ayuda para invitar
- votaciones: tipo Si/No, semaforo o termometro (el servidor ya aceptaba
  options); resultados en barras; aviso a quien la crea de que no puede votar
- workflow activistas con tribus, parlamento, votaciones y eventos
- seccion apagada: /modules dice cual era y la activa de un toque
- Modulos en el movil: agrupados como el lanzador, interruptores y Guardar fijo
- tribus en filas compactas; secciones de una tribu en tira de fichas + Mas
- lanzador y barra con iconos SVG en vez de letras vai
- Ajustes: idioma y aspecto primero; idiomas de un toque
- aviso de bienvenida solo en la portada y en una linea; Ahora no al final
- pie de una linea con el estado de sincronizacion en claro
- mobile_ux.css para los dos skins: un margen de 14px (eran 74 por lado en
  las fichas), Anton solo en titulos y boton principal, buscador en una linea,
  la barra de abajo se esconde con el teclado
2026-10-19 12:37:58 +02:00
oasis-bot
2e3ff1e79f tema Acampada: letra y botones mas grandes
Lanzador (iconos de 64px, etiquetas de 13px en negrita que solo parten entre
palabras), barra de abajo, fichas de filtro de 46px, iconos de las tarjetas
de 44px, reacciones del chat, formularios y textos de cabecera.
2026-10-19 02:17:12 +02:00
oasis-bot
0baf85faa9 skins: elegir el aspecto en el movil con tarjetas de un toque
En Ajustes del movil, Tema y Flujos de trabajo dejan de ser desplegables:
cada skin es una tarjeta con su miniatura y cada workflow un boton grande,
y se aplican al tocarlos. Solo se ofrecen los skins que tienen maqueta de
movil (Acampada y Oasis Mobile); el servidor rechaza un tema de escritorio,
que dejaba la app sin maqueta. La bienvenida tambien ofrece elegir skin.

Aplicar un workflow ya no cambia el skin: se queda el que haya elegido cada
cual.

OasisMobile.css se carga con su propio <link> versionado antes de
Acampada.css, en vez de con @import: el @import sin ?v= se quedaba en la
cache del WebView y tras actualizar se veia la maqueta vieja.
2026-10-19 02:17:12 +02:00
oasis-bot
ac921c9d56 readme: capturas con el tema Acampada
Las ocho capturas, la portada y el esquema de la interfaz rehechos sobre la
1.3.3 con el tema Acampada. generar_portada.py compone la portada a partir
de las capturas y dibuja el esquema; el texto vive en el script. Novedades:
tema, fotos que no se pueden limpiar y DNS.
2026-10-19 02:06:05 +02:00
oasis-bot
9a4ca6b2b3 tema Acampada: Karvan y respuestas del chat en blanco y negro
karvan.css trae su paleta amarilla sin !important: las salas activas salian
en negro con la letra oscura. Ahora tarjetas con marco negro, burbujas como
las del chat y los botones de llamada en blanco.
2026-10-19 02:06:05 +02:00
oasis-bot
b64dcf9b7a fotos: si no se pueden limpiar, no se suben
Si quitar los metadatos fallaba, blobHandler subia la foto original, con el
GPS. Ahora se para con un 400 y un aviso (imageMetadataUnsafe, en los 11
idiomas): HEIC/HEIF/AVIF y TIFF, que pueden llevar EXIF y aqui no se saben
limpiar, y JPEG/PNG/WebP que no se dejan recorrer. Un JPEG cortado ya no
sube como una imagen vacia. Formatos sin EXIF (BMP, SVG...) pasan igual.

Es el cambio del 10 de octubre, llevado a la copia de la limpieza que vive
ahora en blobHandler.js.
2026-10-19 02:02:16 +02:00
hacklab
ed77a13c85 dns: primero el resolutor del sistema, los publicos de respaldo
main.js preguntaba siempre a 8.8.8.8/1.1.1.1. Las VPN que evitan fugas de DNS
(Mullvad) y las redes que bloquean DNS externos los rechazan, y entonces no se
encontraba ningun pub por nombre: unirse a uno no hacia nada y no decia nada.

Ahora va primero getaddrinfo, que respeta la VPN y el DNS privado de Android;
a los 5 s, o si falla, tambien los publicos. Probado en Android 15 detras de
Mullvad: antes no resolvia hacklab.laenre.net y ahora si.
2026-10-19 02:00:56 +02:00
oasis-bot
b3172ed1c3 android: release --public, version de package.json y libnode comprimida
Lo del 10 de octubre que sigue valiendo sobre la base 1.3.3:
- con un tag v* el workflow publica la APK; a mano sigue en borrador
- el workflow trae el APK oficial de la misma version: de ahi salen
  node_modules y libnode.so (prepare.sh ya lo usaba, pero el runner no lo tenia)
- versionName sale de package.json en vez de ir escrito a mano
- libnode.so comprimida, como en la oficial (unos 35 MB menos por descarga)
- release.sh: JAVA_HOME en el PATH, no deja salir una release debuggable,
  exige CHANGE_WIFI_MULTICAST_STATE y avisa si la APK pasa de 80 MB

Se queda fuera la poda de prepare.sh y la sha256 de la APK 1.2.5: ahora el
node_modules viene ya podado del APK oficial.
2026-10-19 02:00:40 +02:00
hacklab
99e5d766e9 android: pantalla completa con margenes, teclado y descargas
El contenedor deja sitio a las barras del sistema y al teclado (Android 15
obliga con targetSdk 35). Las descargas van al gestor de Android. Fondo
blanco e iconos oscuros, como el tema Acampada.
2026-10-19 01:59:39 +02:00
hacklab
ba057f0092 android: permisos y MulticastLock para ver moviles en la misma wifi
El anuncio LAN de SSB va por UDP broadcast y Android lo filtra sin un
MulticastLock. La oficial declara ACCESS_WIFI_STATE y
CHANGE_WIFI_MULTICAST_STATE; esta no, asi que dos moviles en la misma red no
se encontraban.
2026-10-19 01:59:13 +02:00
hacklab
de5e8fba20 android: un script deja el proyecto listo para Android Studio
Clona el Oasis oficial en su commit, descarga su APK de la misma version y
lanza prepare.sh. Despues basta con abrir android/ y darle a Build APK.
2026-10-19 01:59:13 +02:00
oasis-bot
101bdfe4e8 tema Acampada: blanco, negro y cian sobre el lanzador de la 1.3.3
Trae el tema del 10 de octubre (que se quedo fuera de PRUEBAS) y lo pone
por defecto en el movil. Acampada.css importa OasisMobile.css, asi que el
lanzador, la barra de 4 atajos y el workflow de activistas siguen igual;
hive_acampada.css va detras del tema y repinta lanzador, fichas de filtro,
hilos del foro, burbujas del chat, iconos de las tarjetas e identidades.

Quien ya tenia OasisMobile conserva su workflow reconocido.
2026-10-19 01:59:04 +02:00
oasis-bot
7d281902fb readme: capturas de la 1.3.3 nada mas entrar
Some checks failed
APK / build (push) Has been cancelled
Portada con cuatro pantallas, enlace de descarga y novedades arriba del
todo. Capturas nuevas de la interfaz y de Karvan, y el esquema de la
interfaz redibujado: ya no hay hexagonos ni Peers/Invites en la barra.
Las imagenes viven en docs/capturas, en el propio repo.
2026-10-19 00:44:06 +02:00
oasis-bot
cfe0b6cca1 karvan: tus mensajes a la derecha tambien al recargar la sala
karvan.js solo marcaba como propios los que llegaban despues de abrir
la sala; los que pintaba el servidor salian todos como de otra persona.
2026-10-19 00:44:06 +02:00
oasis-bot
5f5c1d6d0a interfaz: filtros en fichas y tarjetas sin cajas de mas
- Los filtros de chats, feed, foros, pads... heredaban el estilo de boton
  grande (48px, ancho completo): en el foro las categorias llenaban la
  pantalla. Ahora van en fila como los de Actividad; Crear sigue grande.
- La regla de tarjetas ([class*=-card]) pillaba tambien -card-header y
  -card-hero-image, que salian como cajas con fondo y borde. Se limita a
  las clases acabadas en -card.
- Acciones de la tarjeta (borrar, fijar, enlace, abrir) en una sola fila
  de iconos redondos; '.main-column form' las ponia a ancho completo.
- Se ocultan las cajas de filtros vacias del foro.
2026-10-19 00:44:06 +02:00
oasis-bot
948b6460d9 upstream: integrar Oasis 1.3.3
Diferencia 1.3.1 -> 1.3.3 del codigo de la APK oficial aplicada a tres
bandas encima de la rama: karma en el perfil, salas con directo, fotos
selladas, visibilidad de menciones, telefono por stream en Android y
las traducciones nuevas. Sin dependencias nuevas.

OasisMobile.css sigue siendo el del fork; del oficial solo se traen al
final las reglas de las piezas nuevas (karma, directo a pantalla
completa, salas en vivo, graphos).
2026-10-19 00:30:25 +02:00
oasis-bot
bc64565e9c android: gradle toma el JDK de JAVA_HOME
La ruta fija a /opt/android-studio/jbr rompia la compilacion en cualquier
otra maquina y en el runner, que pasa su propio JAVA_HOME.
2026-10-19 00:28:51 +02:00
oasis-bot
ab9bfab09a activistas: workflow por defecto, lanzador compacto y barra de 4 atajos
Con el workflow activistas caben todos los modulos en una pantalla: se
quitan el filtro Personal/Social y los accesos de usuario avanzado
(menciones, habitantes, perfil, CV, estadisticas, blockexplorer,
modulos). Peers y la edicion de la barra pasan a Herramientas; la barra
inferior se queda en los 4 atajos. La config de serie arranca ya con
los modulos de activistas.
2026-10-18 23:54:54 +02:00
oasis-bot
e01f718c1c docs: base 1.3.1, lanzador y workflows; la APK base ha de ser la 1.3.1 2026-10-18 02:52:17 +02:00
oasis-bot
5a04113330 interfaz: lanzador de modulos en lugar de los hexagonos
La portada ordena los modulos por secciones en una rejilla de iconos, como el
cajon de apps del movil. Solo salen los modulos activos y las secciones que
tienen alguno; Personal y Social de la topbar parten el lanzador en dos.

Fuera los enlaces que ya no existen en 1.3.1 (/legacy, /public/latest/*) y
dentro los modulos nuevos: wiki, rooms, emergencias, mailing, campanas...

1.3.1 trae en mobile.css su propio chrome movil (cabecera fija y cajones); se
apaga desde el tema para que no se monte encima del de la rama.
2026-10-18 02:52:17 +02:00
oasis-bot
a11a730c26 workflows: activistas con lo justo para organizarse
Chats, Karvan, pads, foro, imagenes, videos, wiki y blogs, mas feed,
invitaciones y copia de seguridad. Karvan entra en la lista de modulos para que
los workflows lo enciendan y apaguen como al resto.

En movil un workflow cambia los modulos y no el tema: se queda OasisMobile.
2026-10-18 02:52:17 +02:00
oasis-bot
c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00
s1to
8d838dd8b1 readme: la portada del repositorio, con esquemas y capturas
Al entrar en el repositorio se veia el aviso de rama experimental y, debajo,
el README entero de upstream copiado: 350 lineas contando lo que ya cuenta el
proyecto oficial, y mejor.

Ahora la portada ensena lo que hay aqui: la tabla de que anade esta rama frente
al oficial, Oasis explicado en cuatro dibujos, Karvan, las videollamadas, las
identidades y como empezar desde cero, todo con esquemas propios y capturas
reales.

El aviso de que esto NO es Oasis oficial se mantiene arriba del todo y completo.
Y al final, los enlaces al proyecto de epsylon, que es de donde sale todo.

Las imagenes se enlazan a la wiki en vez de duplicarse en el repositorio, que
es lo que hace tambien upstream con las suyas.

El fichero se sigue leyendo al arrancar el backend (readFileSync sin try/catch),
asi que lo importante es que exista: comprobado que arranca.
2026-08-20 08:32:22 +02:00
s1to
923bb0ef9d blobs: las fotos ya no suben con la ubicacion dentro
stripImageMetadata dependia de sharp, y cuando no esta devolvia la imagen
intacta. En Android sharp no se empaqueta —lleva binarios nativos y el bundle
del movil va sin ninguno— asi que las fotos subian con su EXIF entero: marca,
modelo, numero de serie, fecha y coordenadas GPS. Y sin sharp instalado,
tambien en escritorio.

Ahora hay un limpiador que no depende de nada: recorre el contenedor y tira los
bloques de metadatos sin decodificar ni recomprimir, asi que los pixeles quedan
byte a byte como estaban.

  JPEG   fuera APP1 (Exif y XMP), APP13 (IPTC), APP14, COM y demas.
         Se quedan JFIF y el perfil de color, que afectan a como se ve.
  PNG    fuera tEXt, zTXt, iTXt, eXIf, tIME, dSIG.
  WebP   fuera los bloques EXIF y XMP del contenedor RIFF.

De la orientacion se guarda solo eso: sin sharp no se pueden girar los pixeles,
y tirar la etiqueta dejaria tumbadas las fotos verticales. Se reconstruye un
bloque EXIF de 32 bytes con esa unica etiqueta, que no dice nada de quien hizo
la foto ni donde. Si sharp esta, se sigue prefiriendo su camino, que ademas gira
la imagen de verdad.

Comprobado con una foto que llevaba GPS, marca, modelo, numero de serie y fecha:

  - En el movil y en escritorio, el blob guardado queda con 32 bytes de EXIF
    (solo la orientacion, que sobrevive) y cero rastros de lo demas.
  - Los pixeles son identicos antes y despues, en JPEG, PNG y WebP.
  - El perfil de color ICC se conserva.
  - Sin metadatos, progresivo, GIF, fichero corrupto, fichero vacio y algo que
    no es una imagen: se devuelven tal cual, sin excepciones.
2026-08-19 21:07:02 +02:00
s1to
da2ad8e1c6 android: la pantalla de espera, con tilde y sin asustar
Decia "Preparando la aplicacion" sin tilde y "puede tardar unos minutos", que
para un primer arranque de quince segundos suena a que algo va mal. Ahora dice
lo que hace y cuanto: descomprime el servidor, tarda un minuto, no hay que
hacer nada.
2026-08-19 20:48:38 +02:00
s1to
5c769a5a10 estructura: los ficheros de la rama, donde los pondria el dev
Upstream no usa ni un solo subdirectorio en src/views, src/backend, src/models,
src/client/assets/styles ni translations: todo plano. La rama habia creado dos
(views/fork/ y translations/fork/), que es lo que mas se separaba de su forma de
organizar el codigo. Se deshacen, y los ficheros pasan a llamarse como los suyos:

  views/fork/hive_nav.js          -> views/hive_views.js      (como main_views.js)
  views/fork/identities.js        -> views/identities_view.js (como *_view.js)
  translations/fork/i18n_fork.js  -> translations/i18n_fork.js (junto a i18n.js)
  backend/fork_routes.js          -> backend/karvan_routes.js (familia karvan_*)

Ademas se retira una ruta duplicada: /legacy/export estaba declarada dos veces,
en backend.js (la del dev) y en el fichero de rutas de la rama, con el mismo
cuerpo salvo un && defensivo. Nacio cuando el formulario se paso a GET; al
devolverlo a POST quedo como copia exacta. Con ella se van legacyModel y
onboardingModel, que ya no hacia falta inyectar.

La cabecera del fichero de rutas explica ahora por que sigue existiendo, con los
datos medidos y no de memoria: backend.js concentra 625 rutas y se reescribe en
cada release (+833/-286 en la 0.9.2, +266/-31 en la 0.9.1), asi que una ruta
anadida en medio entra en conflicto cada vez.

Quedan diez ficheros propios, todos siguiendo la convencion de upstream, y
+247 lineas repartidas en 21 ficheros suyos (sin contar OasisMobile.css, que es
el tema de la rama).

Comprobado tras mover: arranque, las siete rutas principales, el selector de
identidades con sus siete entradas y su aviso, crear sala y enviar mensaje en
Karvan, y la exportacion de identidad por la ruta del dev.
2026-08-19 20:38:26 +02:00
s1to
e0ff70c138 android: rotar la pantalla rompia la app
Al girar el movil aparecia ERR_CONNECTION_REFUSED y ahi se quedaba. La lista de
configChanges estaba incompleta —faltaba smallestScreenSize, que es justo lo que
cambia al rotar—, asi que Android destruia y recreaba la Activity: el WebView se
perdia y volvia a cargar la URL, a veces antes de que el backend escuchara.

Con la lista completa, rotar ya no recrea nada y ademas no se pierde lo que
estuvieras escribiendo.

Y se anade una red de seguridad que no habia: onReceivedError vuelve a esperar
al backend y recarga la ultima pagina que si cargo, en vez de dejar el error de
Chromium en pantalla. Cubre tres casos mas, todos comprobados en el emulador:

- Matar el proceso :node con la app abierta (lo que hace Android cuando necesita
  memoria): antes se quedaba muerta, ahora vuelve sola en unos 19 segundos.
- Cambiar de identidad, que reinicia el backend a proposito.
- Arranques lentos en moviles con poca memoria.

Un guard evita que se acumulen hilos esperando al mismo puerto, porque
onReceivedError se dispara una vez por recurso que falle.

Probado ademas: boton atras, modo avion, trim-memory RUNNING_CRITICAL y
am kill con reapertura. Todo eso ya iba bien.
2026-08-19 19:34:06 +02:00
s1to
728f10beb0 i18n: los nombres de las categorias vuelven a ser los de epsylon
El overlay pisaba menuNetwork, menuMedia y fediverse para dejarlos en Network,
Media y Fediverse. Se quitan: la rama no deberia separarse de upstream en algo
tan visible, y ahora movil y escritorio dicen lo mismo (Community, Library,
Multiverse).

Eso obliga a renombrar el filtro de la topbar: se llamaba Community y ahora ese
nombre es el de una categoria, asi que en la misma pantalla habria dos. Pasa a
ser Social, que es del fork y no choca con nada de epsylon.

Y las traducciones dejan de estar a medias: las 56 claves de la rama estan
completas en los 11 idiomas, ninguna cae al ingles. Antes solo el castellano
estaba entero y los otros nueve mostraban el texto en ingles.
2026-08-19 19:24:29 +02:00
s1to
161d636f17 legacy: la contraseña de la copia deja de viajar en la direccion
El formulario de exportacion se habia pasado a GET, asi que la contraseña que
cifra la copia de la identidad acababa en el historial del navegador, en el
registro del servidor y en el Referer de lo siguiente que se cargara. Vuelve a
POST, como en upstream, y la ruta la lee del cuerpo.

Comprobado: la exportacion sigue descargando el fichero, y la direccion con la
contraseña ya no responde.
2026-08-19 11:23:19 +02:00
s1to
e84a9f84da forum: quitar el campo subtopic, que no lo lee nadie
Resto del intento de subtemas propios que se descarto: se copiaba a cada
respuesta pero ninguna vista lo mira. Los subtemas de verdad son los de
upstream, con sus rutas y sus vistas.
2026-08-19 11:21:25 +02:00
s1to
d843d47374 identidades: enlace a exportar e importar
Sacar o meter una identidad ya existente lo hace el modulo legacy, que es quien
sabe cifrar el secret con una contraseña. Se enlaza desde la lista para que
este donde se busca.
2026-08-19 11:18:55 +02:00
s1to
f465fb2288 android: script de publicacion y workflow para la APK
release.sh hace el ciclo entero y siempre igual, se lance a mano o desde un
runner: empaqueta el backend, compila, verifica y publica la release en gitea.

Las verificaciones no son decorativas, son las que han cazado fallos reales en
esta rama: una APK puede compilar, instalar y arrancar sin el backend dentro, o
haber perdido los permisos de camara y quedarse sin videollamadas. Se comprueba
que estan el backend y libnode.so, los dos permisos, y la firma.

El versionCode sale del numero de commits, que sube solo; el versionName, de
package.json, que es de donde lo saca tambien el backend, para que la version
que muestra la aplicacion no pueda discrepar de la de la APK. Los dos se pasan
por entorno a Gradle en vez de editarlos a mano.

La release se crea como borrador a proposito: publicar una APK firmada sin que
nadie la mire no deberia poder pasar por descuido.

El workflow de gitea no se dispara con cada commit (la APK son mas de 100 MB) y
necesita un runner con el SDK de Android; queda documentado en su cabecera.
2026-08-19 11:16:25 +02:00
s1to
a22e4a49d4 identidades: selector en los ajustes, con reinicio en movil
La base de multicuenta ya estaba en el arranque (OASIS_ACCOUNT), pero solo se
podia elegir por linea de comandos. Ahora hay una seccion en los ajustes que
lista las identidades, marca cual esta en uso y permite crear una.

Como funciona:

- accounts.js encuentra las identidades buscando directorios ~/.<algo> con un
  secret dentro, y lee de el su @id. En Android ese HOME es el almacenamiento
  privado de la app, asi que quedan aisladas del resto del sistema.
- El selector anota la elegida en src/configs/active-account; ssb_config.js la
  usa al arrancar si no hay OASIS_ACCOUNT (que sigue mandando).
- Crear una identidad solo anota el nombre: el secret lo genera ssb-config al
  arrancar con esa cuenta.

En movil hay ademas un boton de reinicio, porque aqui cerrar la app no basta:
el backend vive en un servicio en primer plano que sobrevive. La ruta responde
la pagina y luego termina el proceso; el servicio se recrea y node arranca con
la identidad nueva. No se puede levantar el runtime dos veces en el mismo
proceso, de ahi salir en vez de reiniciar por dentro.

Comprobado en el emulador: al reiniciar, el feed pasa a ser el de la otra
identidad y la anterior sigue intacta.

Dos carpetas pueden tener el mismo secret copiado, y publicar desde las dos
parte el registro sin arreglo posible. La lista lo detecta comparando los @id
y lo avisa en rojo en las dos filas.
2026-08-19 11:08:55 +02:00
s1to
37937e8363 assets: recuperar las tres imagenes de UX que faltaban
La pantalla de ajustes y el asistente de bienvenida piden ux-blocks.png,
ux-chats.png y ux-ainav.png para el selector de modo de interfaz. No se
copiaron al integrar 0.9.5, asi que el selector salia con tres imagenes rotas.

De paso se retira media-favorites.js con su JSON: es de una version anterior
de la rama y ya no lo requiere nadie, upstream lo resuelve con
content_favorites.js.
2026-08-19 10:35:26 +02:00
s1to
48cafbbe59 android: selector de ficheros, espera del backend y re-extraccion
Cuatro arreglos del envoltorio, todos vistos al probarlo en el emulador:

- onShowFileChooser: sin el, el navegador interno no abre nada al pulsar
  "Choose File", asi que no se podia subir ninguna imagen. El APK oficial si
  lo implementa; era una regresion frente a el.

- La marca de re-extraccion del backend era el versionName. Al reconstruir la
  APK sin subir version, la app se quedaba con el backend anterior y ningun
  cambio se aplicaba. Pasa a ser lastUpdateTime del paquete, que cambia en
  cada instalacion. (openFd sobre el asset no vale: va comprimido dentro del
  APK y no admite descriptor.)

- La espera del backend sondeaba con HEAD, que el backend rechaza con 400.
  Ahora abre un socket TCP contra 127.0.0.1:3000, que es lo unico que hay que
  saber, y espera hasta cinco minutos en vez de uno: descomprimir 231 MB y
  levantar SSB tarda mas en un movil lento. Mientras, pinta una pantalla de
  estado con los segundos transcurridos, en vez de dejar el WebView en blanco.

- targetUrl/onNewIntent para poder abrir una ruta concreta desde un intent.

network_security_config.xml tenia base-config detras de domain-config, que el
esquema no admite. Corregido el orden y restaurada la referencia en el
manifiesto, que se habia sustituido por usesCleartextTraffic=true al depurar:
eso abria texto en claro hacia cualquier host, no solo hacia loopback.
2026-08-19 10:30:37 +02:00
s1to
5bfbe2d6c8 modules: karvan en su sitio alfabetico
La lista de modulos va ordenada por nombre; karvan estaba detras de polls,
que es de donde se copio el patron. Pasa a estar entre jobs y larp.
2026-08-19 10:30:37 +02:00
s1to
cb892579f5 karvan: el panel de llamada no tenia estilos
Un comentario sin cerrar en karvan.css se comia la llave de la regla que
neutraliza el div{background:#222;padding:20px} global, y con ella todo el
bloque de la videollamada. El resultado eran dos cajas vacias enormes y unos
botones cuadrados en vez del panel.

Ademas:

- La hoja del modulo se cargaba antes que el tema, asi que el tema ganaba por
  cascada. Pasa a cargarse despues, que es lo que decia su propio comentario.
- Las opciones de duracion (30m / 2h / 8h) se partian letra a letra en
  escritorio; les faltaba white-space: nowrap.
- Clear-SNH usa !important en casi todo, asi que ninguna hoja posterior puede
  ganarle. Se resuelve como lo resuelve Oasis: es el tema el que pinta el
  modulo. Sin ese bloque las burbujas salian negras sobre blanco y el texto
  secundario en amarillo palido, ilegible.

Comprobado con los cuatro temas en escritorio y en el emulador.
2026-08-19 10:30:16 +02:00
s1to
2203b54917 chats: la respuesta citada se guardaba pero no se pintaba
buildMessage no copiaba replyTo del contenido del mensaje, asi que la vista
nunca encontraba a quien se respondia y la burbuja salia sin cita. El campo
estaba puesto por error en buildChat, donde no significa nada: un chat no
responde a ningun mensaje.

Se ve al responder a un mensaje y recargar el hilo.
2026-08-19 10:30:00 +02:00
SITO
0bf4a8fcb9 fix: la app no arrancaba en Android por una expresion regular de 0.9.5
Diagnosticado en el emulador. El backend moria al cargar backend.js:

  SyntaxError: Invalid regular expression: /^#([\p{L}\p{N}_-]+)/:
  Invalid property name in character class

libnode.so de nodejs-mobile viene sin ICU completo, asi que las propiedades Unicode
en expresiones regulares no existen y el fichero ni siquiera compila. La rama ya lo
habia resuelto con un rango explicito; al integrar 0.9.5 se reintrodujo la forma de
upstream y con ella el fallo. Afectaba a backend.js y a data_model.js.

Tras el arreglo el emulador arranca: 'Backend started on port 3000', 991 modulos,
285 ms de warmup.

Y se devuelven al overlay los nombres de tres categorias. Upstream los renombro en
0.9.5 (Network -> Community, Media -> Library, Fediverse -> Multiverse) y al
restaurar los ficheros de traduccion se colaron los suyos, cambiando las etiquetas
de los hexagonos. Lo destapo una captura de pantalla del usuario, no el codigo.
2026-08-19 08:24:12 +02:00
SITO
f354069af7 ssb: base de multicuenta y arreglo de las rutas fijas a ~/.ssb
ssb_config pasa a derivar la cuenta de OASIS_ACCOUNT. Como ssb-config construye la
ruta desde el appname y gui.js saca el socket de config.path, cambiar ese literal
arrastra de forma coherente el almacen, la base de datos, los blobs, el secret y el
socket. Sin la variable se usa 'ssb', asi que las instalaciones existentes siguen en
~/.ssb y no notan nada.

El nombre se valida contra [a-zA-Z0-9][a-zA-Z0-9_-]{0,31} y cae a 'ssb' si no encaja.
No es de adorno: acaba en rutas de fichero y hay operaciones destructivas detras.
Comprobado que OASIS_ACCOUNT=../../etc no sale de sitio.

statePath separa tambien por cuenta cuando se usa OASIS_STATE_DIR. Sin eso, dos
identidades compartirian el estado que 0.9.5 empezo a guardar ahi, que es justo el
camino a partir el registro.

Y el arreglo que importa: doce sitios construian la ruta como os.homedir() + '/.ssb'
en vez de usar la de la cuenta. El peor era panicmode_model, que con varias
identidades habria borrado la equivocada; tambien exportmode y legacy_model, que
habrian exportado o restaurado la identidad de otra cuenta. Los de lectura
—main_models, stats_model y backend.js con gossip.json y ebt— habrian mirado siempre
la cuenta por defecto.

Falta la parte visible: el selector de identidad y el reinicio del proceso al
cambiar. Esta base es la que hacia falta para que eso no sea peligroso.

Verificado: con OASIS_ACCOUNT sin definir la ruta sigue siendo ~/.ssb; con 'trabajo'
pasa a ~/.trabajo; con un valor con .. o espacios se rechaza. Diez rutas OK con la
app en marcha, incluidas /peers y /stats, que son las que leen esos ficheros.
2026-08-19 00:44:23 +02:00
SITO
f80b2e04f0 docs: aviso de rama experimental en el README y version al dia
El README abria con 'Oasis Mobile 0.9.1' y describia mejoras sobre 0.9.0, sin decir
en ningun sitio que esto no es el Oasis oficial. Ahora lo primero que se lee es que
es una rama experimental sin afiliacion, con enlace al repositorio de epsylon, la
indicacion de que los fallos se reportan aqui y el enlace a la wiki.

El package.json de la raiz seguia en 0.9.1 mientras el de src/server ya estaba en
0.9.5. Alineados.

Nota: backend.js sirve este README en la interfaz, asi que el aviso se ve tambien
desde dentro de la aplicacion.
2026-08-19 00:41:25 +02:00
SITO
f3330e3d0e views: la topbar de movil solo en movil
renderMobileTopbar era la unica de las tres piezas de interfaz de la rama sin gate,
asi que en escritorio se dibujaba encima de la cabecera de upstream. Los hexagonos y
la barra inferior si lo tenian.

Detectado al arrancar el arbol sin OASIS_MOBILE para preparar la version de
escritorio.
2026-08-19 00:35:49 +02:00
SITO
736b528b99 karvan: darle sitio en el escritorio siguiendo el patron del dev
Karvan solo existia en la rejilla de hexagonos, que es de movil. En escritorio no
habia forma de llegar salvo escribiendo la URL.

Se anade renderKarvanLink en main_views, calcado de renderPollsLink —misma forma,
mismo checkMod por getConfig().modules, mismo navLink— y se invoca en el grupo
social junto a renderChatsLink. Entra tambien en modules_view y en la lista de
/modules de backend.js, para poder apagarlo como cualquier otro modulo, con sus
etiquetas en el overlay.

Los estilos del modulo pasan a src/client/assets/styles/karvan.css. Estaban dentro
de OasisMobile.css, que en escritorio no se carga, asi que las salas se habrian
visto sin formato. Va como hoja propia siguiendo el patron de highlight.css, que
tampoco pertenece a ningun tema: asi el modulo se ve igual con cualquier tema y en
las dos plataformas.

Verificado: karvan.css sirve 200 y se carga en las dos plantillas, el enlace aparece
en el menu lateral, y en movil la rejilla de hexagonos sigue igual.
2026-08-19 00:30:55 +02:00
SITO
841acf2a7a karvan: quitar el STUN de Google y servir la configuracion ICE del propio nodo
El cliente tenia stun.l.google.com fijo en el codigo. Un servidor STUN aprende la
IP publica y el momento de cada consulta, asi que tal cual estaba cada llamada le
avisaba a Google de que ese usuario esta llamando. En un proyecto con esta postura
sobre privacidad no se sostiene.

Por defecto ya no hay ningun servidor ICE: solo candidatos host, que funcionan en
la misma red y con NAT amable. Es mas honesto degradar a nada que degradar a un
tercero.

Se anade turnCredentials.js, siguiendo el estilo de los helpers de src/backend/:
genera credenciales efimeras con el mecanismo REST de coturn (use-auth-secret),
username = caducidad:identificador y credential = HMAC-SHA1 en base64. El feed id
hace de identificador, que es lo que distingue a quien llama en Oasis. Como coturn
valida el HMAC, las credenciales se emiten sin hablar con el servidor y caducan
solas.

La ruta GET /karvan/ice las sirve solo a loopback —la credencial va firmada con el
secreto del nodo y no debe salir de la maquina— y el cliente la pide ANTES de crear
ninguna RTCPeerConnection, porque la configuracion ICE de una conexion ya creada no
se puede cambiar de forma fiable. Con dos segundos de espera maxima y respaldo a
candidatos host.

La configuracion vive en oasis-config.json bajo rtc, vacia de fabrica, y admite tres
formas: STUN propio, TURN con secreto compartido, o credenciales estaticas. Con
relayOnly se fuerza iceTransportPolicy relay para que ningun par vea la IP del otro.

Verificado: sin configuracion devuelve iceServers vacio; con secreto emite credencial
reproducible y con caducidad; cero referencias a Google en el codigo.
2026-08-19 00:16:50 +02:00
SITO
c3a1c841e6 fix: cerrar la integracion 0.9.5 — recursos que faltaban y regresiones
Una revision a fondo del arbol contra 0.9.5 saco varios fallos, unos heredados y
otros que introdujo la propia integracion. Verificados uno a uno antes de tocar.

Recursos referenciados que no existian (los introduje al adoptar vistas de 0.9.5
que los usan, mientras la rama los habia borrado):
- Los cuatro temas *-SNH.css. settings_view ofrece Dark, Clear, Matrix y Purple,
  pero solo estaba OasisMobile: elegir cualquiera daba 404 y la app se quedaba sin
  estilos. Ahora los cuatro responden 200.
- pdf.min.mjs, pdf.worker.min.mjs y pdf-viewer.js, referenciados desde seis vistas.

Regresiones revertidas a 0.9.5 verbatim en catorce ficheros. Las de fondo:
- larp_model perdio getGoverningPeriodId, que backend.js llama: el anuncio de
  gobierno no salia nunca, con el TypeError tragado por un catch.
- courts_view y search_view tenian selected: false, que hyperscript SI serializa
  como atributo y HTML interpreta como seleccionado. La forma de upstream, el
  spread condicional, es la correcta.
- pm_model perdio includeDeleted, projects_model la validacion de deadline pasado,
  jobs_model el desempate por timestamp, middleware el flag de debug HTTP.

forum_view pasa a 0.9.5 conservando el filtro hot. Esto arregla un fallo activo: la
vista de la rama declaraba un cuarto parametro topic y backend.js llama con cinco
argumentos, asi que al abrir una respuesta el %clave del hilo se colaba como tema y
el hilo salia VACIO. Upstream pasa los mismos cinco argumentos a una firma de tres y
JS los ignora, que es lo correcto.

chats_view pasa a 0.9.5 y se implementa replyTo de verdad. Estaba muerto de punta a
punta: backend.js no leia ctx.query.replyTo ni pasaba el cuarto argumento a
sendMessage, asi que ningun mensaje llegaba a tener replyTo y la cita no se
renderizaba jamas. Ahora la cadena esta completa —modelo, GET, POST y vista— con
indice msgById armado antes de mezclar con las encuestas, y sin gate de movil:
responder a un mensaje no es una funcion de movil.

aria-label: encontrada la causa. No es que hyperaxe lo ignore, es que hyperscript
solo asigna como propiedad y html-element solo serializa atributos de su tabla, en
la que aria-* no esta; data-* si tiene rama propia, por eso las tablas responsive
funcionaban. Se arregla con el escape hatch attrs:{}, sin tocar node_modules.

prepare.sh copia README.md y falla si no esta: backend.js lo lee con readFileSync
sin try/catch, de modo que sin el fichero el backend muere con ENOENT al arrancar.

Verificado con la app en marcha: 17 rutas OK, los cuatro recursos que faltaban
sirviendo 200, aria-label emitido en el HTML, e interfaz intacta (10 categorias,
50 modulos, 7 accesos rapidos, 0 etiquetas vacias).
2026-08-18 23:48:14 +02:00
SITO
7459644a73 android: fijar HOME y reutilizar el node_modules podado del APK base
main.js resuelve el directorio de datos con process.env.HOME || resolve(__dirname,
'..','..'). En Android HOME suele llegar como "/", que no es escribible, asi que el
servicio lo fija explicitamente con Os.setenv al almacenamiento privado de la app:
ahi cuelga ~/.ssb. Tambien fija TMPDIR.

prepare.sh deja de pedir un npm install: saca el node_modules del APK base, que
viene podado —137 MB frente a los 1,4 GB de una instalacion de escritorio— y
sustituye solo el codigo. Es seguro porque las 98 dependencias son identicas entre
0.9.1 y 0.9.5: ninguna nueva, ninguna quitada, ninguna con version distinta.
El zip se comprime; sin comprimir el backend son 145 MB y la APK se iria por encima
de 200.

APK generada y verificada: 78 MB, net.laenre.oasis, versionName 0.9.5, targetSdk 35,
con libnode.so y el backend de 32 MB dentro.
2026-08-18 23:35:19 +02:00
SITO
32f6d2269c android: wrapper propio, con los permisos que faltaban para las videollamadas
Hasta ahora la APK salia re-firmando la de epsylon, y su classes.dex no implementa
WebChromeClient.onPermissionRequest. Sin ese metodo el WebView deniega por defecto
todo getUserMedia, asi que las videollamadas de Karvan no podian funcionar por mucho
que se parchease el manifest: la lista de permisos es estatica y se fija al compilar.

El wrapper cierra las dos puertas. Declara CAMERA, RECORD_AUDIO y
MODIFY_AUDIO_SETTINGS, pero no se conceden al instalar: son permisos peligrosos y se
piden en tiempo de ejecucion la primera vez que se pulsa Llamar. Si nadie llama, no
se pide nada. uses-feature required=false evita excluir dispositivos sin camara.

Reutiliza las tres librerias nativas tal cual: libnode.so es nodejs-mobile v18.20.4
oficial y libnative-lib.so son 6,8 KB con un unico simbolo JNI. De ahi que el
namespace sea com.solarnethub.oasis —JNI resuelve por nombre— mientras el
applicationId es net.laenre.oasis, que permite instalarla junto a la oficial. No
hace falta NDK ni compilar Node.

De paso corrige cosas del APK actual: targetSdk 35, trafico en claro permitido solo
hacia loopback en vez de usesCleartextTraffic global, allowBackup a false para que
adb backup no saque el .ssb, y el backend en un servicio en primer plano en el
proceso :node, que ademas es lo que permitira reiniciarlo limpio para el cambio de
identidad. El APK actual declara FOREGROUND_SERVICE pero no registra ningun servicio,
de modo que el backend muere al pasar a segundo plano.

Los binarios no se versionan: scripts/prepare.sh extrae las librerias y empaqueta el
backend. La keystore se pasa por entorno.

Verificado: ./gradlew assembleDebug construye una APK de 52 MB con las tres librerias,
targetSdk 35 y los permisos declarados; su DEX contiene onPermissionRequest,
PermissionRequest, CAMERA y RECORD_AUDIO, donde el APK actual tiene cero de los cuatro.
Falta probarla en un dispositivo real.
2026-08-18 23:13:03 +02:00
SITO
ace175cca3 karvan: refrescar la sala sin JavaScript
Sin JS el chat ya funcionaba —el formulario publica y el mensaje sale en el HTML—
pero no llegaban los mensajes de los demas hasta recargar a mano.

La pagina de sala anade <noscript><meta http-equiv=refresh content=10>: solo actua
cuando no hay JavaScript, asi que con JS el data-channel sigue mandando y no hay
recargas encima del chat en vivo. Es el mismo recurso que usa upstream en
indexing_view.

Comprobado ademas sobre el modelo, con TTL acortado: la sala se autodestruye al
vencer y no deja mensajes, el anillo respeta el tope de 250 conservando los mas
recientes, se rechaza la senalizacion de mas de 16 KiB, el tope de 100 salas se
aplica, y postear en una sala inexistente devuelve null en vez de reventar.

El identificador de sala son 72 bits de entropia: no es adivinable, y es la unica
credencial de acceso — quien tiene el enlace, entra. Es el modelo previsto, pero
conviene tenerlo escrito.
2026-08-18 23:04:41 +02:00
SITO
ad2d5c8f62 nav: dar entrada en la interfaz a los modulos nuevos del dev
polls, blogs y data tenian modelo, vista y rutas desde la integracion de 0.9.5,
pero no aparecian en ningun sitio: habia que llegar por URL.

Entran en los hexagonos por categoria — blogs junto a menciones y publicaciones,
encuestas junto a votaciones, coincidencias en herramientas — y en la lista de
modulos fijables en la barra inferior. La rejilla se autogestiona, asi que no hace
falta tocar CSS.

Sus etiquetas ya existian en el i18n de upstream (pollsTitle, blogTitle, dataTitle),
de modo que salen traducidas sin anadir claves al overlay.

Verificado: 10 categorias y 50 modulos (antes 47), los tres enlaces presentes en la
home, 0 etiquetas vacias y las cuatro rutas respondiendo 200.
2026-08-18 23:01:38 +02:00
SITO
7b90fa8211 backend: backend.js a 0.9.5, con lo que faltaba para los modulos nuevos
backend.js pasa a la version de 0.9.5 y baja de 1549 a 16 lineas de divergencia.
Sin esto los modulos que el dev ha anadido estaban portados pero inaccesibles: no
tenian rutas. Ahora /polls, /blogs y /mentions responden.

Reinyectado de la rama: el enganche de fork_routes, los tres gates de movil (no
abrir navegador al arrancar, no matar el sbot al borrar la cadena), el selector de
chats de la barra lateral movil, y los globals de la interfaz — el filtro
Personal/Community, la visibilidad de los hexagonos y el avatar de la topbar.
Esos globals no aparecian al clasificar el diff y su perdida dejaba la home sin
hexagonos; detectado al comparar el HTML servido.

bottomBarPickerView (la pantalla de personalizar la barra) vivia en main_views y se
perdia al adoptar 0.9.5. Pasa a fork/hive_nav.js, junto al resto de la interfaz de
la rama, y main_views la reexporta en una linea.

housing se porta pero apagado (housingMod off). Mantenerlo fuera obligaba a editar
83 referencias de backend.js en cada release; asi el fichero queda igual que
upstream y el modulo no aparece en la interfaz. Se portan tambien contentPdf,
housing_model y housing_view para cerrar los requires.

Verificado con la app en marcha: 29 rutas OK, hexagonos 10/47 con los filtros
Personal y Community dando 5 y 5, topbar con avatar, 7 accesos rapidos, orden de
hojas correcto, y Karvan creando sala y sirviendo el panel de videollamada.
2026-08-18 22:57:33 +02:00
SITO
7eeac8b004 upstream: integrar 0.9.5 en style.css y las vistas grandes
style.css pasa a 0.9.5: lo unico que la rama le habia hecho era borrar reglas de
housing, y su estilo propio vive en OasisMobile.css, que carga despues y gana igual.

Se adopta 0.9.5 en tags_view (anade busqueda y filtros mine/recent), vote_view,
market_view y activity_view, donde la rama solo arrastraba la version anterior:
renderContentActions de dos parametros, comentarios de mercado propios y un import
muerto de renderHiveNav que no se llegaba a llamar.

Quedan a proposito en la version de la rama forum_view y chats_view: llevan
funcionalidad movil propia (la tira de temas del foro y la interfaz de responder
mensajes, que acompana al replyTo del modelo) entrelazada con estructuras que 0.9.5
ha reescrito. Integrarlas pide rehacer esa funcionalidad sobre la forma nueva, y no
es un cambio que convenga colar entre otros veinte.

Verificado con la app en marcha: 21 rutas OK, 0 claves i18n rotas, 0 requires rotos,
interfaz intacta y orden de hojas correcto (style.css, mobile.css, OasisMobile.css).
2026-08-18 22:49:21 +02:00
SITO
373fba5d96 views: main_views a 0.9.5 conservando la interfaz de la rama
main_views.js pasa a la version de 0.9.5 y se le reinyectan los seis puntos propios:
el enganche a fork/hive_nav, la topbar y los hexagonos en la cabecera, la barra
inferior antes del pie, el orden invertido de hojas de estilo en movil (en los dos
templates), el campo para copiar el Oasis ID y el chip de dispositivo.

Esto era necesario, no cosmetico: el renderContentActions de la rama tenia dos
parametros y el de 0.9.5 tiene tres. Las vistas ya integradas lo llaman con el
tercero (favoritos, difusion, reportar) y la rama lo ignoraba en silencio, de modo
que esos botones no llegaban a dibujarse.

Cuidado con el orden de hojas: 0.9.5 carga el tema antes de mobile.css, lo que en
movil dejaba a mobile.css ganando por cascada. Corregido en los dos templates.

bookmark_view pasa a 0.9.5 con el renderPMButton de la rama reinyectado. El
renderFavoriteToggle propio desaparece porque 0.9.5 integra los favoritos dentro de
renderContentActions, que cubre lo mismo.

Verificado con la app en marcha: 15 rutas OK, orden de hojas correcto en el HTML
servido (style.css, mobile.css, OasisMobile.css), 10 categorias y 47 modulos en los
hexagonos, topbar con Personal/Community y avatar, 7 accesos rapidos, campo de Oasis
ID presente y 0 etiquetas vacias.
2026-08-18 22:45:35 +02:00
SITO
c662e37668 upstream: integrar 0.9.5 en modelos y vistas medianas
chats_model: se adopta 0.9.5 y se reinyecta el replyTo de la rama (responder a
mensajes). El fix del salt en joinByInvite ya venia en upstream, y el limite de
mensajes pasa a ser configurable (60/h) en vez del parche que lo saltaba en movil.

config-manager: version de 0.9.5 con karvanMod, los seis modulos de public y toda
la normalizacion de bottomBarPins reinyectados; housingMod fuera. Entran blogsMod
y pollsMod del dev.

Se adopta 0.9.5 en votes_model (ya trae el margen de 2 minutos de la rama y anade
codigos de error), blobHandler (superconjunto compatible: mantiene handleBlobUpload
y anade subida multiple y deteccion de OGG), market_model (misma refactorizacion
mas opiniones y compradores), agenda_model y agenda_view (el codigo de 0.9.5 ya
degrada solo sin housingModel), opinions_view, cv_view y trending_view.

peers_view y parliament_view se quedan como estan: sus celdas llevan data-label,
que es lo que OasisMobile.css usa para convertir las tablas en tarjetas en movil.

Verificado con la app en marcha: 20 rutas OK, interfaz intacta (10 categorias, 47
modulos, 7 accesos rapidos, 0 etiquetas vacias) y Karvan completo de punta a punta
— crear sala, enviar y leer mensajes, mailbox de senalizacion WebRTC con miembros,
y la pagina de sala sirviendo karvan.js con el panel de videollamada.
2026-08-18 22:38:09 +02:00
SITO
82b6c3ae05 upstream: integrar 0.9.5 en 34 ficheros mas y portar los modulos nuevos
Segunda tanda. Resueltas por merge de tres vias 15 divergencias que no solapaban,
y aplicada la version de 0.9.5 en 19 vistas y modelos donde la rama solo arrastraba
codigo antiguo (firmas viejas de renderProductForm, renderJobForm, listTags sin
search, y el import sin renderSpreadEditWarning). Se preserva la eliminacion de
housing donde la rama lo habia descartado: modules_view, blockchain_view,
search_model y tags_model.

Modulos nuevos del dev portados: blog, data, gallery, mentions, polls, workflows,
comments, recurrence y media_gallery, mas contentPdf, pdfDocument y content_favorites.

main_views recupera renderEngagement, spreadsFor y renderSpreadEditWarning, que la
rama habia eliminado y que las vistas de 0.9.5 necesitan. Sin ellas polls_view
lanzaba TypeError al editar.

El overlay de i18n pasa de 15 a 41 claves (46 en castellano): incorpora las 9 que
upstream tenia en 0.9.1 y elimino en 0.9.5 mientras nuestro codigo las sigue usando
(menuBlogs, spreadHint, chatShareUrl, forumFilterHot, publishBlog...) y las 17 de
Karvan, que nunca existieron y tiraban de texto de reserva en ingles; ahora estan
traducidas al castellano.

Verificado con la app arrancada: declara 0.9.5, 0 claves i18n sin resolver (antes
26), 26 rutas responden 200/302, y la interfaz de la rama sale intacta: 10
categorias y 47 modulos en los hexagonos, topbar con Personal/Community y avatar,
7 accesos rapidos en la barra inferior y 0 etiquetas vacias (antes 1, la de Blogs).
2026-08-18 22:03:41 +02:00
SITO
f964e61a1c upstream: integrar 0.9.5 en lo que no diverge (57 ficheros) + dependencias nuevas
Primera tanda de la integracion 0.9.1 -> 0.9.5. Se actualizan los 57 ficheros que
upstream cambio y la rama no tocaba, entre ellos los 11 de traduccion: eso ya solo
es posible gracias al overlay de i18n, antes eran 11 conflictos garantizados.

Se portan ademas las dependencias que 0.9.5 introduce y de las que dependen los
ficheros actualizados: pdfDocument, content_favorites, recurrence, media_gallery,
comments_view, gallery_view y polls (modelo, limites y vista).

Quedan 48 ficheros con divergencia real por resolver a mano (backend.js,
main_views.js, OasisMobile.css y 45 vistas y modelos).

Verificado: 0 errores de sintaxis en src/, 0 requires relativos rotos, la app
arranca declarando 0.9.5 y las 47 rutas principales responden 200/302.
2026-08-18 21:42:59 +02:00
SITO
b3986b12a0 backend: llevar tambien bottombar y legacy/export al router del fork
Quedaban tres rutas propias sueltas dentro de la cadena de upstream:
/settings/bottombar (GET y POST, la personalizacion de la barra inferior) y
/legacy/export en GET, que upstream solo tiene en POST.

Con esto backend.js queda sin ninguna ruta de la rama: 8899 lineas frente a las
9067 de partida, y el unico rastro son las 4 lineas del require de fork_routes
en el array de middleware.

Verificado: el router monta las 11 rutas y backend.js no conserva referencias
a identificadores movidos.
2026-08-18 21:34:09 +02:00
SITO
e5e8e53374 backend: mover las rutas de Karvan a un router propio
backend.js concentra las 577 rutas de upstream en una sola cadena fluida y se
mueve +905/-298 lineas por release. Las 8 rutas de Karvan, su modelo, el import
de vistas y las tres funciones de relay cross-device vivian intercaladas ahi,
asi que cada integracion las ponia en conflicto.

Pasan a src/backend/fork_routes.js, un router propio que se inserta en la cadena
de middleware justo antes del router de upstream; lo que no casa cae a next().
Las dependencias (cooler, pull, pmModel, checkMod, getViewerId) se inyectan en
vez de reconstruirse, para no abrir un segundo cliente muxrpc contra el sbot.

backend.js pasa de 9067 a 8932 lineas. Quedan 2 lineas de enganche en lugar de 137.

Verificado: el router monta las 8 rutas (/karvan, /karvan/create, /karvan/:id,
/karvan/:id/invite, /karvan/:id/msg, /karvan/:id/msgs y /karvan/:id/signal en GET
y POST) y no quedan referencias huerfanas en backend.js.
2026-08-18 21:32:06 +02:00
SITO
9321718d90 i18n: devolver los 11 ficheros de idioma a la version de upstream
Con el overlay en su sitio, la rama ya no necesita editarlos. Los 11
oasis_*.js quedan identicos a upstream 0.9.1, asi que al integrar una version
nueva se copian tal cual en lugar de resolver conflictos sobre ~13.000 lineas.

El overlay recoge ademas los 6 textos del grupo spread en castellano que la
rama corregia dentro de oasis_es.js.

Efecto lateral asumido: vuelven las 86 claves de modulos que la rama no usa.
Son texto muerto, no cambian comportamiento.

Verificado: las claves propias resuelven en los 11 idiomas y la interfaz
renderiza identica (419 / 799 / 7799 bytes en topbar, barra inferior y hexagonos).
2026-08-18 21:18:06 +02:00
SITO
3fe71a0ec2 views: mover topbar, barra inferior y hexagonos fuera de main_views
main_views.js es el fichero de upstream con mas movimiento despues de backend.js
(+256/-502 lineas de 0.9.1 a 0.9.5). La interfaz propia de la rama vivia dentro,
asi que cada integracion de una version nueva la ponia en riesgo.

renderMobileTopbar, renderBottomBar, renderHiveNav y PINNABLE_MODULES pasan a
src/views/fork/hive_nav.js, una factory que recibe i18n por referencia (main_views
lo muta sin reasignarlo). En main_views quedan 8 lineas de enganche en lugar de 168.

Verificado que el HTML renderizado es identico byte a byte antes y despues, en las
tres funciones y con los filtros personal, community y sin filtro.
2026-08-18 21:15:23 +02:00
SITO
61273dfeec i18n: mover las claves propias a un overlay fuera de los ficheros de upstream
Las 15 claves anadidas por la rama (barra inferior bb*, menu Personal/Community,
karvanTitle, peerLastChange, filter) vivian editadas dentro de los 11
src/client/assets/translations/oasis_*.js. Upstream reescribe esos ficheros en
cada release (de 0.9.1 a 0.9.5 elimino 849 claves), asi que cada version las
ponia en conflicto.

Ahora estan en translations/fork/i18n_fork.js y se fusionan desde i18n.js. Los
11 ficheros de idioma pueden reemplazarse por los de upstream sin perder nada.

Verificado cargando i18n.js con los oasis_*.js de 0.9.5 sin modificar: las 15
claves resuelven en los 11 idiomas y las nuevas de upstream siguen disponibles.
2026-08-18 21:12:05 +02:00
s1to
6020c7f8df [0.9.1] FORK_IA_UX: Fase 2 cross-device — relay Karvan messages over SSB private msgs
Makes a Karvan room work between two different phones once they're connected to a pub:
- karvan_model: rooms track remoteFeeds (SSB ids of other participants); addRemoteFeed/
  getRemoteFeeds/anyRemoteRooms (feed-id validated, capped at MAX_MEMBERS).
- backend: inviting registers the invitee's feed; joining by link registers the inviter's
  feed (read from the invite PM). Posting a message publishes a private 'karvan-relay' to
  those feeds; an efficient LIVE log-stream subscriber (createLogStream old:false live:true)
  ingests incoming relays and injects them into the local mirror room (dedup by mid,
  skips own → no loop). WebRTC SIGNAL frames are NOT relayed (too many/slow for the log).
- Chosen over a muxrpc plugin because a classic pub only replicates the LOG (store-and-forward);
  live muxrpc wouldn't reach the other phone. No SSB-startup changes → no boot risk.
Verified: boot OK with the live stream, invite/adopt register feeds, message posts + relay
publishes, server stays up, 16/16 tests. Cross-device delivery needs the user's 2-phone+pub test.
Note: text chat only — video still needs the wrapper camera permission (Fase 3).
2026-08-07 21:06:13 +02:00
s1to
2c2c8f241d [0.9.1] FORK_IA_UX: review pass — remove dead code + Karvan security hardening
Dead code (from the abandoned thumb-zone/Explore-sheet nav experiment):
- OasisMobile.css: removed ~90 lines of orphaned CSS (.oasis-bottombar-fork/.bb-fab-*,
  .hive-sheet*/.fork-sheet*/.hsq-*, .omt-spacer, .hive-sheet-seg/.hs-seg, dead
  .oasis-bottombar-fixed) — no element emits any of them.
- main_views.js: removed renderHiveSheet() (never called; sole emitter of that CSS).
- karvan_view.js: dropped dead export karvanShortId + unused karvanView param.
- Added a style for .karvan-msg-live (client emitted it with no rule).

Karvan security (from the review):
- GET /karvan/:id only adopts a mirror room on a real navigation (sec-fetch-dest
  document / Accept text/html), not on <img>/subresources → fixes a CSRF that could
  spam/evict the user's ephemeral rooms.
- Cap room.members at 50 (was unbounded; each poll echoed it back).
- Reject signal payloads >16KB (SDP/ICE are tiny) — anti memory-DoS.
- karvan.js: guard malformed {kind:desc} signals so one bad signal can't abort a poll batch.
Tests: 15/15 (added members-cap + oversized-payload). Verified: CSRF fix (nav=200,
subresource=302), pages unchanged after CSS removal, boot clean.
2026-08-07 20:42:53 +02:00
s1to
af160dbd26 [0.9.1] FORK_IA_UX: Fase 2 (invite) — invite a contact to a Karvan room by feed id over SSB
Reuses Oasis's own private-message invite pattern (like the industry module:
pmModel.sendMessage([feed], 'KARVAN_INVITE', '... -> /karvan/<id>')), so the invite
lands in the contact's inbox with a link — no new SSB code, no touching the SSB
startup. Joining a room by link adopts a local mirror (karvanModel.adoptRoom, the
id is the capability). Real-time cross-device signaling (media/text sync between
the two mirrors) is the remaining Fase 2 piece (muxrpc ephemeral), deferred.
Verified single-node: invite publishes to the inbox, validation, adopt, form; 13/13
model tests (added adoptRoom).
2026-08-07 20:27:45 +02:00
s1to
813bf16958 [0.9.1] FORK_IA_UX: tests for the Karvan ephemeral-rooms model (12 cases, no framework)
Oasis ships no test runner, so this is a dependency-free node script (node
test/karvan_model.test.js). Covers rooms, ephemeral messages, the 250-msg ring
buffer, truncation/validation, self-destruct by idle+absolute TTL, idle reset on
activity, MAX_ROOMS eviction, and the WebRTC signaling mailbox. Lives in test/
(outside src/), so it is not bundled into the APK.
2026-08-07 20:01:42 +02:00
s1to
e2598ab747 [0.9.1] FORK_IA_UX v7: Karvan calls Fase 1 — video/mic UI + media over the existing WebRTC
Adds a call panel to the Karvan room (local video + remote gallery + call/mic/cam/hang-up
buttons) and grafts getUserMedia + addTrack + ontrack onto the peer connections we already
use for the text data-channel (perfect negotiation handles the renegotiation). No backend,
no new !important. Verified: getUserMedia path attaches audio+video tracks to the local
video and activates the panel (chromium fake device). getUserMedia will fail gracefully on
the current wrapper (no camera/mic permission) with a clear message — the chat keeps working.
Fases 2 (SSB cross-device signaling) and 3 (wrapper permissions) pending.
2026-08-07 19:44:37 +02:00
s1to
423e973f02 [0.9.1] FORK_IA_UX v6: show the hive only on the home, keep content pages clean
The hexagon hive was rendered in the header on EVERY page (~210px), pushing real
content far down on Peers/Market/Inbox/etc. Now a middleware flag
(__OASIS_SHOW_HIVE__ = path is /activity or /) gates renderHiveNav so the hive is
the home hub only; other pages get just the fixed topbar and their content up top.
Personal/Community and the bottombar still navigate everywhere.
2026-08-07 19:04:25 +02:00
s1to
634625d134 [0.9.1] FORK_IA_UX v5: drop 29 !important by replacing the CSS (no visual change)
Per feedback (avoid !important, substitute the CSS). Verified with computed-style
diffs on the real forum/chat DOM — layout is byte-identical.
- OasisMobile.css: 45 -> 23 !important. Removed the ones that only fought epsylon's
  NARROW rules; win by load-order/specificity instead (e.g. .forum-score-box
  .forum-score-form, .main-column .new-message-form). Kept the truly load-bearing
  ones (inline-style overrides [style*=...], round buttons vs the broad
  button{min-height:44px!important}, and comment-body-row vs mobile.css:164).
- mobile.css (FORK ONLY, user-authorized): removed !important from 7 narrow
  forum/chat rules (.forum-comment margin/padding, .comment-body-row flex-dir,
  .comment-vote/text-col width, .forum-score-* flex, .comment-textarea width) so
  our theme wins without !important. Not touched on UX_OASIS.
2026-08-06 17:27:43 +02:00
s1to
b31b8cbfbd [0.9.1] FORK_IA_UX v4: restore the customizable bottom bar (pins + edit pencil + fixed Peers/Invites), drop the + FAB
Per feedback: bring the bottom bar back to the design that was liked several
versions ago — user-pinned modules, an edit pencil to /settings/bottombar, and a
fixed Peers/Invites pair on the right. The central + FAB is removed. The hive
returns to the header (top) so Personal/Community filter the visible hexagons; the
Explore bottom-sheet is retired. Karvan module kept.
2026-08-06 16:55:40 +02:00
s1to
81be3e6c21 [0.9.1] FORK_IA_UX v3: restore working Personal/Community in the top bar
The top bar was left empty (logo + avatar only) after moving the filter to the
Explore sheet — it looked unbalanced/broken. Since the sheet no longer blocks the
top (visibility:hidden), Personal/Community are brought back to the top bar where
they fill the space and now work; the duplicate segmented control is removed from
the Explore sheet.
2026-08-06 16:47:40 +02:00
s1to
e938c8bb42 [0.9.1] FORK_IA_UX v2: fix top-bar (visibility sheet) + Karvan module (ephemeral messages + WebRTC)
Navigation fixes:
- The bottom "Explore" sheet no longer blocks the top bar: closed state is
  visibility:hidden (out of hit-testing), so the top identity buttons and page
  content always receive taps (fixes "top buttons don't work").
- Top bar is now identity only (logo, avatar). The Personal/Community filter
  moved into the Explore sheet, next to the hive, as a segmented control.

New module "Karvan" (self-contained; inspired by karvan-protocol ephemeral rooms):
- Ephemeral/temporary chat rooms held only in RAM, self-destructing on idle
  (30 min) / absolute (2 h) TTL — nothing is written to disk.
- Server relay chat (RAM + polling) as the reliable path, plus a WebRTC
  data-channel layer (perfect negotiation, HTTP signaling mailbox) for instant
  P2P delivery; degrades to the relay if WebRTC is unavailable. Data-channel
  only (no camera/mic) so no wrapper permission changes are needed.
- Files: models/karvan_model.js, views/karvan_view.js, client/public/js/karvan.js;
  routes /karvan* + karvanMod config + nav entry (network) + karvanTitle i18n (11 langs).

Additive and gated by OASIS_MOBILE / karvanMod; Linux and the UX_OASIS branch untouched.
2026-08-06 16:38:36 +02:00
s1to
745c340917 [0.9.1] FORK_IA_UX: thumb-zone navigation — hive bottom-sheet + publish FAB + reordered bar
Mobile-usability experiment grounded in real studies (Hoober 2013,
Bergstrom-Lehtovirta CHI 2011, Parhi 2006 target size, NN/g 2016 visible nav):
- The hive (categories + modules) opens from the bottom bar as a thumb-reachable
  bottom-sheet (checkbox-hack, no JS), freeing the top for content.
- Bottom bar reordered: Explore, PM, (+) publish FAB, Search, Inbox; the central
  FAB is the distinct, elevated primary action.
- Network/status quick row (Peers, Graphos, Inbox, Settings) inside the sheet.
- Tap targets >=48px. Additive and gated by OASIS_MOBILE (Linux untouched).
2026-08-06 12:00:18 +02:00
s1to
ed9f01e8e6 [0.9.1] Mobile: fixed topbar, global hive, per-message chat reply/switcher, forum subtopics, square module grid
- Topbar: single fixed line [logo][Personal][Community][avatar] in one dark
  tone (#121212) with gold accents only; the hive hexagons scroll below it.
- Hive: Personal/Community filter the home hive from any page; opening a
  category shows its modules as a 2x2 grid of square buttons (a lone odd
  button is centered instead of left-aligned).
- Chats: per-message reply (?replyTo=), chat switcher strip and quote banner;
  the message rate limit is lifted on mobile.
- Forums: optional subtopics (?topic=) with threaded rendering.
- Bottombar: fixed Inbox - PM - Write - Search - Graphos - Peers.
2026-08-05 23:36:18 +02:00
s1to
2b2c65fc7a [0.9.1] Mobile: Signal-style chats + compact threaded forums + circular reply
- Chats/PM: message bubbles (own messages right with accent, others left),
  compact meta, all via the existing .chat-message-self hooks — no view changes.
- Forums: shrink the oversized vote widget to a small horizontal bar, flatten
  the nested boxes into one clean comment block, indent replies by depth so
  threads read as threads, tone down the highlighted-reply frame.
- Reply/compose: Signal-style row — a growing text box plus a round send
  button with a ↩ / ➤ glyph (CSS ::before), instead of the full-width bar.
- All in the OasisMobile theme (loads after mobile.css so it wins by cascade);
  where epsylon forces !important on the forum layout we match it, scoped to
  the forum/chat widgets. No client JS, no changes to any feature view.
2026-08-04 14:02:12 +02:00
s1to
7f85389a88 [0.9.1] Mobile: Personal/Community filter the home hive from any page
- Turn the header Personal/Community tabs into links to
  /activity?hive=personal|community, so they work on every page (they take
  you to the home and filter the hexagons), not only while already on
  /activity — no more dead buttons off the home.
- Drop the "All" tab: entering shows all 10 categories; clicking a filter
  that is already active toggles it off and shows all again.
- Filter the hive server-side (renderHiveNav reads the request filter set
  by the middleware) instead of the CSS radio-hack, and drop the now-dead
  half-filter CSS. Active tab is highlighted via the .active class.
2026-08-04 11:46:19 +02:00
s1to
d5dac39bfd [0.9.1] i18n: localize the new keys across all 11 languages
Fixes the previous commit, which only added the new UI keys (menuCommunity,
menuAll, filter, bb*) to English and thus broke the per-language consistency
check. Add them (plus peerLastChange) to all 11 language files — Spanish
translated, the rest English placeholders to be localized — so every
language has the same key set. i18n test back to green (12/12).
2026-08-04 11:25:50 +02:00
s1to
c088d176c1 [0.9.1] Mobile usability: filter hexagons by tab, collapse activity filters
- Personal/Community/All header tabs now filter the home hexagon nav (default
  All shows the 10; Personal/Community show their 5) via the CSS radio-hack.
  Removed the square-accordion sidebars (.sidebar-left/.sidebar-right) they
  replaced.
- Wrap the dense activity filter strip (RECENT/ALL/MINE/…) in a collapsible
  FILTER dropdown, hidden by default, keeping the hexagons visible above.
- Add the missing English i18n keys (menuCommunity, menuAll, filter, bb*).
2026-08-04 11:07:48 +02:00
s1to
871cccbe17 [0.9.1] Bump to 0.9.1 (mobile navigation advances) and slim the README
Version bump to 0.9.1 in package.json (root and src/server, the one the
app reports) reflecting the mobile navigation work over Oasis 0.9.0:
topbar, hexagon nav and customizable bottom bar. Replace the README
header with a short 0.9.1 note.
2026-08-01 21:27:22 +02:00
s1to
0d26562450 [0.9.0] Fold the mobile UX theme into OasisMobile.css (no separate file)
Move the theme content into epsylon's existing OasisMobile.css and drop
the separate OasisMobileUX.css, so there is no new theme file: his mobile
theme just carries the UX styles now. Set themes.current back to
OasisMobile. Same visual result; on mobile builds the theme still loads
after mobile.css so it wins by cascade without !important.
2026-08-01 21:02:02 +02:00
s1to
4085f35a41 [0.9.0] Describe the Oasis Mobile UX build in the README
Add a short header describing this as the Oasis 0.9.0 Android UX layer
(topbar, hexagon nav, customizable bottom bar) delivered as a selectable
theme on top of epsylon's Oasis, keeping mobile.css/OasisMobile.css
untouched. The original Oasis README follows below.
2026-08-01 18:18:57 +02:00
s1to
1f7d51e6b1 Harden mobile quick bar: render only on mobile, drop inline style
renderBottomBar now returns early when OASIS_MOBILE !== '1' (same guard
as renderHiveNav), instead of emitting an inline style="display:none" to
hide it on desktop. The localhost CSP is style-src 'self' without
unsafe-inline, so that inline attribute was blocked there and the bar
could show on the Linux build. Rendering the bar only on mobile removes
the inline style entirely and keeps desktop unaffected.

No change to mobile output (the bar already rendered without inline
style on mobile). Follow-up from the UX security review.
2026-08-01 15:14:18 +02:00
s1to
8ab1cf6d18 Fix mobile topbar avatar: populate self picture
renderMobileTopbar read global.__OASIS_SELF_AVATAR__, but nothing ever
set it, so the top-right circle always fell back to the default avatar
(noticeable in the start/welcome flow, where the profile card showed the
real picture but the topbar circle did not).

Populate the global in the throttled refresh middleware from
about.image(self): build /image/256/<blob> when a picture exists, else
leave it null so the topbar uses the default avatar.
2026-08-01 14:57:37 +02:00
s1to
230d11ba7a Clean up Oasis-Mobile-UX theme: cascade order, drop dead JS
- Link the theme after mobile.css on mobile builds (OASIS_MOBILE=1) so
  it wins by cascade; removed ~420 !important, keeping only the few that
  override epsylon's inline styles.
- Reveal the quick bar without !important: the inline display:none is
  emitted only on desktop.
- Remove unused oasis-mobile-nav.js (it was never linked and would be
  blocked by CSP script-src 'none' on clearnet).
- epsylon's mobile.css and OasisMobile.css left untouched.
2026-08-01 14:41:49 +02:00
s1to
8d21216362 Upgrade mobile UX to Oasis 0.9.0
Rebase onto upstream 0.9.0. Mobile topbar (logo->home, avatar->profile),
hexagon category nav (hive) and bottom bar; remove the six top-bar
buttons (inbox/pm/publish/search/graphos/peers) that cluttered the header.
Theme OasisMobileUX carries topbar/hexagon/bottombar styles.
2026-08-01 13:54:39 +02:00
s1to
de36b38851 Fix Oasis-Mobile-UX theme not loading on mobile
Load the theme unconditionally on mobile and ship it as the default,
so the mobile styles (scroll fix, hexagons, bottom bar) always apply.
2026-07-17 17:03:47 +02:00
s1to
9ff72a17a0 Add Oasis-Mobile-UX theme with hexagon navigation
- Selectable Oasis-Mobile-UX theme; mobile.css kept intact
- Hexagon category launcher on home (10 categories with expandable modules)
- Customizable bottom bar (up to 4 shortcuts, Peers/Invites fixed)
- Empty states and collapsible activity filters
2026-07-17 12:57:50 +02:00
s1to
89a11fd789 Fix vertical scroll on mobile 2026-07-11 15:09:11 +02:00
345 changed files with 115666 additions and 42703 deletions

68
.gitea/workflows/apk.yml Normal file
View file

@ -0,0 +1,68 @@
# Construye la APK y la publica en las releases.
#
# Con un tag v* se publica directamente: crear el tag ya es la decision de sacar
# version, y es la que enlaza el README (releases/latest). Lanzada a mano desde
# la interfaz se queda en borrador, para revisarla antes.
#
# NO se dispara sola con cada commit: la APK son mas de 100 MB y el backend tarda
# en empaquetarse. Se lanza a mano desde la interfaz de gitea, o al crear un tag.
#
# El runner necesita: JDK 17 con compilador, el SDK de Android (plataforma 35 y
# build-tools), node, zip y unzip. Un runner pelado NO vale.
#
# Secretos que hay que dar de alta en el repositorio:
# OASIS_KEYSTORE_B64 el almacen de claves en base64
# OASIS_KEYSTORE_PASS su contraseña
# OASIS_KEY_ALIAS el alias (por defecto "oasis")
#
# El token para publicar lo pone gitea solo en GITEA_TOKEN.
name: APK
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # release.sh cuenta commits para el versionCode
- name: Traer el APK oficial de la misma version (node_modules y libnode.so)
run: |
v=$(node -p "require('./src/server/package.json').version")
curl -sfL -o /tmp/oasis-base.apk \
"https://github.com/epsylon/oasis/releases/download/oasis-android-v$v/oasis-v$v.apk"
echo "OASIS_APK_BASE=/tmp/oasis-base.apk" >> "$GITHUB_ENV"
- name: Restaurar el almacen de claves
run: |
echo "${{ secrets.OASIS_KEYSTORE_B64 }}" | base64 -d > /tmp/oasis.jks
chmod 600 /tmp/oasis.jks
- name: Construir, verificar y publicar
env:
JAVA_HOME: /usr/lib/jvm/temurin-17-jdk-amd64
ANDROID_HOME: /opt/android-sdk
OASIS_KEYSTORE: /tmp/oasis.jks
OASIS_KEYSTORE_PASS: ${{ secrets.OASIS_KEYSTORE_PASS }}
OASIS_KEY_ALIAS: ${{ secrets.OASIS_KEY_ALIAS }}
OASIS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
OASIS_GITEA_URL: ${{ github.server_url }}
OASIS_GITEA_REPO: ${{ github.repository }}
run: |
cd android
if [ "${{ github.ref_type }}" = "tag" ]; then
./scripts/release.sh --release --publish --public
else
./scripts/release.sh --release --publish
fi
- name: Borrar el almacen de claves
if: always()
run: rm -f /tmp/oasis.jks /tmp/oasis-base.apk

5
.gitignore vendored
View file

@ -1 +1,6 @@
node_modules/
# pruebas en local: el node_modules suele ser un enlace y el estado de la llamada lo escribe el movil
src/server/node_modules
.phone_state.json
android/.tools/

228
GUIA_DE_DESARROLLO.md Normal file
View file

@ -0,0 +1,228 @@
# Guía de desarrollo
Notas para quien vaya a meter mano en la rama del móvil. Leedlas antes de empezar,
que nos ahorramos disgustos.
Esta rama va encima de Oasis, que es de epsylon. Él saca versión casi cada día
(39 entre la 1.0.1 y la 1.3.3, en siete semanas) y cada versión llega en un solo
commit. Si metemos cosas en sus ficheros, en cada integración hay que volver a
pelearse con ellas. Por eso la idea es sencilla: lo de Oasis lo cambia él; nosotros
cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.
Los números y de dónde salen están en `docs/FRONTERA_UPSTREAM.md`.
## Lo que no se toca
- `src/backend/backend.js`. Cambia en 34 de cada 39 versiones. Ni rutas nuevas, ni
middleware, ni envolver funciones suyas como `checkMod`.
- La cadena de middleware del final de `backend.js`. Crece en cada versión.
- Los modelos de `src/models/`, salvo el nuestro (`fork_workflows.js`). Ahí se decide qué se publica en la red y cómo se cuenta.
Si lo cambiamos, nuestro móvil cuenta distinto que el escritorio de al lado. Un
ejemplo real: quien crea una votación no puede votar en ella. No nos gusta, pero
es una regla del modelo, así que se explica en pantalla y punto.
- `src/server/` (arranque de SSB, `ssb_config.js`, `package.json`).
- `src/backend/request_guards.js`, que lleva la CSP. Nada de `style=""` ni de
`<script>` en la página.
- Las traducciones del dev, `translations/oasis_*.js`. Se reescriben casi en cada
versión.
- `styles/style.css` y `styles/mobile.css`.
- Los temas del dev: Dark, Clear, Purple, Matrix y también `OasisMobile.css`, que
aunque parezca nuestro es suyo (lo toca en 21 de 39 versiones).
## Las vistas del dev: una línea y ya
`src/views/*_view.js` y `main_views.js` son del dev y las reescribe a menudo
(`tribes_view.js` en 23 versiones, `main_views.js` en 31). Si en una pantalla suya
hace falta otro HTML en el móvil, el código va en `src/views/mobile_ui.js` y en la
vista solo queda la llamada:
```js
const mobileUi = require('./mobile_ui');
// ...
if (mobileUi.isMobile()) return mobileUi.tribeTabs(tribe, section, sections);
```
Cuando el dev reescribe la vista, se vuelve a poner esa línea y listo. En escritorio
no cambia nada porque la bandera `OASIS_MOBILE` solo la pone `main.js` en el móvil.
Ahora mismo hay enganches en `main_views.js`, `tribes_view.js`, `vote_view.js`,
`settings_view.js` y `welcome_view.js`. Para verlos: `grep -n mobileUi src/views/*.js`.
## Llamadas: las del dev
La rama tuvo un módulo de salas y llamadas propio (Karvan). Se quitó el 19/10/2026
porque el dev ya trae Teléfono y Salas. En Android el sonido y la cámara pasan por un
puente nativo de la app del dev (`.audio_bridge`; los ficheros
`src/server/phone_*_android.js` también son suyos). Por eso la APK se monta sobre la
oficial con `android/scripts/sobre_oficial.sh`: su app tal cual, nuestro backend
dentro y paquete propio. Si falta algo en las llamadas, se le pide al dev, no se monta
otro módulo.
Ojo al comparar con el dev: para el móvil la referencia es el `nodejs-project` de su
APK oficial, no su repositorio git. Hay ficheros, como `SSB_server.js` o
`phone_module.js`, cuya versión de móvil solo está en la APK.
Lo mismo vale para cualquier módulo: antes de hacer uno propio, mirad si el dev ya lo
tiene o lo está haciendo.
## Navegación
Al abrir se ve la actividad. Los módulos están en la quinta pestaña de abajo, Menú,
que abre una hoja con el lanzador (`hive_views.js`: una casilla y CSS). Un script
mínimo, `public/js/mobile_menu.js`, hace que el botón atrás de Android cierre la hoja;
sin él el menú funciona igual. La barra tiene siempre cuatro atajos elegidos por cada cual más Menú. Por qué
así: `docs/ESTUDIO_NAVEGACION.md`. Si se cambia, que sea con una prueba que lo
justifique, no por gusto.
## Lo que es nuestro
Aquí se puede hacer lo que haga falta.
- `src/client/assets/styles/mobile_base.css`: la maqueta del móvil (lanzador, barras,
tarjetas, formularios). Se carga siempre en el móvil, antes del skin.
- `src/client/assets/styles/mobile_ux.css`: márgenes, letra, botones y todas las
piezas nuevas. Vale para los dos skins de móvil.
- `src/client/assets/themes/Acampada.css` y `styles/hive_acampada.css`: el skin
Acampada.
- `src/views/mobile_ui.js`: lo que se enchufa en vistas del dev.
- `src/views/mobile_modules_view.js`, `identities_view.js`: pantallas propias.
- `src/views/hive_views.js`: lanzador y barras de arriba y abajo.
- `src/views/mobile_icons.js`: los iconos.
- `src/backend/fork_routes.js`: nuestro router. Va justo delante del del dev.
- `src/backend/mobile_layer.js`: lo que nuestra interfaz necesita del backend
(avatar, filtro y lanzador de la barra de arriba). La monta `fork_routes.js`.
- `src/models/fork_workflows.js`: nuestros workflows.
- `src/client/assets/translations/i18n_fork.js`: nuestros textos.
- `src/client/assets/images/ux-*.svg`: nuestras imágenes.
- `android/scripts/sobre_oficial.sh`: monta la APK sobre la oficial del dev. El resto
de `android/` (el proyecto Gradle propio) queda como estaba, sin uso.
## Cómo hacer cada cosa
Cambiar cómo se ve algo. Se busca la clase en el HTML (servidor local e inspector) y
se escribe la regla en `mobile_ux.css`. Para los colores usad las variables `--ux-*`
(`--ux-bg`, `--ux-fg`, `--ux-accent`, `--ux-muted`, `--ux-line`, `--ux-danger`), que
tiran de Acampada si está y si no de la paleta de OasisMobile. Si la regla no gana,
es que `mobile.css` o `Acampada.css` mandan con `!important` y selectores largos:
hay que subir la especificidad y poner `!important` también. Ojo con una trampa:
`Acampada.css` quita el borde a todos los `div`, así que para ponérselo a uno hace
falta `.main-column div.la-clase`.
Letra: Anton para títulos y para el botón negro principal de cada pantalla
(`.create-button`). Lo demás, letra del sistema en negrita.
Márgenes: 14 px a cada lado y ya. Los contenedores de en medio sin relleno, que
antes se iban sumando y una ficha perdía 74 px por lado.
Cambiar el HTML de una pantalla del dev. Función en `mobile_ui.js` y una línea en la
vista, como arriba.
Una pantalla o una ruta nueva. En `fork_routes.js`. Lo que no atiende nuestro
router pasa al del dev con `next()`:
```js
.get('/modules', async (ctx, next) => {
if (!isMobile()) return next(); // escritorio: la del dev
ctx.body = mobileModulesView({ ... });
})
```
Retocar lo que contesta el dev. También en `fork_routes.js`, en `wrapUpstream`: deja
que el dev conteste y luego mira la respuesta. Así sabemos a qué sección apagada se
quería entrar (el dev solo manda a `/modules`) o añadimos `?saved=1` al guardar.
Textos. En `i18n_fork.js` y en los 11 idiomas (en, es, fr, eu, de, it, pt, zh, ar,
hi, ru). Antes, mirad que la clave no exista ya en `oasis_en.js`, porque si existe la
estaríais cambiando también en escritorio. En el código siempre con texto de reserva:
`i18n.miClave || 'My text'`.
Un workflow. En `fork_workflows.js`, que lo mete en las listas que exporta el modelo
del dev. No se toca `workflows_model.js`.
Un icono. En `mobile_icons.js`, trazo de 2 px sobre rejilla de 24 y `currentColor`. El
nombre es el de la ruta: `/tribes` es `tribes`.
## Antes de hacer commit
- Que `git diff --stat` no toque nada de la lista de lo que no se toca. En las vistas
del dev, solo líneas de enganche.
- `node --check` en los `.js` que hayáis cambiado.
- Arrancar en local y ver que `/activity`, `/tribes`, `/modules`, `/settings`,
`/votes`, `/phone`, `/rooms` e `/inbox` cargan.
- Mirarlo a 412 px de ancho, y a 360 si es algo ancho.
- Si es CSS nuevo, mirarlo también en un WebView viejo (Android 9 trae el 69): no
hay `:has`, ni `gap` en flex, ni `inset`, ni `dvh`. Lo que dependa de eso va con
alternativa; hay un bloque `@supports not (inset: 0)` al final de `mobile_ux.css`.
En emulador x86 la APK no arranca por debajo de Android 12 (el traductor ARM no
sabe ejecutar el node del dev), así que el viejo se prueba con el servidor local y
el navegador de pruebas del WebView.
- Los textos nuevos, en los 11 idiomas.
- Nada en línea, que la CSP lo bloquea.
- Commit en castellano, sin coautorías de herramientas.
- Sin claves, contraseñas ni nada de `.ssb`.
## Cuando sale versión nueva del dev
1. Lo suyo se coge tal cual, y para los ficheros con versión de móvil, la de su APK
oficial (`assets/nodejs-project.zip`), no la de su git.
2. En sus vistas se vuelven a poner los enganches. El `grep -n mobileUi` sobre la
versión anterior de la rama dice dónde iban.
3. Se repite la medición de `docs/FRONTERA_UPSTREAM.md`: qué ha tocado y si ha
desaparecido alguna clase que usen nuestras hojas.
4. Se repasan a 412 px las pantallas de la lista de arriba.
5. Se monta la APK sobre la oficial de esa misma versión:
`OASIS_VERSION=x.y.z android/scripts/sobre_oficial.sh`.
## Probar en local
```sh
HOME=/tmp/oasis-prueba OASIS_NETWORK_PAUSED=1 node main.js
```
Y en el navegador `http://localhost:3000` con el inspector en modo móvil a 412x915.
Va con una identidad de usar y tirar y con la red parada.
Cosas que pasan:
- `src/server/node_modules` no está en git. Sirve el de la APK base.
- Si al reiniciar sale "Another Oasis instance is already running", el anterior sigue
vivo y estáis viendo código viejo. Matadlo primero.
- Al arrancar desaparecen `src/configs/banking-eco-history.json` y
`fediverse-accounts.json`. Antes del commit, `git checkout -- src/configs/`.
En el emulador (`Pixel_9_API_36` de Android Studio), la APK de depuración deja
inspeccionar la WebView desde `chrome://inspect`.
## Lo que todavía está mal
Para el móvil, la referencia es el `nodejs-project` de la APK oficial del dev, no su
git: él mete cambios de móvil (en `backend.js`, `SSB_server.js`, `phone_module.js`...)
que solo están en la APK. Comparado con eso, la rama cambia 17 ficheros suyos, 561
líneas (19/10/2026). Por tamaño:
- `backend/blobHandler.js` (190): limpiar los metadatos de las fotos también en
Android. Es una función nuestra metida en su fichero; habría que proponérsela al dev.
- `configs/oasis-config.json` (87) y `configs/config-manager.js` (18): la config de
serie del móvil (tema Acampada, workflow acampada, barra de abajo).
- `views/main_views.js` (62), `settings_view.js` (34), `welcome_view.js` (11),
`tribes_view.js` (9), `vote_view.js` (7): enganches a `mobile_ui.js` y el orden de
carga de las hojas. Es lo que la guía permite, pero hay que revisarlo en cada versión.
- `main.js` (45): DNS del sistema primero, para respetar la VPN.
- `server/ssb_config.js` (30): varias identidades en el mismo móvil.
- `models/workflows_model.js` (17): la lógica de skins del móvil (`themeOf`,
`sameTheme`). `settings_view.js` coge `currentWorkflow` al cargar y no se puede
envolver desde fuera.
- `styles/mobile.css` (14), `bookmark_view.js` (13), `forum_view.js` (7),
`translations/i18n.js` (5, la mezcla con `i18n_fork.js`): arreglos sueltos de antes.
- `backend/backend.js` (2): la línea que monta nuestro router.
Cuando el dev saque versión, se repite esta comparación con su APK nueva.

472
README.md
View file

@ -1,328 +1,256 @@
# Oasis
Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application
that helps you follow interesting content and discover new ones.
![SNH](https://solarnethub.com/git/snh-oasis-logo3.jpg "SolarNET.HuB")
Oasis redefines what it means to be connected in the modern world, giving people
the ability to control their online presence and interactions without the need for centralized institutions.
----------
## Frontend:
Main features of the Oasis interface are:
+ Data manipulation is not permitted due to the use of BLOCKCHAIN technology.
+ No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
+ Use your favorite web browser to read and write messages to the people you care about.
+ Strong cryptography in every single point of the network.
+ You are the center of your own distributed network. Online or offline, it works anywhere that you are.
+ Initial identities are randomnly generated (no username or password required).
+ No personal profile generated (no questions about gender, age, location, etc …).
+ Automatic exif stripping (such as GPS coordinates) on images for better privacy.
+ No email or associated mobile phone required.
+ Automatic updates with new functionalities.
![SNH](https://solarnethub.com/git/snh-oasis-settings.png "SolarNET.HuB")
But it has others features that are also really interesting, for example:
+ Support for multiple languages.
![SNH](https://solarnethub.com/git/snh-oasis-languages.png "SolarNET.HuB")
+ Modularity to set your own environment.
![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
+ Support for multiple themes (including Mobile Theme).
![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB")
![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB")
![SNH](https://solarnethub.com/git/snh-matrix-theme.png "SolarNET.HuB")
And some other nice modules, like for example:
+ A complex Reddit-styled forum system.
![SNH](https://solarnethub.com/git/snh-forum.png "SolarNET.HuB")
+ Or a collaborative -offline- maps system.
![SNH](https://solarnethub.com/git/snh-maps.jpeg "SolarNET.HuB")
And much more, that we invite you to discover by yourself ;-)
![SNH](https://solarnethub.com/git/snh-games.jpeg "SolarNET.HuB")
----------
## Modules:
Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
+ Agenda: Module to manage all your assigned items.
+ AI: Module to talk with a LLM called '42'.
+ AINav: Module for natural-language queries about the network's content.
+ Audios: Module to discover and manage audios.
+ Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
+ BlockExplorer: Module to navigate the blockchain.
+ Bookmarks: Module to discover and manage bookmarks.
+ Calendars: Module to discover and manage calendars.
+ Chats: Module to discover and manage encrypted chats.
+ Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).
+ Courts: Module to resolve conflicts and emit veredicts.
+ Documents: Module to discover and manage documents.
+ Events: Module to discover and manage events.
+ Favorites: Module to manage your favorite content.
+ Fediverse: Manage your other fediverse accounts, including sending and receiving content.
+ Feed: Module to discover and share short-texts (feeds).
+ Forums: Module to discover and manage forums.
+ Games: Module to play and share your scores in various mini-games.
+ Governance: Module to discover and manage votes.
+ Graphos: Module to explore the network as an interactive map of peers.
+ Images: Module to discover and manage images.
+ Invites: Module to manage and apply invite codes.
+ Jobs: Module to discover and manage jobs.
+ Legacy: Module to manage your secret (private key) quickly and securely.
+ Latest: Module to receive the most recent posts and discussions.
+ L.A.R.P.: Module for a live-action role-playing layer with 9 houses.
+ Logs: Module to record (via AI assistant) your experiences.
+ Maps: Module to manage and share offline maps.
+ Market: Module to exchange goods or services.
+ Melody: Module to generate and share the "sound" of your blockchain.
+ Multiverse: Module to receive content from other federated peers.
+ Opinions: Module to discover and vote on opinions.
+ Pads: Module to manage collaborative encrypted text editors.
+ Parliament: Module to elect governments and vote on laws.
+ Pixelia: Module to draw on a collaborative grid.
+ Projects: Module to explore, crowd-funding and manage projects.
+ Popular: Module to receive posts that are trending, most viewed, or most commented on.
+ Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.
+ Shops: Module to manage and discover shops.
+ Summaries: Module to receive summaries of long discussions or posts.
+ Tags: Module to discover and explore taxonomy patterns (tags).
+ Tasks: Module to discover and manage tasks.
+ Threads: Module to receive conversations grouped by topic or question.
+ Topics: Module to receive discussion categories based on shared interests.
+ Torrents: Module to explore and manage torrents.
+ Transfers: Module to discover and manage smart-contracts (transfers).
+ Trending: Module to explore the most popular content.
+ Tribes: Module to explore or create tribes (groups).
+ Videos: Module to discover and manage videos.
+ Wallet: Module to manage your digital assets (ECOin).
# OASIS_MOBILE — rama de desarrollo experimental
> ## ⚠ ESTO NO ES OASIS OFICIAL
>
> Esta es una **rama de desarrollo experimental** mantenida por el hacklab, **no
> afiliada** al proyecto original.
>
> ### El repositorio oficial de Oasis es el de epsylon: https://github.com/epsylon/oasis
>
> Si quieres **usar Oasis de verdad, instala el oficial**. Lo que hay aquí puede
> romperse, cambiar de forma incompatible o desaparecer sin aviso. No está auditado y
> no tiene garantía de ninguna clase.
>
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.**
Si vas a desarrollar, lee antes la [guía de desarrollo](GUIA_DE_DESARROLLO.md). Ahí
está qué se puede tocar y qué no. En corto: el backend, el middleware, los modelos y
las traducciones son de epsylon y no se tocan; lo nuestro va en hojas, vistas y rutas
propias.
Versión base: Oasis 1.3.3 · Rama: `PRUEBAS` ·
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX)
![Oasis Mobile: lanzador, filtros y barra de atajos](docs/capturas/00-portada.png)
Both the codebase and the inhabitants can generate new modules.
[⬇ Descargar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/releases) ·
Android 7 o superior, 64 bits · se instala junto al Oasis oficial sin pisarlo
----------
### Novedades de esta versión
## C-AI (collective artificial intelligence)
- Al día con Oasis 1.3.3: karma en el perfil, salas con directo, fotos selladas en
chats privados y tribus, visibilidad de menciones y las traducciones nuevas.
- Arranca en modo acampada: chats, teléfono, salas, pads, foro, wiki, tribus,
parlamento, votaciones y eventos. El resto se enciende en *Módulos* o en
*Ajustes → Workflows*.
- Las llamadas son las de Oasis. Fuera Karvan, el módulo propio de salas: ahora se usan
el Teléfono y las Salas del dev, con su levantar la mano, grabación y emisión.
- La APK se monta sobre la oficial: se coge la del dev, se le cambia el backend por el
de la rama y se le da otro nombre de paquete. Así trae todo lo nativo del dev (el
puente de audio y cámara, las notificaciones de llamada) y convive con la oficial.
- Al abrir se ve la actividad, no un menú. Los módulos están en la quinta pestaña de
abajo, Menú, que abre una hoja desde abajo en cualquier pantalla.
- Votaciones como en asamblea: al crear una se elige Sí/No, semáforo (verde, amarillo,
rojo) o termómetro (del 1 al 5). Los resultados salen en barras.
- Borrar pide confirmación. Antes la X de una tribu la borraba de un toque.
- Iconos que se entienden en el lanzador y en la barra, en vez de letras del alfabeto
vai. La barra marca dónde estás y se esconde al escribir.
- Tribus en filas: doce comisiones caben en tres pantallas, no en diez. Dentro de una
tribu, las secciones van en una tira de fichas.
- Módulos con interruptores, agrupados como en el lanzador. Si entras en una sección
apagada, te lo dice y la enciendes de un toque.
- Pantallas más aprovechadas: un margen de 14 px en vez de los 74 que se sumaban, una
sola letra para botones, buscador en una línea y el buzón en pocas líneas por mensaje.
- Fotos más seguras: si una foto no se puede limpiar de metadatos (ubicación,
cámara…), no se sube y se avisa.
- DNS: primero el resolutor del sistema, que respeta la VPN y el DNS privado de
Android; los públicos quedan de respaldo.
---
## Lo que añade
| | Oasis oficial | Esta rama |
|---|---|---|
| Llamadas y salas | Teléfono y Salas, con micrófono y cámara | Los mismos, tal cual |
| Interfaz de móvil | El menú de escritorio en una pantalla pequeña | Tema Acampada, lanzador con iconos, fichas, barra de cuatro atajos |
| Primer arranque | Casi sesenta módulos encendidos | Modo acampada: lo justo para organizarse |
| Votaciones | Seis opciones fijas en una tabla | Sí/No, semáforo o termómetro, con barras |
| Varias identidades | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave |
| App Android | La del dev | La del dev, con el backend de la rama y paquete propio (`net.laenre.oasis`) |
| Metadatos de las fotos | Se quitan solo si está `sharp`; en Android nunca | Se quitan siempre: GPS, marca, modelo, número de serie |
Oasis contains its own AI model called "42".
Los nombres de las categorías, los módulos y el resto de la aplicación son los de
epsylon, sin tocar. Con el tema OasisMobile se ve el del propio dev.
---
## Qué es Oasis, en cuatro dibujos
Una red social sin servidor: publicas en tu propio equipo y los demás se traen una
copia. No hay cuenta, no hay contraseña y nadie puede borrarte.
The main idea behind this implementation is to enable distributed learning generated through the collective action of many individuals, with the goal of redistributing the necessary processing load, as well as the ecological footprint and corporate bias.
![Qué es Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/01-red.png)
![SNH](https://solarnethub.com/git/oasis-ai-example2.png "SolarNET.HuB")
Los mensajes viajan de vecino en vecino, así que llegan a gente con la que nunca te
has conectado. Por eso funciona incluso sin internet, entre dos móviles en la misma wifi.
Our AI is trained with content from the OASIS network and its purpose is to take action and obtain answers to individual, but also global, problems.
![Cómo llega un mensaje](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/02-gossip.png)
+ https://wiki.solarnethub.com/socialnet/ai
Un pub es solo un nodo que no se apaga nunca. No es un servidor con tus datos: no
guarda tu identidad, no decide qué ves y no puede borrarte.
----------
![Qué es un pub](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/04-pub.png)
## Parliament
Todo lo que publicas se encadena y va firmado con tu clave. Nadie puede colar un
mensaje en tu nombre ni cambiar lo que dijiste.
Oasis contains its own Parliament (Government system).
![Tu registro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/14-registro.png)
![SNH](https://solarnethub.com/git/oasis-parliament.png "SolarNET.HuB")
## Courts
> La explicación completa —los saltos, las tribus, el LARP, los módulos— está en
> [Entender Oasis en diez minutos](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender).
Oasis contains its own Courts (Justice system).
---
![SNH](https://solarnethub.com/git/oasis-courts.png "SolarNET.HuB")
## ECOin
## Llamadas y salas
Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace.
![Llamadas: las del dev, con el móvil enchufado](docs/capturas/esquema-llamadas.png)
![SNH](https://solarnethub.com/git/oasis-tomatoes-example.png "SolarNET.HuB")
El Teléfono (llamadas de uno a uno, con buzón de voz) y las Salas (hasta cincuenta
personas, con vídeo, levantar la mano, grabación y emisión) son de Oasis. En Android
el sonido y la cámara no los maneja Node: el servidor se conecta a un puente nativo de
la app (`.audio_bridge`), que graba, reproduce, saca fotogramas de la cámara y emite
ficheros.
You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
Ese puente está en la app Android del dev, que no tiene el código publicado. Por eso la
APK de esta rama se monta sobre la oficial: se queda con su app tal cual y solo cambia
lo que corre dentro (el backend con nuestra capa) y el nombre del paquete. Probado en
el emulador: se entra en una sala y el micrófono graba.
+ https://ecoin.03c8.net
## Banking
En la barra de arriba, junto al avatar, está el botón de *No molestar* del Teléfono.
Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
---
![SNH](https://solarnethub.com/git/oasis-banking.png "SolarNET.HuB")
----------
## La interfaz
## Carbon Footprinting
![La interfaz por dentro](docs/capturas/esquema-interfaz.png)
Oasis contains its own carbon footprint meter.
<p align="center">
<img src="docs/capturas/01-portada.png" width="200">
<img src="docs/capturas/02-tribus.png" width="200">
<img src="docs/capturas/03-votacion.png" width="200">
<img src="docs/capturas/04-modulos.png" width="200">
</p>
<p align="center">
<img src="docs/capturas/05-ajustes.png" width="200">
<img src="docs/capturas/06-barra.png" width="200">
<img src="docs/capturas/08-salas.png" width="200">
<img src="docs/capturas/07-oasismobile.png" width="200">
</p>
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting.png "SolarNET.HuB")
La actividad al abrir, tribus, una votación de semáforo, módulos, ajustes, la barra de
atajos, las salas del dev y el menú con el tema OasisMobile del dev.
All transmissions, stored files, and interactions are measured to understand the environmental impact of the network.
Casi sesenta módulos no caben en la pantalla de un teléfono. Al abrir la app se ve la
actividad, lo que ha pasado en la red. Abajo hay cuatro atajos que eliges tú y una
quinta pestaña fija, Menú, que abre desde abajo una hoja con todos los módulos por
secciones, como el cajón de apps del móvil. Está en todas las pantallas. Por qué así,
y no un menú arriba o una hamburguesa: [docs/ESTUDIO_NAVEGACION.md](docs/ESTUDIO_NAVEGACION.md).
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting2.png "SolarNET.HuB")
Solo aparecen los módulos activos. En *Ajustes → Workflows* se eligen de golpe: el de
acampada deja lo justo para organizarse. En el móvil un workflow cambia los módulos,
no el aspecto: el aspecto se elige en *Ajustes → Tema* (Acampada u OasisMobile).
And also of each inhabitant.
---
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting3.png "SolarNET.HuB")
## Cómo está hecha la rama
----------
![Cómo va la rama encima de Oasis](docs/capturas/esquema-rama.png)
## L.A.R.P.
Oasis lo desarrolla epsylon y saca versión casi cada día. Para no tener que rehacer la
rama en cada una, lo nuestro va en ficheros propios: hojas de estilo, unas pocas
vistas, un router que va delante del suyo y el envoltorio de Android. El backend, el
middleware, los modelos y las traducciones son suyos y no se tocan. Las normas están
en la [guía de desarrollo](GUIA_DE_DESARROLLO.md) y las cifras en
[docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md).
Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses.
---
![SNH](https://solarnethub.com/git/oasis-larp-schema.jpg "SolarNET.HuB")
## Empezar desde cero
The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics.
![De cero a estar dentro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/05-empezar.png)
+ https://wiki.solarnethub.com/socialnet/roleplaying#how_to_play
Check "The Houses" to review which one fit better with your ambitions:
| | |
|---|---|
| [oasis-project.pub](https://oasis-project.pub) | Directorio de pubs, con invitaciones y estado de cada uno |
| [0asis.net](https://0asis.net) | El proyecto explicado desde el principio |
| [wiki.solarnethub.com](https://wiki.solarnethub.com/socialnet/overview) | La documentación oficial, módulo por módulo |
+ https://wiki.solarnethub.com/socialnet/roleplaying#the_houses
---
----------
## Instalar
## Fediverse
La APK no está en ninguna tienda: se descarga de las *releases* de este repositorio
y se instala a mano, con orígenes desconocidos activado. Se instala junto al Oasis
oficial sin pisarlo —el identificador es `net.laenre.oasis`—, así que puedes tener
los dos y comparar.
Oasis bridges to the **Fediverse**. You can connect your fediverse accounts (ex: **Mastodon**) and use them for content from inside Oasis: read your home timeline, publish (text, images and video), reply, boost and favourite — without storing any third-party content in your SSB log (the feed is fetched live and shown ephemerally; only your credentials are kept locally).
Necesita unos 350 MB libres. El primer arranque tarda entre 15 y 60 segundos porque
descomprime el servidor.
![SNH](https://solarnethub.com/git/oasis-fediverse.png "SolarNET.HuB")
> Verifica la huella de la firma antes de instalar una APK bajada de un servidor
> propio: `apksigner verify --print-certs`
Connect your account from **Settings → Fediverse**, then open it from the **Fediverse** menu. Each network lives in its own space.
### Compilar
+ Mastodon connect guide: [docs/FEDIVERSE/MASTODON/connect.md](docs/FEDIVERSE/MASTODON/connect.md)
La APK se monta sobre la oficial de la misma versión. Hace falta Java 17 o más y las
build-tools de Android (zipalign y apksigner); apktool se baja solo.
----------
## Invite codes (for PUBs and TRIBES):
```sh
cd android
./scripts/sobre_oficial.sh # baja la oficial, le pone el backend de la rama, la firma
# para publicar, con la clave del hacklab:
OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
```
Oasis is a TRUSTNET. This means you need an invitation code to enter the PUBs (managed by inhabitants or hacklabs).
Sale en `android/build/sobre-oficial/oasis-<versión>-hacklab.apk`. Sin clave se firma
con la de depuración y queda depurable, para poder inspeccionarla desde
`chrome://inspect`.
Similarly, TRIBES (groups in Oasis) require an entry code.
El proyecto Gradle propio de `android/` (`release.sh`, Android Studio) sigue ahí, pero
ya no es el camino: no tiene el puente de audio y en él no hay llamadas.
![SNH](https://solarnethub.com/git/snh-oasis-invites.png "SolarNET.HuB")
While you can use it and connect to any nodes you want, it's a good idea to get an entry code to connect with the community.
![Qué hay dentro de la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/16-arquitectura-movil.png)
So you'll need to know someone, or participate in a collective action that distributes invitation codes, to see everything.
---
+ https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants
----------
## Documentación
## Architecture:
Para desarrollar, en el propio repositorio:
Oasis uses a gossip protocol or epidemic protocol which is a procedure or process of computer peer-to peer communication
that is based on the way epidemics spread.
| | |
|---|---|
| [GUIA_DE_DESARROLLO.md](GUIA_DE_DESARROLLO.md) | Qué se toca, qué no y cómo hacer cada cosa |
| [docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md) | Cuánto cambia el dev en cada versión, medido |
| [docs/USABILIDAD_PAD.txt](docs/USABILIDAD_PAD.txt) | Pruebas de usabilidad en el móvil |
![SNH](https://solarnethub.com/git/snh-meshnet.png "SolarNET.HuB")
Todo lo de arriba, en detalle, está en la
[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki):
This means that information is able to distribute across multiple machines, without requiring direct connections between them.
| | |
|---|---|
| [Entender Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender) | La red, el gossip, los saltos, los pubs, las tribus y el LARP |
| [Empezar desde cero](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Empezar) | Invitación, seguir gente, abrir tu blog o tu tienda |
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Teléfono y Salas en el móvil |
| [La interfaz](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Interfaz) | Botón por botón |
| [Identidades](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Identidades) | Varias cuentas en el mismo móvil |
| [Compilar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Compilar) | Y cómo se publica una versión |
| [Estructura del código](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Estructura) | Dónde vive cada fichero y por qué ahí |
| [Si algo va mal](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Problemas) | Los fallos que han pasado de verdad |
| [Qué NO tiene](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Limitaciones) | Lo que falta respecto al oficial, sin adornos |
![SNH](https://solarnethub.com/git/gossip-graph1.png "SolarNET.HuB")
---
Even though Alice and Dan lack a direct connection, they can still exchange feeds:
## El proyecto original
![SNH](https://solarnethub.com/git/gossip-graph2.png "SolarNET.HuB")
This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB
to reach Alice, and visa-versa.
Esta rama existe gracias a y por encima de el trabajo de epsylon. El código, la
arquitectura, los módulos y la red son suyos.
----------
- Código: https://github.com/epsylon/oasis
- Proyecto: https://solarnethub.com
- Wiki: https://wiki.solarnethub.com/socialnet/overview
## Backend:
## Licencia
Oasis is based on a mesh network and self-hosted social media ecosystem called Secure Scuttlebutt (SSB).
SSB uses a blockchain like append-only data structure and a fully decentralized P2P network. There are no servers or authorities
of any kind. Like a crypto transaction, SSB posts are censorship-resistant and are replicated to the entire network.
![SNH](https://solarnethub.com/git/ssb-participants-perspective.png "SolarNET.HuB")
In SSB each user hosts their own content and the content of the peers they follow, which provides fault tolerance and
eventual consistency.
----------
## Installing:
Follow ['INSTALL.md'](docs/install/install.md) to build and install it on your device.
----------
## Setup & Deploy:
Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal) to learn how to choose your language, set a theme & configure your avatar.
----------
## SNH-Hub (for HackLabs):
The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
![SNH](https://solarnethub.com/git/snh-pub-feed.png "SolarNET.HuB")
Just visit: https://pub.solarnethub.com/
![SNH](https://solarnethub.com/git/snh-pub-laplaza.png "SolarNET.HuB")
And also you can visit periodically the public statistic of the SNH-PUB:
![SNH](https://solarnethub.com/git/snh-pub-stats.png "SolarNET.HuB")
See stats: https://laplaza.solarnethub.com/
----------
## Roadmap:
Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
----------
## Translations:
Oasis supports multiple languages. One way to contribute is to translate the interface into your language so other people in your region can use it more intuitively.
+ https://wiki.solarnethub.com/socialnet/snh#choose_language
----------
## Development:
Oasis is completely coded in: node.js, HTML5 + CSS.
Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
----------
## Links:
+ SNH Website: https://solarnethub.com
+ Kräkens.Lab: https://krakenslab.com
+ Documentation: https://wiki.solarnethub.com
+ Research: https://wiki.solarnethub.com/docs/research
+ Code of Conduct: https://wiki.solarnethub.com/docs/code_of_conduct
+ The KIT: https://wiki.solarnethub.com/kit/overview
+ Ecosystem: https://wiki.solarnethub.com/socialnet/ecosystem
+ Project Network: https://wiki.solarnethub.com/socialnet/snh#the_project_network
+ Oasis: https://wiki.solarnethub.com/socialnet/overview
+ ECOin: https://wiki.solarnethub.com/ecoin/overview
+ Role-playing (L.A.R.P): https://wiki.solarnethub.com/socialnet/roleplaying
+ Warehouse: https://wiki.solarnethub.com/stock/submit_request
+ THS: https://thehackerstyle.com
+ PeerTube: https://video.hardlimit.com/c/thehackerstyle/videos
+ Youtube: https://www.youtube.com/@thehackerstyle
+ Twitch: https://twitch.tv/thehackerstyle
AGPL-3.0, la misma que el proyecto original.

7
android/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# artefactos y binarios grandes: los prepara scripts/prepare.sh
app/src/main/jniLibs/
app/src/main/assets/nodejs-project.zip
.gradle/
build/
app/build/
local.properties

99
android/README.md Normal file
View file

@ -0,0 +1,99 @@
# Wrapper Android propio
Proyecto Gradle para construir la APK de la rama sin depender del APK oficial como
molde.
## Por que existe
Nota (19/10/2026): desde la 1.3.x la APK oficial ya pide microfono y camara y trae un
puente nativo de audio y camara (`.audio_bridge`) para el Telefono y las Salas. Por eso
la APK de la rama se monta ahora sobre la oficial con `scripts/sobre_oficial.sh`, y el
proyecto Gradle de esta carpeta queda sin uso. Lo de abajo describe la situacion anterior.
El APK que se venia usando es el de epsylon re-firmado, y su `classes.dex` **no
implementa `WebChromeClient.onPermissionRequest`**. Sin ese metodo el WebView de
Android deniega por defecto toda peticion de `getUserMedia()`, de modo que las
llamadas no pueden funcionar por mucho que se parchee el manifest:
la lista de permisos es estatica y hay que declararla al compilar.
Este wrapper resuelve las dos puertas: declara los permisos y los concede al
contenido web solo tras el consentimiento del usuario.
## Permisos: declarados, no concedidos
`CAMERA`, `RECORD_AUDIO` y `MODIFY_AUDIO_SETTINGS` figuran en el manifest porque
Android exige que la lista sea fija. Pero son permisos peligrosos: **se piden en
tiempo de ejecucion la primera vez que se pulsa Llamar**, no al instalar. Si nadie
llama, no se pide nada y la app no accede a la camara ni al microfono.
`uses-feature ... required="false"` evita excluir de la tienda a dispositivos sin
camara o sin microfono.
## Que reutiliza y que es nuevo
Las tres librerias nativas se toman tal cual del APK existente:
- `libnode.so` — nodejs-mobile **v18.20.4** oficial (OpenSSL 3.0.13+quic), un
artefacto publico; no hay que compilar Node.
- `libnative-lib.so` — 6,8 KB, un solo simbolo JNI:
`Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments`.
- `libc++_shared.so`
Por eso el `namespace` del modulo es `com.solarnethub.oasis`: JNI resuelve por
nombre y la clase puente tiene que vivir en ese paquete. El `applicationId` si es
propio (`net.laenre.oasis`), asi que la app se instala junto a la oficial sin
pisarla.
Si algun dia se quiere soltar el `.so` de epsylon, reimplementar esos 6,8 KB son
unas 40 lineas de C++ contra las cabeceras de nodejs-mobile, y ahi si haria falta
el NDK.
## Mejoras sobre el APK actual
- `targetSdk 35` (el actual se quedo en uno ya caducado para la tienda).
- Trafico en claro permitido **solo hacia loopback** via
`network_security_config`, en vez de `usesCleartextTraffic="true"` global.
- `allowBackup="false"`: `adb backup` deja de poder extraer `~/.ssb`.
- El backend corre en un servicio en primer plano, en el proceso `:node`, asi que
sobrevive en segundo plano — imprescindible para recibir llamadas. El APK actual
declara `FOREGROUND_SERVICE` pero no registra ningun servicio.
- Al descomprimir el backend se descartan las entradas con rutas sospechosas
(zip slip).
## Construir
Con Android Studio basta con `./scripts/android-studio.sh`: clona el Oasis oficial,
descarga su APK de la misma version y empaqueta el backend. Luego *File > Open >*
esta carpeta y *Build > Build APK(s)*. A mano, paso a paso:
```sh
# 1. dependencias del backend (una vez)
(cd ../src/server && npm install)
# 2. librerias nativas + empaquetado del backend
./scripts/prepare.sh ../../oasis_mobile # o la ruta de un APK
# 3. compilar
./gradlew assembleDebug # o assembleRelease
```
Para firmar la release, por entorno (la keystore nunca al repositorio):
```sh
export OASIS_KEYSTORE=/ruta/oasis-alfa-key.jks
export OASIS_KEYSTORE_PASS=...
export OASIS_KEY_ALIAS=oasis
./gradlew assembleRelease
```
## Requisitos
JDK 17 o superior **con compilador**. El OpenJDK del sistema puede ser solo
runtime; `gradle.properties` apunta al JDK que trae Android Studio. Cambia esa
linea si compilas en otra maquina.
## Estado
Compila y genera APK con los permisos y el `onPermissionRequest` en su sitio.
**Falta probarlo en un dispositivo real**: que arranque el backend, que la
interfaz cargue y que `getUserMedia` resuelva al pulsar Llamar.

View file

@ -0,0 +1,66 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
// El .so nativo exporta Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments,
// asi que la clase puente tiene que vivir en ese paquete para que el simbolo resuelva.
// El applicationId si es propio: la app se instala junto a la oficial sin pisarla.
namespace = "com.solarnethub.oasis"
compileSdk = 35
defaultConfig {
applicationId = "net.laenre.oasis"
minSdk = 24
targetSdk = 35
// release.sh los pasa por entorno; a mano, el nombre sale de package.json (como
// en el backend) para que la APK no diga ser otra version que la que lleva dentro
val pkgVersion = Regex("\"version\"\\s*:\\s*\"([^\"]+)\"")
.find(rootProject.file("../package.json").readText())?.groupValues?.get(1) ?: "0.0.0"
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
versionName = System.getenv("OASIS_VERSION_NAME") ?: pkgVersion
ndk { abiFilters += listOf("arm64-v8a") }
}
signingConfigs {
create("release") {
val ks = System.getenv("OASIS_KEYSTORE")
if (ks != null) {
storeFile = file(ks)
storePassword = System.getenv("OASIS_KEYSTORE_PASS")
keyAlias = System.getenv("OASIS_KEY_ALIAS") ?: "oasis"
keyPassword = System.getenv("OASIS_KEY_PASS") ?: System.getenv("OASIS_KEYSTORE_PASS")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
if (System.getenv("OASIS_KEYSTORE") != null) signingConfig = signingConfigs.getByName("release")
}
}
buildFeatures { buildConfig = true }
packaging {
jniLibs {
// Comprimidas, como en la APK oficial: libnode.so pasa de 50 a 16 MB en la
// descarga. Sin comprimir (false) se cargaria por mmap desde la APK, pero eso
// solo ahorra unos milisegundos al arrancar y cuesta 35 MB a cada descarga.
useLegacyPackaging = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
}

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- descubrir otros moviles en la misma wifi: el anuncio LAN de SSB va por UDP
broadcast/multicast, y Android lo filtra si no se coge un MulticastLock -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- solo para guardar descargas en Android 9 o anterior; desde el 10 no hace falta -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Camara y microfono para el Telefono y las Salas de Oasis.
Se declaran aqui porque Android exige que la lista sea estatica, pero NO se
conceden al instalar: son permisos peligrosos y se piden en tiempo de ejecucion
la primera vez que se llama o se entra en una sala. Si nadie llama, no se pide nada y la app
nunca accede a la camara ni al microfono. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- required=false para no excluir de la tienda a dispositivos sin camara o sin microfono -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="false"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Oasis">
<!-- El backend Node vive en su propio proceso: asi sobrevive en segundo plano
y se puede reiniciar limpio (nodejs-mobile solo permite arrancar node una
vez por proceso). -->
<service
android:name="net.laenre.oasis.NodeService"
android:process=":node"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- La lista de configChanges tiene que ser completa: si falta una sola (y la
que faltaba era smallestScreenSize), rotar destruye y recrea la Activity,
el WebView se pierde y vuelve a cargar la URL, a veces antes de que el
backend escuche. -->
<activity
android:name="net.laenre.oasis.MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|keyboard|navigation|uiMode|fontScale|layoutDirection"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,22 @@
package com.solarnethub.oasis
/**
* Puente con el runtime de Node.
*
* El nombre de la clase y del metodo no son libres: libnative-lib.so exporta
* Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments, y JNI resuelve
* por nombre. De ahi que el namespace del modulo sea com.solarnethub.oasis aunque
* el applicationId sea net.laenre.oasis.
*
* No es una Activity pese al nombre; solo declara el metodo nativo.
*/
object OasisActivity {
init {
System.loadLibrary("node")
System.loadLibrary("native-lib")
}
/** Arranca node en el hilo actual. No retorna hasta que el runtime termina. */
@JvmStatic
external fun startNodeWithArguments(arguments: Array<String>): Int
}

View file

@ -0,0 +1,304 @@
package net.laenre.oasis
import android.Manifest
import android.app.DownloadManager
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.PermissionRequest
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import com.solarnethub.oasis.BuildConfig
class MainActivity : AppCompatActivity() {
private lateinit var web: WebView
private var pending: PermissionRequest? = null
private var fileCallback: ValueCallback<Array<Uri>>? = null
private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
// Si se cancela hay que devolver null igualmente: si no, el <input> queda
// bloqueado y no vuelve a abrir el selector nunca mas.
fileCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res.resultCode, res.data))
fileCallback = null
}
private val askAndroid = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
val req = pending
pending = null
if (req == null) return@registerForActivityResult
// Solo se concede al contenido web lo que el sistema nos ha concedido a nosotros.
val allowed = req.resources.filter { res ->
when (res) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> granted[Manifest.permission.CAMERA] == true
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> granted[Manifest.permission.RECORD_AUDIO] == true
else -> false
}
}.toTypedArray()
if (allowed.isEmpty()) req.deny() else req.grant(allowed)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= 33 && !has("android.permission.POST_NOTIFICATIONS")) {
askAndroid.launch(arrayOf("android.permission.POST_NOTIFICATIONS"))
}
startService(Intent(this, NodeService::class.java))
web = WebView(this)
setContentView(edgeToEdge(web))
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
web.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false // permite reproducir el audio remoto de la llamada
allowFileAccess = false
allowContentAccess = false
}
// La navegacion se queda dentro del backend local; lo de fuera va al navegador.
web.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(v: WebView?, r: WebResourceRequest?): Boolean {
val u = r?.url ?: return false
if (isLocal(u)) return false
startActivity(Intent(Intent.ACTION_VIEW, u))
return true
}
/**
* Red de seguridad: si una pagina del backend no carga, se vuelve a esperar
* y se reintenta, en vez de dejar el ERR_CONNECTION_REFUSED de Chromium en
* pantalla. Pasa cuando el backend todavia no escucha (arranque lento), y
* tambien cuando se ha reiniciado —al cambiar de identidad, o porque Android
* mato el proceso del servicio estando en segundo plano—.
*/
override fun onPageFinished(v: WebView?, url: String?) {
// se recuerda para poder volver aqui si luego hay que reintentar
if (url != null && url.startsWith("http://127.0.0.1:3000")) lastGoodUrl = url
}
override fun onReceivedError(v: WebView?, req: WebResourceRequest?, err: WebResourceError?) {
if (req?.isForMainFrame != true) return // un css o una imagen no cuentan
val u = req.url ?: return
if (!isLocal(u)) return // paginas de fuera, ni tocarlas
Log.w("OasisNode", "la pagina no ha cargado (${'$'}u); reintentando")
loaded = false
waitAndLoad()
}
}
// Sin este WebChromeClient.onPermissionRequest, el WebView DENIEGA por defecto
// toda peticion de getUserMedia y no hay videollamada posible, aunque los
// permisos esten declarados en el manifest. Es la puerta que faltaba.
web.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
view: WebView?,
callback: ValueCallback<Array<Uri>>?,
params: FileChooserParams?
): Boolean {
fileCallback?.onReceiveValue(null) // descartar uno anterior sin resolver
fileCallback = callback
return try {
pickFiles.launch(params?.createIntent())
true
} catch (e: Exception) {
fileCallback = null
false
}
}
override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread {
if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread }
val needed = mutableListOf<String>()
for (res in request.resources) {
when (res) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE ->
if (!has(Manifest.permission.CAMERA)) needed += Manifest.permission.CAMERA
PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
if (!has(Manifest.permission.RECORD_AUDIO)) needed += Manifest.permission.RECORD_AUDIO
}
}
if (needed.isEmpty()) {
request.grant(request.resources)
} else {
// Aqui es donde el usuario ve el dialogo del sistema: al pulsar Llamar,
// no al instalar la app.
pending = request
askAndroid.launch(needed.toTypedArray())
}
}
}
}
// Lo que la pagina manda descargar (un PDF, un fichero adjunto) va al gestor de
// descargas de Android; sin esto el WebView no hace nada al tocar el enlace.
web.setDownloadListener { url, userAgent, disposition, mime, _ -> download(url, userAgent, disposition, mime) }
waitAndLoad()
}
/**
* La pantalla entera, por debajo de las barras del sistema (Android 15 lo impone
* con targetSdk 35, y en versiones anteriores se pide igual para que se vea lo
* mismo). El contenedor deja de margen lo que ocupan la barra de estado, la de
* navegacion, el recorte de la camara y el teclado cuando esta abierto, asi la
* barra de abajo de Oasis sube con el teclado en vez de quedar tapada.
* El fondo blanco y los iconos oscuros son los del tema Acampada.
*/
private fun edgeToEdge(content: WebView): FrameLayout {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
val root = FrameLayout(this)
root.setBackgroundColor(Color.WHITE)
root.addView(content, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
WindowCompat.getInsetsController(window, root).apply {
isAppearanceLightStatusBars = true
isAppearanceLightNavigationBars = true
}
ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
v.setPadding(bars.left, bars.top, bars.right, maxOf(bars.bottom, ime.bottom))
WindowInsetsCompat.CONSUMED
}
return root
}
/** Descarga por el gestor de Android, a la carpeta Descargas, con aviso al terminar. */
private fun download(url: String, userAgent: String?, disposition: String?, mime: String?) {
try {
val name = URLUtil.guessFileName(url, disposition, mime)
val req = DownloadManager.Request(Uri.parse(url))
.setTitle(name)
.setMimeType(mime)
.addRequestHeader("User-Agent", userAgent ?: "")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
(getSystemService(DOWNLOAD_SERVICE) as DownloadManager).enqueue(req)
Toast.makeText(this, name, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.w("OasisNode", "no se pudo descargar ${'$'}url: ${'$'}e")
// si el gestor no puede, que lo abra otra aplicacion
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {}
}
}
private fun has(p: String) =
ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
private fun isLocal(u: Uri?): Boolean {
val h = u?.host ?: return false
return (h == "127.0.0.1" || h == "localhost") && u.port == 3000
}
private fun showStatus(attempt: Int) {
val s = attempt / 2
web.loadDataWithBaseURL(null, """
<html><head><meta name='viewport' content='width=device-width,initial-scale=1'>
<style>body{background:#fff;color:#000;font-family:sans-serif;display:flex;
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
div{padding:24px}h2{text-transform:uppercase;border-bottom:3px solid #000;padding-bottom:6px}
p{font-size:15px;line-height:1.5}</style></head>
<body><div><h2>Oasis</h2>
<p>Preparando la aplicaci&oacute;n&hellip;<br>El primer arranque descomprime el
servidor y puede tardar un minuto. No hace falta hacer nada.</p><p>${s}s</p></div></body></html>
""".trimIndent(), "text/html", "utf-8", null)
}
/**
* Carga la interfaz reintentando hasta que el backend responda.
*
* Se hace con el propio WebView en vez de sondear con HttpURLConnection: es el
* canal que de verdad va a servir la app, y evita depender de que la peticion de
* comprobacion pase los mismos filtros (el backend, por ejemplo, responde 400 a HEAD).
*/
private var loadAttempt = 0
private var loaded = false
@Volatile private var waiting = false // hay un hilo esperando al backend
private var lastGoodUrl: String? = null // ultima pagina que cargo bien
/**
* Espera a que el backend acepte conexiones y entonces carga la interfaz.
*
* La comprobacion es un socket TCP, no una peticion HTTP: no depende del metodo
* (el backend responde 400 a HEAD), ni de redirecciones (/ redirige a /activity),
* ni de la politica de trafico en claro. Si el puerto acepta, esta listo.
*
* La pantalla de estado se pinta ANTES de empezar a esperar, nunca entre medias:
* cargarla mientras el WebView trae la pagina buena cancelaria esa carga.
*/
/** Ruta a abrir: la del intent si viene con una, o la raiz. */
private fun targetUrl(): String {
val d = intent?.data
if (d != null && isLocal(d)) return d.toString()
val path = intent?.getStringExtra("path")
if (!path.isNullOrBlank() && path.startsWith("/")) return "http://127.0.0.1:3000${'$'}path"
return "http://127.0.0.1:3000/"
}
override fun onNewIntent(newIntent: Intent) {
super.onNewIntent(newIntent)
intent = newIntent
if (loaded) web.loadUrl(targetUrl())
}
private fun waitAndLoad() {
// onReceivedError puede dispararse varias veces seguidas (la pagina y sus
// recursos); sin esto se acumularian hilos esperando al mismo puerto
if (waiting) return
waiting = true
showStatus(0)
Log.i("OasisNode", "esperando al backend en 127.0.0.1:3000")
Thread {
var i = 0
while (i < 600 && !loaded) { // hasta 5 minutos
val ok = try {
java.net.Socket().use { it.connect(java.net.InetSocketAddress("127.0.0.1", 3000), 1000); true }
} catch (e: Exception) {
if (i % 10 == 0) Log.w("OasisNode", "socket #" + i + ": " + e.javaClass.simpleName + ": " + e.message)
false
}
if (ok) {
Handler(Looper.getMainLooper()).post {
loaded = true
waiting = false
Log.i("OasisNode", "backend listo tras " + (i / 2) + "s, cargando interfaz")
web.loadUrl(lastGoodUrl ?: targetUrl())
}
return@Thread
}
if (i % 8 == 0) Handler(Looper.getMainLooper()).post { if (!loaded) showStatus(i) }
Thread.sleep(500); i++
}
waiting = false
}.start()
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (web.canGoBack()) web.goBack() else super.onBackPressed()
}
}

View file

@ -0,0 +1,139 @@
package net.laenre.oasis
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build
import android.os.IBinder
import android.system.Os
import android.util.Log
import com.solarnethub.oasis.OasisActivity
import com.solarnethub.oasis.R
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
/**
* Servicio en primer plano que sostiene el backend Node.
*
* Vive en el proceso :node por dos razones: nodejs-mobile solo permite arrancar el
* runtime una vez por proceso —cambiar de identidad exige matar el proceso, no vale
* reiniciar dentro—, y asi el backend sobrevive cuando la app pasa a segundo plano,
* que es imprescindible para recibir llamadas.
*/
class NodeService : Service() {
companion object {
private const val TAG = "OasisNode"
private const val CHANNEL = "oasis-node"
private const val NOTIF_ID = 1
@Volatile private var started = false
}
// Sin esto Android tira los paquetes multicast/broadcast que llegan por wifi y el
// descubrimiento LAN de SSB no ve a nadie: dos moviles en la misma red no se encuentran.
private var multicastLock: WifiManager.MulticastLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
startForeground(NOTIF_ID, buildNotification())
acquireMulticastLock()
if (started) return
started = true
Thread({ runNode() }, "node-main").start()
}
override fun onDestroy() {
try { multicastLock?.takeIf { it.isHeld }?.release() } catch (e: Throwable) { Log.w(TAG, "soltando MulticastLock", e) }
super.onDestroy()
}
private fun acquireMulticastLock() {
if (multicastLock?.isHeld == true) return
try {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
multicastLock = wifi.createMulticastLock("oasis-lan").apply { setReferenceCounted(false); acquire() }
} catch (e: Throwable) {
// sin wifi o sin permiso: los pubs siguen funcionando, solo se pierde la LAN
Log.w(TAG, "no se ha podido coger el MulticastLock; sin descubrimiento LAN", e)
}
}
private fun buildNotification(): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL, getString(R.string.node_channel), NotificationManager.IMPORTANCE_LOW)
ch.setShowBadge(false)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(ch)
}
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL)
else @Suppress("DEPRECATION") Notification.Builder(this)
return b.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.node_running))
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setOngoing(true)
.build()
}
private fun runNode() {
try {
val root = File(filesDir, "nodejs-project")
extractProjectIfNeeded(root)
val main = File(root, "main.js")
if (!main.exists()) { Log.e(TAG, "no encuentro main.js en ${root.absolutePath}"); return }
// main.js hace process.env.HOME || resolve(__dirname,'..','..'). En Android HOME
// suele venir como "/" —no escribible—, asi que se fija explicitamente al
// almacenamiento privado de la app: ahi cuelga ~/.ssb.
val home = filesDir.parentFile ?: filesDir
val tmp = File(home, "tmp").apply { mkdirs() }
Os.setenv("HOME", home.absolutePath, true)
Os.setenv("TMPDIR", tmp.absolutePath, true)
Log.i(TAG, "HOME=${home.absolutePath}")
val rc = OasisActivity.startNodeWithArguments(arrayOf("node", main.absolutePath))
Log.w(TAG, "el runtime de node ha terminado con codigo $rc")
} catch (e: Throwable) {
Log.e(TAG, "fallo arrancando node", e)
}
}
/** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */
private fun extractProjectIfNeeded(root: File) {
val stamp = File(root, ".version")
// Marca por tamano y fecha del asset, no por versionName: al reconstruir la APK
// sin subir la version, con versionName la app seguiria con el backend anterior.
// lastUpdateTime cambia en cada instalacion, tambien reinstalando la misma version.
// (openFd no vale: el asset va comprimido dentro del APK y no admite descriptor.)
val current = packageManager.getPackageInfo(packageName, 0).lastUpdateTime.toString()
if (stamp.exists() && stamp.readText() == current) return
if (root.exists()) root.deleteRecursively()
root.mkdirs()
assets.open("nodejs-project.zip").use { input ->
ZipInputStream(input).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val out = File(root, entry.name.removePrefix("nodejs-project/"))
// no confiar en las rutas del zip: evita escribir fuera del directorio (zip slip)
if (!out.canonicalPath.startsWith(root.canonicalPath + File.separator) &&
out.canonicalPath != root.canonicalPath) {
Log.w(TAG, "entrada de zip descartada por ruta sospechosa: ${entry.name}")
} else if (entry.isDirectory) {
out.mkdirs()
} else {
out.parentFile?.mkdirs()
FileOutputStream(out).use { fos -> zip.copyTo(fos) }
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
stamp.writeText(current)
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hexagono, el mismo motivo que la rejilla de navegacion de la interfaz -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path
android:pathData="M54,28 L74,40 L74,64 L54,76 L34,64 L34,40 Z"
android:strokeColor="#FFB400" android:strokeWidth="5" android:fillColor="#00000000"/>
<path
android:pathData="M54,44 L63,49 L63,59 L54,64 L45,59 L45,49 Z"
android:fillColor="#FFB400"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#121212</color>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Oasis</string>
<string name="node_channel">Servicio de Oasis</string>
<string name="node_running">Oasis esta activo</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Oasis" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">#FFFFFF</item>
<item name="android:statusBarColor">#FFFFFF</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- El backend corre en 127.0.0.1:3000 sin TLS, asi que hay que permitir texto en
claro, pero SOLO hacia loopback. El resto de la red queda en cifrado obligatorio.
El orden importa: el esquema de Android exige base-config antes que domain-config. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

4
android/build.gradle.kts Normal file
View file

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View file

@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
# Gradle usa el JDK de JAVA_HOME (17 o superior, con javac). No se fija aqui la
# ruta: cada maquina y el runner tienen el suyo.

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
android/gradlew vendored Executable file
View file

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# Deja el proyecto listo para compilarlo desde Android Studio.
#
# Android Studio solo compila: no sabe traer el Oasis oficial, ni sacar libnode.so
# del APK de epsylon, ni empaquetar el backend. Esto lo hace todo de una vez:
#
# 1. clona el Oasis oficial en ../upstream, en el commit de .upstream-commit
# (de ahi salen las dependencias del backend, src/base/node_modules)
# 2. descarga el APK oficial de la misma version a android/native/
# (de ahi salen libnode.so y los modulos nativos de Node para Android)
# 3. lanza prepare.sh, que copia las librerias y empaqueta el backend
#
# Uso, desde la raiz del repo o desde android/:
# ./android/scripts/android-studio.sh
#
# Despues, en Android Studio: File > Open > la carpeta android/, esperar a que
# termine el Gradle Sync, y Build > Build App Bundle(s) / APK(s) > Build APK(s).
#
# Cada vez que cambies algo en src/ o en main.js, vuelve a lanzarlo: la APK lleva
# el backend empaquetado y Android Studio no lo rehace solo.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)" # android/
REPO="$(cd "$HERE/.." && pwd)" # raiz del repo
UPSTREAM="${OASIS_UPSTREAM:-$REPO/../upstream}"
say() { printf '\n== %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
for cmd in git node curl zip unzip; do
command -v "$cmd" >/dev/null || die "falta '$cmd': instalalo y vuelve a lanzarlo"
done
# --- 1) el Oasis oficial, en el mismo commit que src/ -----------------------------
COMMIT="$(cat "$REPO/.upstream-commit")"
if [ ! -d "$UPSTREAM/.git" ]; then
say "clonando el Oasis oficial en $UPSTREAM (unos 300 MB, solo la primera vez)"
git clone https://github.com/epsylon/oasis "$UPSTREAM"
fi
if [ "$(git -C "$UPSTREAM" rev-parse HEAD)" != "$COMMIT" ]; then
say "poniendo el Oasis oficial en el commit $COMMIT"
git -C "$UPSTREAM" fetch --quiet origin || true
git -C "$UPSTREAM" checkout --quiet "$COMMIT"
fi
# --- 2) el APK oficial de la misma version ----------------------------------------
VERSION="$(node -p "require('$REPO/src/server/package.json').version")"
BASE="$HERE/native/oasis-v$VERSION.apk"
if [ ! -f "$BASE" ]; then
say "descargando el APK oficial $VERSION (unos 50 MB, solo la primera vez)"
mkdir -p "$HERE/native"
curl -fL --progress-bar -o "$BASE.part" \
"https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk" \
|| die "no se ha podido descargar el APK oficial de la $VERSION"
mv "$BASE.part" "$BASE"
fi
# --- 3) librerias y backend --------------------------------------------------------
say "empaquetando el backend (un minuto o dos)"
OASIS_UPSTREAM="$UPSTREAM" "$HERE/scripts/prepare.sh" "$BASE"
cat <<EOF
== listo
Ahora, en Android Studio:
1. File > Open > $HERE
2. espera a que termine el Gradle Sync (la primera vez descarga bastante)
3. Build > Build App Bundle(s) / APK(s) > Build APK(s)
La APK sale en:
$HERE/app/build/outputs/apk/debug/app-debug.apk
Si cambias algo en src/ o en main.js, vuelve a lanzar este script antes de compilar.
EOF

81
android/scripts/prepare.sh Executable file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Prepara lo que el proyecto Android no lleva en git:
# - las librerias nativas (libnode.so son 49 MB; no tiene sentido versionarlas)
# - assets/nodejs-project.zip con el backend Node y sus dependencias
#
# Uso: ./scripts/prepare.sh [ruta-a-un-apk-o-directorio-con-lib/arm64-v8a]
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
JNI="$HERE/app/src/main/jniLibs/arm64-v8a"
ASSETS="$HERE/app/src/main/assets"
SRC="${1:-}"
mkdir -p "$JNI" "$ASSETS"
# --- 1) librerias nativas -----------------------------------------------------
need_libs=(libnode.so libnative-lib.so libc++_shared.so)
missing=0
for l in "${need_libs[@]}"; do [ -f "$JNI/$l" ] || missing=1; done
if [ "$missing" = "1" ]; then
if [ -z "$SRC" ]; then
echo "Faltan las librerias nativas en $JNI"
echo "Pasa un APK o un directorio que tenga lib/arm64-v8a/, por ejemplo:"
echo " ./scripts/prepare.sh ../../oasis_mobile"
exit 1
fi
if [ -d "$SRC/lib/arm64-v8a" ]; then
cp "$SRC/lib/arm64-v8a"/*.so "$JNI/"
elif [ -f "$SRC" ]; then
tmp="$(mktemp -d)"; unzip -qo "$SRC" 'lib/arm64-v8a/*' -d "$tmp"
cp "$tmp/lib/arm64-v8a"/*.so "$JNI/"; rm -rf "$tmp"
else
echo "No encuentro librerias en $SRC"; exit 1
fi
echo "librerias nativas copiadas:"; ls -la "$JNI" | tail -n +2 | awk '{print " "$NF" ("$5" bytes)"}'
fi
# --- 2) backend + dependencias ---------------------------------------------
# El node_modules del movil esta podado: son ~137 MB frente a los 1,4 GB de una
# instalacion de escritorio. En vez de reinstalarlo, se reutiliza el del APK base:
# tiene que ser la APK oficial de la MISMA version que src/ (ahora 1.3.3): desde la
# 1.1 el backend usa ssb-db2 y el node_modules de una 0.9.x ya no le sirve.
BASE_ZIP=""
if [ -n "$SRC" ] && [ -f "$SRC" ]; then
tmpz="$(mktemp -d)"; unzip -qo "$SRC" 'assets/nodejs-project.zip' -d "$tmpz" 2>/dev/null || true
[ -f "$tmpz/assets/nodejs-project.zip" ] && BASE_ZIP="$tmpz/assets/nodejs-project.zip"
fi
work="$(mktemp -d)"
if [ -n "$BASE_ZIP" ]; then
( cd "$work" && unzip -q "$BASE_ZIP" )
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
rm -rf "$work/nodejs-project/src"
elif [ -d "$REPO/src/server/node_modules" ]; then
mkdir -p "$work/nodejs-project"
cp -r "$REPO/src/server/node_modules" "$work/_nm"
else
echo "No hay node_modules. Pasa un APK base del que sacarlo, o instalalas:"
echo " (cd $REPO/src/server && npm install --omit=dev)"
exit 1
fi
mkdir -p "$work/nodejs-project"
cp -r "$REPO/src" "$work/nodejs-project/"
# backend.js lee README.md con readFileSync SIN try/catch (linea ~1690):
# si falta, el backend muere con ENOENT al arrancar.
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/" 2>/dev/null || true
test -f "$work/nodejs-project/README.md" || { echo "FALTA README.md: el backend no arrancaria"; exit 1; }
rm -rf "$work/nodejs-project/src/server/node_modules"
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
# --- 3) empaquetar ------------------------------------------------------------
# Con compresion: sin ella el backend son ~145 MB y la APK se iria por encima de 200.
rm -f "$ASSETS/nodejs-project.zip"
( cd "$work" && zip -q -r "$ASSETS/nodejs-project.zip" nodejs-project )
rm -rf "$work" "${tmpz:-/nonexistent}"
echo "backend empaquetado: $(du -h "$ASSETS/nodejs-project.zip" | cut -f1) en assets/nodejs-project.zip"
echo
echo "Listo. Ahora: ./gradlew assembleDebug (o assembleRelease para firmar)"

175
android/scripts/release.sh Executable file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
#
# Construye la APK, la verifica y (si se le pasa un token) publica la release en
# gitea. Pensado para lanzarlo a mano o desde un runner; hace lo mismo en los dos
# sitios, que es lo que evita el clasico "en mi maquina si sale".
#
# Uso:
# ./scripts/release.sh # compila debug y verifica
# ./scripts/release.sh --release # compila release firmada
# ./scripts/release.sh --release --publish # borrador, para revisarla
# ./scripts/release.sh --release --publish --public # publicada: la descarga del README
#
# Variables:
# OASIS_KEYSTORE, OASIS_KEYSTORE_PASS, OASIS_KEY_ALIAS firma (obligatorias con --release)
# OASIS_GITEA_TOKEN publicar (obligatoria con --publish)
# OASIS_APK_BASE APK del que sacar node_modules la primera vez
# OASIS_GITEA_URL por defecto https://gitea.laenre.net
# OASIS_GITEA_REPO por defecto hacklab/OASIS_MOBILE
# ANDROID_HOME por defecto ~/Android/Sdk
# JAVA_HOME un JDK 17 o superior CON compilador
#
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
cd "$HERE"
MODE=debug
PUBLISH=0
PUBLIC=0
for arg in "$@"; do
case "$arg" in
--release) MODE=release ;;
--publish) PUBLISH=1 ;;
--public) PUBLIC=1 ;;
*) echo "opcion desconocida: $arg"; exit 2 ;;
esac
done
GITEA_URL="${OASIS_GITEA_URL:-https://gitea.laenre.net}"
GITEA_REPO="${OASIS_GITEA_REPO:-hacklab/OASIS_MOBILE}"
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
say() { printf '\n== %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
# --- 1) version -------------------------------------------------------------
# El nombre sale de package.json, que es de donde lo saca tambien el backend, para
# que la version que muestra la aplicacion y la de la APK no puedan discrepar.
VERSION_NAME="${OASIS_VERSION_NAME:-$(node -p "require('$REPO/package.json').version" 2>/dev/null || echo 0.0.0)}"
# El codigo tiene que subir en cada publicacion o Android rechaza la instalacion:
# el numero de commits sirve y no hay que llevarlo a mano.
VERSION_CODE="${OASIS_VERSION_CODE:-$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 1)}"
TAG="v${VERSION_NAME}-${VERSION_CODE}"
export OASIS_VERSION_NAME="$VERSION_NAME" OASIS_VERSION_CODE="$VERSION_CODE"
say "version ${VERSION_NAME} (codigo ${VERSION_CODE}), modo ${MODE}"
# --- 2) comprobaciones previas ---------------------------------------------
[ -n "${JAVA_HOME:-}" ] || die "define JAVA_HOME apuntando a un JDK con compilador"
[ -x "${JAVA_HOME}/bin/javac" ] || die "en ${JAVA_HOME} no hay javac: es un runtime, no un JDK"
# apksigner y aapt2 son scripts que llaman a "java" a pelo: sin esto fallaban con
# "java: no encontrado" y parecia que la APK estaba mal firmada
export PATH="$JAVA_HOME/bin:$PATH"
[ -d "$SDK" ] || die "no encuentro el SDK de Android en $SDK (define ANDROID_HOME)"
if [ "$MODE" = release ]; then
[ -n "${OASIS_KEYSTORE:-}" ] || die "--release necesita OASIS_KEYSTORE"
[ -f "$OASIS_KEYSTORE" ] || die "no existe el almacen de claves: $OASIS_KEYSTORE"
[ -n "${OASIS_KEYSTORE_PASS:-}" ] || die "--release necesita OASIS_KEYSTORE_PASS"
fi
if [ "$PUBLISH" = 1 ]; then
[ -n "${OASIS_GITEA_TOKEN:-}" ] || die "--publish necesita OASIS_GITEA_TOKEN"
[ "$MODE" = release ] || die "no se publica una APK de depuracion"
fi
# --- 3) backend empaquetado -------------------------------------------------
# prepare.sh es idempotente pero tarda; se rehace siempre porque el backend cambia
# en cada commit y una APK con el backend viejo es peor que no tener APK.
# La primera vez hace falta un APK base del que sacar node_modules: el del movil
# esta podado y sin binarios nativos, y un npm install normal no lo reproduce.
say "empaquetando el backend"
./scripts/prepare.sh ${OASIS_APK_BASE:+"$OASIS_APK_BASE"} \
|| die "prepare.sh ha fallado (define OASIS_APK_BASE con un APK del que sacar node_modules)"
ZIP=app/src/main/assets/nodejs-project.zip
[ -f "$ZIP" ] || die "falta $ZIP"
ZIP_MB=$(( $(stat -c%s "$ZIP") / 1024 / 1024 ))
[ "$ZIP_MB" -ge 20 ] || die "el backend empaquetado son $ZIP_MB MB: se ha quedado a medias"
say "backend: ${ZIP_MB} MB"
# --- 4) compilar ------------------------------------------------------------
say "compilando"
if [ "$MODE" = release ]; then
./gradlew --quiet assembleRelease
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
else
./gradlew --quiet assembleDebug
APK=$(ls app/build/outputs/apk/debug/*.apk | head -1)
fi
[ -f "$APK" ] || die "no se ha generado ninguna APK"
say "APK: $APK ($(( $(stat -c%s "$APK") / 1024 / 1024 )) MB)"
# --- 5) verificar -----------------------------------------------------------
# Estas tres comprobaciones son las que han cazado fallos de verdad: una APK que
# compila e instala puede no llevar el backend dentro, o haber perdido los permisos.
BT=$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1)
[ -n "$BT" ] || die "no encuentro build-tools en $SDK"
say "verificando"
# ojo con pipefail: grep -q cierra la tuberia y el productor muere con SIGPIPE,
# asi que se vuelca a fichero antes de mirar
LISTING=$(mktemp); PERMS=$(mktemp)
trap 'rm -f "$LISTING" "$PERMS"' EXIT
unzip -l "$APK" > "$LISTING"
"$BT/aapt2" dump permissions "$APK" > "$PERMS" 2>/dev/null || true
grep -q "assets/nodejs-project.zip" "$LISTING" || die "la APK no lleva el backend dentro"
grep -q "android.permission.CAMERA" "$PERMS" || die "la APK no declara CAMERA: no podria hacer videollamadas"
grep -q "android.permission.RECORD_AUDIO" "$PERMS" || die "la APK no declara RECORD_AUDIO"
grep -q "libnode.so" "$LISTING" || die "la APK no lleva el runtime de node"
grep -q "CHANGE_WIFI_MULTICAST_STATE" "$PERMS" || die "la APK no declara CHANGE_WIFI_MULTICAST_STATE: no veria otros moviles en la misma wifi"
# Una APK debuggable deja leer por adb los datos de la app, clave secreta incluida.
if "$BT/aapt2" dump badging "$APK" 2>/dev/null | grep -q "application-debuggable"; then
[ "$MODE" = release ] && die "la APK release ha salido debuggable"
say "OJO: APK de depuracion (debuggable). Solo para probar en tu movil, no la repartas"
fi
# La oficial pesa ~54 MB; si esta se dispara es que prepare.sh ha dejado de adelgazar
APK_MB=$(( $(stat -c%s "$APK") / 1024 / 1024 ))
[ "$APK_MB" -le 80 ] || say "OJO: la APK son ${APK_MB} MB (la oficial ~54): revisa que mete prepare.sh"
if [ "$MODE" = release ]; then
"$BT/apksigner" verify --print-certs "$APK" > /tmp/oasis-signer.txt 2>&1 \
|| { cat /tmp/oasis-signer.txt >&2; die "la APK no esta firmada correctamente"; }
FINGERPRINT=$(grep -m1 "SHA-256 digest" /tmp/oasis-signer.txt | awk '{print $NF}')
say "huella del certificado: $FINGERPRINT"
fi
SHA=$(sha256sum "$APK" | cut -d' ' -f1)
say "sha256 de la APK: $SHA"
# --- 6) publicar ------------------------------------------------------------
if [ "$PUBLISH" != 1 ]; then
say "listo (sin publicar)"
exit 0
fi
say "publicando $TAG en $GITEA_REPO"
API="$GITEA_URL/api/v1/repos/$GITEA_REPO"
AUTH="Authorization: token $OASIS_GITEA_TOKEN"
NOTES=$(printf 'Rama experimental del hacklab sobre el Oasis de epsylon.\n\nNO es Oasis oficial: https://github.com/epsylon/oasis\n\nVersion base: %s (codigo %s)\n\nsha256 de la APK: %s\nHuella del certificado: %s\n\nComprueba la huella antes de instalar:\n\n apksigner verify --print-certs oasis.apk\n' \
"$VERSION_NAME" "$VERSION_CODE" "$SHA" "${FINGERPRINT:-sin firmar}")
RELEASE_ID=$(curl -sf -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
-d "$(node -e '
const [tag, name, body] = process.argv.slice(1);
const pub = process.argv[4] === "1";
process.stdout.write(JSON.stringify({ tag_name: tag, name, body, draft: !pub, prerelease: !pub }));
' "$TAG" "Oasis Mobile $TAG" "$NOTES" "$PUBLIC")" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null) \
|| die "no se ha podido crear la release (token sin permiso, o el tag ya existe)"
# Siempre con el mismo nombre: el README enlaza a releases/download/latest/oasis-mobile.apk,
# y la version ya va en el tag y en el titulo de la release.
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-mobile.apk" \
-H "$AUTH" -F "attachment=@$APK" >/dev/null \
|| die "la release se ha creado pero no se ha podido subir la APK"
if [ "$PUBLIC" = 1 ]; then
say "publicada: $GITEA_URL/$GITEA_REPO/releases/latest"
else
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
say "revisala y quita el borrador a mano (o usa --public); no se publica sola a proposito"
fi

126
android/scripts/sobre_oficial.sh Executable file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env bash
#
# Construye la APK de la rama encima de la APK oficial del dev.
#
# Desde la 1.3.x la app Android de epsylon trae cosas que la nuestra no tiene: el
# puente nativo de audio y camara (.audio_bridge) que usan el Telefono y las Salas,
# las notificaciones de llamada y sus permisos. Su codigo no esta publicado, asi que
# en vez de imitarlo se parte de su APK:
#
# 1. se baja la APK oficial de la misma version (github, epsylon/oasis)
# 2. se desmonta con apktool
# 3. su backend (assets/nodejs-project.zip) se cambia por el de la rama, conservando
# su node_modules, que es el podado para el movil
# 4. se le cambia el paquete a net.laenre.oasis (y lo que depende de el) para que
# conviva con la oficial
# 5. se vuelve a montar, se alinea y se firma
#
# Uso:
# ./scripts/sobre_oficial.sh # firma con la clave de depuracion
# OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
#
# Variables:
# OASIS_VERSION version del dev (por defecto la de package.json)
# OASIS_APK_OFICIAL ruta a la APK oficial, si ya se tiene (si no, se baja)
# ANDROID_HOME por defecto ~/Android/Sdk
# JAVA_HOME un JDK 17 o superior
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
JAVA="${JAVA_HOME:+$JAVA_HOME/bin/}java"
TOOLS="$HERE/.tools"
OUT="$HERE/build/sobre-oficial"
PKG_DEV="com.solarnethub.oasis"
PKG="net.laenre.oasis"
APKTOOL_VER="3.0.3"
APKTOOL_SHA="dbf930b076c6b9be08d57c449cacefc3bdd6b71ebd59b3066fc0e1f5b14f9423"
say() { printf '\n== %s\n' "$*"; }
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
VERSION="${OASIS_VERSION:-$(node -p "require('$REPO/package.json').version")}"
BT="$(ls -d "$SDK"/build-tools/*/ | sort -V | tail -1)"
[ -x "$BT/zipalign" ] || die "no encuentro zipalign en $BT"
mkdir -p "$TOOLS" "$OUT"
# --- apktool, version fija y comprobada ---------------------------------------
APKTOOL="$TOOLS/apktool_$APKTOOL_VER.jar"
if [ ! -f "$APKTOOL" ]; then
say "bajando apktool $APKTOOL_VER"
curl -fsSL -o "$APKTOOL" "https://github.com/iBotPeaches/Apktool/releases/download/v$APKTOOL_VER/apktool_$APKTOOL_VER.jar"
fi
echo "$APKTOOL_SHA $APKTOOL" | sha256sum -c --quiet - || die "apktool no coincide con la huella esperada"
# --- la APK oficial -------------------------------------------------------------
OFICIAL="${OASIS_APK_OFICIAL:-$TOOLS/oasis-v$VERSION.apk}"
if [ ! -f "$OFICIAL" ]; then
say "bajando la APK oficial $VERSION"
curl -fsSL -o "$OFICIAL" "https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk"
fi
say "APK oficial: $OFICIAL"
"$BT/apksigner" verify --print-certs "$OFICIAL" | grep -m2 -E "certificate DN|SHA-256" || die "la APK oficial no verifica"
# --- desmontar ------------------------------------------------------------------
say "desmontando"
rm -rf "$OUT/dec"
"$JAVA" -jar "$APKTOOL" d -q -f -o "$OUT/dec" "$OFICIAL"
DEC="$OUT/dec"
# --- backend de la rama, con el node_modules del dev ---------------------------
say "cambiando el backend por el de la rama"
work="$(mktemp -d)"
( cd "$work" && unzip -q "$DEC/assets/nodejs-project.zip" )
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
rm -rf "$work/nodejs-project/src"
cp -r "$REPO/src" "$work/nodejs-project/src"
rm -rf "$work/nodejs-project/src/server/node_modules"
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/"
rm -f "$work/nodejs-project/.phone_state.json"
rm -f "$DEC/assets/nodejs-project.zip"
( cd "$work" && zip -q -r "$DEC/assets/nodejs-project.zip" nodejs-project )
rm -rf "$work"
# --- paquete propio, para convivir con la oficial ------------------------------
# Las clases se quedan como estan (com.solarnethub.oasis.OasisActivity...); cambia
# el paquete, los authorities, el permiso propio y los nombres de las acciones de
# llamada, que si no se cruzarian con los de la app oficial.
say "paquete $PKG"
sed -i \
-e "s|package=\"$PKG_DEV\"|package=\"$PKG\"|" \
-e "s|$PKG_DEV.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|$PKG.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|g" \
-e "s|android:authorities=\"$PKG_DEV.|android:authorities=\"$PKG.|g" \
"$DEC/AndroidManifest.xml"
grep -rl "\"$PKG_DEV\.[A-Z_]*\"" "$DEC"/smali* | xargs -r sed -i -E "s|\"$PKG_DEV\.([A-Z_]+)\"|\"$PKG.\1\"|g"
# las de prueba (firmadas con la clave de depuracion) se marcan depurables: asi se
# puede inspeccionar la WebView desde chrome://inspect. Las de publicar, nunca
if [ -z "${OASIS_KEYSTORE:-}" ]; then
sed -i 's|<application |<application android:debuggable="true" |' "$DEC/AndroidManifest.xml"
fi
# version: la del dev por mil mas los commits de la rama, para que suba siempre
CODE_DEV="$(sed -n 's/^ versionCode: //p' "$DEC/apktool.yml")"
CODE="$(( CODE_DEV * 1000 + $(git -C "$REPO" rev-list --count HEAD) % 1000 ))"
sed -i -e "s/^ versionCode: .*/ versionCode: $CODE/" -e "s/^ versionName: .*/ versionName: $VERSION-hacklab/" "$DEC/apktool.yml"
say "version $VERSION-hacklab (codigo $CODE)"
# --- montar, alinear y firmar --------------------------------------------------
say "montando"
"$JAVA" -jar "$APKTOOL" b -q -o "$OUT/sin-firmar.apk" "$DEC"
"$BT/zipalign" -f -p 4 "$OUT/sin-firmar.apk" "$OUT/alineada.apk"
FINAL="$OUT/oasis-$VERSION-hacklab.apk"
if [ -n "${OASIS_KEYSTORE:-}" ]; then
"$BT/apksigner" sign --ks "$OASIS_KEYSTORE" --ks-pass "env:OASIS_KEYSTORE_PASS" \
--ks-key-alias "${OASIS_KEY_ALIAS:-oasis}" --out "$FINAL" "$OUT/alineada.apk"
else
"$BT/apksigner" sign --ks "$HOME/.android/debug.keystore" --ks-pass pass:android \
--ks-key-alias androiddebugkey --out "$FINAL" "$OUT/alineada.apk"
fi
rm -f "$OUT/sin-firmar.apk" "$OUT/alineada.apk" "$FINAL.idsig"
"$BT/apksigner" verify "$FINAL" || die "la APK firmada no verifica"
say "APK: $FINAL ($(du -h "$FINAL" | cut -f1))"
echo "sha256: $(sha256sum "$FINAL" | cut -d' ' -f1)"

View file

@ -0,0 +1,9 @@
pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() }
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = "OasisMobile"
include(":app")

106
docs/ESTUDIO_NAVEGACION.md Normal file
View file

@ -0,0 +1,106 @@
# Cómo moverse por Oasis en el móvil
Estudio para decidir la navegación de la rama. 19/10/2026.
## La pregunta
Oasis tiene casi sesenta módulos. En un escritorio caben en un menú lateral; en un
móvil no. ¿Qué tiene que ver alguien al abrir la app, y cómo llega al resto?
## Lo que hay ahora
Medido en el emulador (Pixel 9, 412 px de ancho) con el workflow acampada (16 módulos):
- Al abrir, la portada es el lanzador: una rejilla de iconos por secciones que ocupa
unos 1.300 px, casi pantalla y media. La actividad, que es lo que ha pasado en la
red, empieza debajo y no se ve sin bajar.
- Abajo hay una barra de cuatro atajos que elige cada cual (Actividad, Chats,
Publicar, Buzón de serie).
- Para ir a un módulo que no está en la barra hay que volver a la portada y buscarlo
en la rejilla. Desde dentro de un módulo no hay acceso directo al resto.
O sea: la portada es un menú, no un sitio donde pase algo, y el menú solo está en la
portada.
## Cómo lo hacen otras
- Instagram, TikTok, X: el contenido es la portada. Abajo, cinco destinos fijos
(inicio, buscar, crear, vídeos, perfil). Lo demás vive en el perfil o en ajustes.
- WhatsApp, Signal, Telegram: pestañas con las tres o cuatro cosas que se usan
siempre. Lo demás, en un menú de tres puntos.
- Google Maps, Gmail y la guía de Material Design: una barra abajo con entre tres y
cinco destinos; si hay más, el último es "Más" y abre una hoja con el resto.
- El menú hamburguesa arriba a la izquierda (muy usado en 2012-2016) se ha ido
abandonando: lo que no se ve se usa menos, y arriba queda lejos del pulgar.
## Qué pedimos a la navegación
1. Al abrir, ver qué pasa (actividad), no un menú.
2. Llegar a cualquier módulo desde cualquier pantalla, en dos toques como mucho.
3. Que el sitio para hacerlo se vea sin explicarlo.
4. Que se alcance con el pulgar con el móvil en una mano.
5. No tapar contenido ni quitar sitio de pantalla.
## Opciones
| | Portada | Llegar a un módulo | Pulgar | Se ve | Comentario |
|---|---|---|---|---|---|
| A. Como ahora | Lanzador | Volver a portada y buscar | Medio | Sí, en portada | La actividad queda enterrada |
| B. Botón "Menú" bajo la barra de arriba | Actividad | 2 toques | Lejos (arriba) | Sí | La idea inicial; ocupa una fila arriba |
| C. "Menú" como quinta pestaña de abajo | Actividad | 2 toques | Cerca | Sí | Patrón "Más" de Android y de las apps grandes |
| D. Hamburguesa arriba | Actividad | 2 toques | Lejos | Poco | Lo que se esconde se usa menos |
## Recomendación
C: la portada pasa a ser la actividad, y la barra de abajo tiene siempre una quinta
pestaña, "Menú", que abre desde abajo una hoja con todos los módulos agrupados (el
mismo lanzador de ahora). Se cierra tocando fuera, con la X o volviendo a tocar Menú.
Es la idea de B, un botón que despliega las opciones, pero puesto donde llega el
pulgar y donde está el resto de la navegación. Funciona igual en todas las pantallas.
Está hecho con una casilla y CSS; un script mínimo hace que el botón atrás de Android
cierre la hoja en vez de cambiar de página.
## Lo que salió en la prueba (emulador, 19/10/2026)
- Portada a Tribus: dos toques (Menú, Tribus). Desde Foros a Votaciones: dos toques.
- En la primera versión la hoja dejaba ver 690 px y los módulos medían 1.200: Tribus
quedaba fuera. Con cinco iconos por fila y los grupos pequeños de dos en dos, los 20
módulos caben a 412 y a 360 px.
- Se cierra tocando fuera, con la X y volviendo a tocar Menú.
- Con la hoja abierta, atrás cambiaba de página. Ahora la cierra.
- Al volver atrás desde un módulo abierto desde la hoja, la portada salía con la hoja
abierta (la entrada del historial de la hoja seguía ahí). Ahora se quita antes de
ir al módulo: atrás lleva a la portada, cerrada.
## Tamaños y sistemas (19/10/2026)
| Dónde | Motor | Resultado |
|---|---|---|
| Pixel 9, Android 16, 412 dp, gestos y tres botones | WebView actual | Bien. APK real |
| 360 × 540 dp | WebView actual | La hoja se desliza; todo se alcanza |
| Tableta 800 dp | WebView actual | Contenido y hoja centrados a 720 y 640 px |
| Letra al 130 % | WebView actual | Bien |
| Pixel 3a, Android 9, 412 dp | WebView 69 | Tras los arreglos, bien (ver abajo) |
| 360 dp, Android 11 | Chrome/WebView 83 | Bien: 9 pantallas sin desbordar, 5 pestañas de 70 dp |
En Android 9 y 11 de emulador la APK no arranca: el node del dev es arm64 y el
traductor ARM de las imágenes x86 se para con una instrucción que no conoce (SIGILL).
Pasa igual con la APK oficial, así que no es de la rama. Se probó el motor con el
servidor local.
Lo que fallaba en WebView 69 y está arreglado:
- Fichas y filtros pegados unos a otros (no hay `gap` en flex): márgenes.
- La hoja del menú no se veía (no hay `inset` ni `dvh`): top/left/right/bottom y `vh`.
- Los grupos pequeños del menú se montaban (no hay `:has`): de dos en dos solo donde
hay `:has`; en el viejo, cada grupo a lo ancho.
- El botón No molestar quedaba encima de la hoja: ahora va por debajo de ella.
## Qué mirar en la prueba
- Desde la portada, cuántos toques hacen falta para llegar a Tribus, a Votaciones y a
Ajustes, y desde dentro de un foro.
- Si alguien que no conoce la app encuentra el menú sin que se lo digan.
- Si al volver atrás la hoja se queda abierta (no debería).
- Que la barra de cinco quepa a 360 px de ancho.

163
docs/FRONTERA_UPSTREAM.md Normal file
View file

@ -0,0 +1,163 @@
# Frontera con upstream: que se puede tocar y que no
Las normas del dia a dia estan en [GUIA_DE_DESARROLLO.md](../GUIA_DE_DESARROLLO.md); esto
es la medicion de la que salen.
Medido el 19/10/2026 sobre el repositorio del dev (code.03c8.net/krakenslab/oasis),
de la release 1.0.1 (29/08) a la 1.3.3 (18/10), y comparado con la rama PRUEBAS.
Los scripts estan en el historial de la sesion; para repetirlo basta clonar upstream y
contar con `git log --numstat` por fichero.
## 1. Como trabaja el dev
- Una release casi cada dia: 39 entre la 1.0.1 y la 1.3.3, en siete semanas.
Cada una es un solo commit ("Oasis release X.Y.Z"), sin historia intermedia: no
hay forma de coger un cambio suelto, siempre llega todo junto.
- El backend crece sin parar: `backend.js` paso de ~9.900 a ~15.400 lineas y se
toca en 34 de 39 releases. La cadena de middleware (el array `middleware` del final)
ha ganado capas en este tiempo: contexto por peticion (`requestScope`), idioma de
primera ejecucion, CSP, etc.
- Ya piensa en el movil: tiene su propio tema `OasisMobile.css`, usa la misma
bandera `process.env.OASIS_MOBILE` que la rama y tiene workflows `activists` y
`mobile`. Va en nuestra direccion; cuanto mas nos pisemos con el, peor.
- Las clases CSS cambian, pero poco a poco: de la 1.0.1 a la 1.2.0 aparecen 335
clases y desaparecen 98; hasta la 1.3.1, +304 / -94; de la 1.3.1 a la 1.3.3, +47 / -8.
Unas 100 clases muertas cada tres semanas, sobre ~2.200: un 5 %.
Ficheros que mas toca (releases que lo tocan de 39 / lineas movidas):
| Fichero | Releases | Lineas |
|---|---|---|
| `src/backend/backend.js` | 34 | 11.621 |
| `src/server/package.json` + lock | 34 | 22.700 |
| `src/client/assets/styles/style.css` | 32 | 2.522 |
| `translations/oasis_*.js` (los 11) | 31 cada uno | ~2.000 cada uno |
| `src/views/main_views.js` | 31 | 2.634 |
| `src/views/activity_view.js` | 27 | 1.183 |
| `src/views/tribes_view.js` | 23 | 2.675 |
| `src/client/assets/themes/OasisMobile.css` | 21 | 3.040 |
| `src/views/settings_view.js` | 17 | 849 |
| `src/views/vote_view.js` | 14 | 155 |
| `src/configs/config-manager.js` | 11 | 120 |
| `src/client/assets/styles/mobile.css` | 10 | 514 |
| `src/views/parliament_view.js` | 10 | 282 |
| `src/models/workflows_model.js` | 8 | 133 |
| `src/views/welcome_view.js` | 8 | 440 |
| `src/server/SSB_server.js` | 7 | 183 |
| `src/models/votes_model.js` | 4 | 33 |
| `src/backend/request_guards.js` (CSP) | 4 | 111 |
## 2. El mapa
### Rojo: no se toca
Cada linea nuestra aqui es un conflicto casi seguro en la siguiente release, y ademas
cambia el comportamiento de la red, no solo como se ve.
- `src/backend/backend.js`: rutas y, sobre todo, la cadena de `middleware`.
- `src/models/*` de upstream: lo que se publica en el log SSB y como se cuenta. Si dos
nodos cuentan distinto (votos, tribus, gobierno), la red se rompe. Ejemplo: "quien
crea una votacion no vota" es una regla de `votes_model.js`; cambiarla en la rama
haria que nuestro movil y un escritorio dieran resultados distintos.
- `src/server/*` (arranque de SSB, `ssb_config`, `package.json` y dependencias).
- `src/backend/request_guards.js` (CSP: no hay estilos ni scripts en linea, y asi
debe seguir).
- `translations/oasis_*.js`: se reescriben en 31 de 39 releases.
- `src/client/assets/styles/style.css` y `mobile.css`: son del dev.
- `src/client/assets/themes/*.css` de upstream, incluido `OasisMobile.css`.
### Ambar: solo un gancho de una linea, con la bandera de movil
Las vistas de upstream (`main_views.js`, `tribes_view.js`, `vote_view.js`,
`settings_view.js`...). Se reescriben a menudo, asi que lo que la rama necesite ahi va
como una llamada a una funcion nuestra, envuelta en
`process.env.OASIS_MOBILE === '1'`, y la logica vive en un fichero propio. Si el dev
reescribe la vista, se vuelve a poner una linea, no se reconstruye una funcion.
### Verde: nuestro, sin limite
- Hojas propias: `themes/Acampada.css`, `styles/mobile_base.css`,
`styles/hive_acampada.css`, `styles/mobile_ux.css`.
- Vistas propias: `hive_views.js`, `mobile_icons.js`, `identities_view.js` y las
`mobile_*.js`.
- El router propio `src/backend/fork_routes.js`: va justo antes del de upstream en la
cadena y ve todas las peticiones. Puede atender rutas nuevas, o envolver una ruta de
upstream (dejarla hacer y retocar la respuesta) sin editarla.
- Traducciones propias en `i18n_fork.js` (se mezclan encima de las de upstream).
- Modelo propio `fork_workflows.js`.
- Todo `android/`.
## 3. Como esta la rama hoy
43 ficheros de upstream modificados y 22 propios. Lo que mas pesa:
| Fichero de upstream | Lineas distintas | Riesgo |
|---|---|---|
| `themes/OasisMobile.css` | 4.873 | Alto: es casi otro fichero. El dev lo toca en 21/39 releases y cada vez hay que elegir entre su version y la nuestra. |
| `views/main_views.js` | 173 | Alto (31/39) |
| `backend/backend.js` | 133 (21 trozos) | Alto (34/39) |
| `configs/oasis-config.json` | 135 | Medio: es la config de serie del movil |
| `backend/blobHandler.js` | 190 | Medio (limpieza de fotos) |
| `backend/updater.js` | 133 | Bajo (3/39) |
| `views/modules_view.js` | 91 | Bajo (5/39) |
| `views/parliament_view.js`, `settings_view.js`, `vote_view.js`, `tribes_view.js` | 50-75 cada una | Medio-alto |
| `models/workflows_model.js` | 31 | Medio: redefine el workflow `activists` del dev |
| `server/ssb_config.js`, `SSB_server.js` | 40 | Medio (multicuenta) |
Las hojas propias y `OasisMobile.css` llevan selectores a clases que ya no pinta nadie:
61 en `OasisMobile.css`, 29 en `hive_acampada.css`, 5 en `Acampada.css`. No rompen
nada, pero son peso muerto y confunden.
## 4. Reglas para cada cambio
1. Primero CSS. Si se arregla con una hoja propia, se arregla ahi.
2. Si hace falta HTML distinto: funcion en un fichero propio + una linea en la vista
de upstream, con la bandera de movil.
3. Si hace falta una ruta o retocar una respuesta: en el router propio. Nunca una
ruta nueva ni un middleware en `backend.js`.
4. Nunca cambiar lo que se publica ni como se cuenta (modelos de upstream).
5. Textos nuevos en `i18n_fork.js`, en los 11 idiomas.
6. Nada en linea: la CSP no deja `style=""` ni `<script>` en la pagina.
7. Antes de integrar una release, repetir esta medicion y mirar si alguna clase de la
que dependemos ha desaparecido.
## 5. Plan para sanear la rama
- Fase A (hecha el 19/10): lo del 19/10 que se metio en ficheros del dev sale a
ficheros propios. Ver la seccion 6.
- Fase B (hecha el 19/10): `OasisMobile.css` vuelve a ser la del dev, identica a la
1.3.3. Nuestra maqueta esta en `styles/mobile_base.css`, que el movil carga siempre
antes del skin. Con el skin OasisMobile se ve el tema del dev encima de la maqueta.
- Fase C (hecha el 19/10): de los 20 trozos de `backend.js` quedan 9 (21 lineas).
El resto (rutas cortadas en el movil, modulos que no van, avatar, lanzador,
retoques del HTML, descargas de la copia de seguridad, cambio de tema) esta en
`backend/mobile_layer.js` y en `fork_routes.js`. Los que quedan no se pueden
hacer desde fuera: expresiones regulares para Node 18, no abrir el navegador, fotos
duplicadas, modo chats y el montaje del router.
- Fase D (hecha el 19/10): `activists` vuelve a ser el del dev. Karvan y el workflow
`acampada` se registran desde `models/fork_workflows.js`. La config de serie del
movil coincide con `acampada`. Queda en `workflows_model.js` la logica de skins del
movil.
- Correccion (19/10, noche): para el movil la referencia buena es el nodejs-project de
la APK oficial, no el git del dev. Parte de lo que la fase C saco de backend.js era
codigo de movil del propio dev; backend.js vuelve a ser su version de movil mas dos
lineas (montar nuestro router), y modules_view.js su version de movil. Contra la APK
oficial la rama cambia 17 ficheros y 561 lineas; la lista esta en la guia.
- Fuera Karvan (19/10): el dev ya trae Telefono y Salas, asi que se quita el modulo
propio de salas y llamadas (modelo, vista, JS, CSS, router, pruebas, textos).
`Clear-SNH.css` y `modules_view.js` vuelven a ser identicos a los del dev y la linea
de `SSB_server.js` que enchufaba el audio de Android pasa a `main.js`, usando los
`setAudioFactory`, `setVideoFactory` y `setStreamFactory` que el propio dev exporta.
- Fase E (hecha el 19/10): fuera 153 selectores muertos de `mobile_base.css`,
`hive_acampada.css` y `Acampada.css` (287 lineas). Comparado pixel a pixel en 14
pantallas: identicas.
## 6. Fase A: lo que se ha movido
| Antes (en ficheros del dev) | Ahora |
|---|---|
| `backend.js`: `checkMod` envuelto para recordar la seccion apagada | El router propio mira las redirecciones a `/modules` de vuelta |
| `backend.js`: ruta `POST /modules/enable` y `?saved=1` tras guardar | Router propio |
| `modules_view.js`: vista de Modulos del movil | `views/mobile_modules_view.js`, servida por el router propio |
| `workflows_model.js`: cuatro modulos mas en `activists` | Workflow propio `acampada` registrado desde `models/fork_workflows.js` |
| `vote_view.js`, `tribes_view.js`, `settings_view.js`, `main_views.js`, `welcome_view.js`: funciones nuevas | Funciones en `views/mobile_ui.js`; en la vista de upstream queda la llamada |

254
docs/USABILIDAD_PAD.txt Normal file
View file

@ -0,0 +1,254 @@
Prueba de usabilidad - Oasis Mobile 1.3.3 (rama PRUEBAS, tema Acampada)
19/10/2026 - emulador Pixel 9, Android 16, 1080x2424; tambien 720x1280 y letra al 130 %
Escenario: alguien de la acampada que entra, cambia a castellano, activa tribus
y parlamento, crea tribus, vota y abre una sala de voz.
=================================
Botones complejos de interpretar
=================================
- Los iconos del lanzador son letras del alfabeto vai (ꔙ ꕿ ꖒ ꕒ ꔗ...). No dicen
nada de lo que hacen: hay que leer siempre la etiqueta de debajo.
- Karvan (⧗) y Nodos (⧖) llevan casi el mismo reloj de arena.
Videos lleva algo que parece un simbolo de dolar.
- En la sala de voz los botones 🎤 🎥 ⛔ no tienen texto ni descripcion.
⛔ no se sabe si es colgar, silenciar o salir.
- En cada tarjeta de tribu el primer boton es una ✕ roja: es BORRAR TRIBU y
borra con un solo toque, sin preguntar. Probado: la tribu desaparece.
- En "Crear tribu" el texto CREAR TRIBU sale tres veces (boton de arriba,
titulo y boton de enviar). El de arriba recarga la pagina y borra lo escrito.
- Dos campos seguidos se llaman "Ubicacion" (lugar y enlace de mapa).
- "Estado: Privada/Publica" y "Modo: Estricto/Abierta" no explican que
significan. Por defecto sale Privada + Estricto.
- Votacion: las opciones salen mezcladas en ingles y castellano
(YES, NO, ABSTENTION, CONFUNDIDO, SEGUIR MAYORIA, SIN INTERES), partidas por
silabas y la tabla se corta por la derecha.
- Jerga a la vista: "server relay", "Synced-peers", "HcT/HcH", "TASA ECO",
la clave @...ed25519 completa en el pie y en el buzon.
=================================
Inicio intuitivo "empezar"
=================================
- El aviso "Tu avatar aun no esta configurado - EMPEZAR" sale arriba en TODAS
las paginas. En pantalla pequena se come casi una quinta parte. EMPEZAR no
dice que va a pasar (es poner foto y nombre).
- En Bienvenida el primer boton, el mas grande, es "AHORA NO". Lo facil es
saltarse todo.
- El nombre viene relleno con un trozo de la clave (jlYELyx4). Parece un nombre
ya puesto y no lo es.
- El idioma sale en el del movil la primera vez (el emulador estaba en ingles).
Cambiarlo despues costaba: Ajustes, bajar a la segunda pantalla, desplegable,
Espanol y boton "Establecer idioma".
- La pantalla de carga si sale en castellano y tarda unos 10 s: bien.
- Ajustes mide unas 7 pantallas. Lo que mas se toca (idioma, aspecto) queda
debajo de Identidades, que es lo mas tecnico.
- Al abrir una seccion apagada (p. ej. Tribus) te manda sin avisar a la tabla
de Modulos. Deberia decir "Tribus esta apagado - Activar".
- Invitar a una sala pide el @feed-id de la otra persona. Nadie lo sabe de
memoria: hace falta elegir de los contactos o compartir un enlace / QR.
=================================
Reduccion de funcionalidades
"skin activistas"
=================================
- El flujo "activistas" NO activa tribus, parlamento ni votaciones. Para una
acampada con 12 tribus es justo lo que falta. Propuesta: anadir tribes,
parliament y votes al flujo activistas (o hacer un flujo "acampada").
- Activar un modulo a mano: la pagina Modulos son 52 filas, unas 11 pantallas.
Tribus esta a 7800 px de altura y la casilla mide 16x16 px (lo minimo
recomendable son 48). El boton Guardar esta al final del todo.
- Propuesta: en movil, Modulos como interruptores grandes agrupados
(Hablar / Organizarse / Compartir) y guardado al tocar.
- Cada tribu que creas te manda a ti mismo un mensaje del POLITICALBOT
("Ha comenzado un nuevo ciclo de gobierno... Anarchy"). 12 tribus = 12 avisos
en el buzon. En movil sobra o deberia agruparse.
=================================
Voz y turnos de palabra
=================================
- LLAMAR en Karvan pide primero la CAMARA aunque solo quieras hablar. Si dices
que no a la camara la llamada no arranca, aunque des el micro. Sale el aviso
"pendiente del wrapper", que es una nota interna.
Arreglo sencillo: LLAMAR empieza solo con audio y 🎥 anade video.
(src/client/public/js/karvan.js, funcion startCall)
- Solo el boton 🎤 suelto funciona con audio.
- La llamada de Karvan se corta al cambiar de pagina (probado: el micro se para
al entrar en Parlamento). La sala que sigue viva al navegar es la de Mumble,
que va en otra app.
- Propuesta: en Parlamento y en cada tribu, boton "Entrar a la sala de voz" que
abra mumble://mumble.acampada26s.net/<canal de la tribu>.
- Turnos de palabra con tiempo: no existe. Encaja como cola de "pido palabra"
dentro de la sala (lista + cronometro visible para todas).
=================================
Listado de tribus
=================================
- Confirmado: cada tribu ocupa casi una pantalla porque todas repiten la misma
imagen generica grande. 12 tribus = 10 pantallas de scroll.
- Propuesta: lista compacta (nombre + miembros + publica/privada, una linea por
tribu), imagen solo si la tribu tiene una propia, y el buscador arriba fijo.
=================================
Votaciones: semaforo / termometro
=================================
- El servidor ya acepta opciones propias (campo "options" separado por comas),
pero el formulario no lo ofrece: siempre salen las 6 opciones fijas.
- Propuesta: al crear, elegir tipo: Si/No - Semaforo (verde, amarillo, rojo) -
Termometro (1 a 5). Sale casi gratis.
- Quien crea la votacion no puede votar en ella y la pantalla no lo explica.
En una asamblea, quien facilita se queda sin voto.
- El selector de fecha es el nativo de Android y propone una semana: bien.
=================================
Lo que va bien
=================================
- Sin desbordes horizontales en 1080 ni en 720 de ancho (salvo tablas).
- Tema Acampada legible, botones grandes, buen contraste.
- La barra de abajo (Actividad, Chats, Publicar, Buzon) se entiende sola.
- Actualizar encima de la 0.9.5 no pierde la identidad ni los datos.
Pendiente de probar: primer arranque en limpio (instalacion nueva), dos moviles
en la misma sala, y la barra de abajo que tapa media pantalla con el teclado
abierto (visto en Crear tribu).
=================================
Arreglado en PRUEBAS (19/10/2026)
=================================
- Borrar pide confirmacion ("¿Borrar para siempre? Si, borrar") y la X va la
ultima de la fila. Vale para tribus y para todo el contenido.
- Karvan: Llamar empieza solo con voz y la camara se anade con su boton.
Botones con icono y nombre (Llamar, Micro, Camara, Colgar). Sin el recuadro
gris en llamadas de voz. Ayuda para invitar. Textos en los 11 idiomas.
- Votaciones: al crear se elige Si/No, Semaforo (verde, amarillo, rojo) o
Termometro (1 a 5). Resultados en barras. Aviso a quien la crea.
- Flujo activistas con tribus, parlamento, votaciones y eventos.
- Abrir una seccion apagada dice cual es y ofrece "Activar" de un toque.
- Modulos: agrupados como el lanzador, interruptores grandes, Guardar fijo.
- Tribus en filas compactas (12 tribus: de 10 pantallas a unas 3). Dentro de
una tribu, las 25 secciones en una tira de fichas + "Mas".
- Iconos del lanzador y de la barra: dibujos claros en vez de letras vai.
La barra de abajo marca la seccion abierta y se esconde con el teclado.
- Ajustes empieza por idioma y aspecto; los idiomas se eligen de un toque.
- Aviso del avatar solo en la portada y en una linea. "Ahora no" al final de
la bienvenida.
- Pie de una linea: "Aun no sincronizas con nadie" / "Sincronizando con N".
- Fuera la jerga: tasa ECO, HcT/HcH, "server relay".
- Espacio: un solo margen de 14px (las fichas perdian 74px por lado).
Letra: Anton solo en titulos y en el boton principal.
=================================
Sigue pendiente
=================================
- Invitar a una sala o mandar un privado eligiendo de los contactos, no
pegando el @id.
- Turnos de palabra con tiempo en la sala de voz.
- Boton "Entrar a la sala de voz" (Mumble) en Parlamento y en cada tribu.
- Quien crea una votacion sigue sin poder votar (regla de upstream: cambiarla
haria que los nodos contaran distinto).
- Probar un primer arranque en limpio y dos moviles en la misma sala.
=================================
Segunda ronda (19/10/2026, tarde)
=================================
Desde ahora cada arreglo respeta docs/FRONTERA_UPSTREAM.md: primero CSS propio;
si hace falta HTML, una funcion propia y una linea en la vista del dev; nada en
backend, middleware ni modelos del dev.
- Boton principal de cada modulo (Crear chat, foro, pad, feed, evento...): el
mismo en todos. Antes unos a todo lo ancho, otros estrechos, otros con otra
letra. [CSS]
- Listas vacias: icono, texto centrado y recuadro discontinuo, en vez de una
linea suelta ("No se encontraron chats."). [CSS]
- El pie se queda abajo aunque la pagina sea corta. [CSS]
- Buzon: cada mensaje en pocas lineas (remitente y fecha arriba, el aviso, el
titulo y tres lineas de texto). Fuera Para (eres tu), Tasa ECO y Cifrado, que
eran iguales en todos. Antes un aviso ocupaba una pantalla. [CSS]
- Busqueda: primero el campo de buscar; las fechas y el ID despues. [CSS]
- Nodos: Refrescar, Quitar inactivos y Exportar en rejilla. [CSS]
- Workflow propio "Acampada" (chats, Karvan, pads, foros, wiki, tribus,
parlamento, votaciones, eventos...) en Ajustes y en la bienvenida, sin tocar
el "Activismo" del dev. [fichero propio]
Visto y pendiente de esta ronda:
- Buzon: Marcar como leido / Archivar / Borrar siguen uno debajo de otro.
- Buzon: los avisos del PoliticalBot de las tribus propias siguen llegando
(es upstream; se puede agrupar en la vista, no quitar).
- Los ID @...ed25519 siguen a la vista en perfil, buzon y nodos: falta mostrar
el nombre y dejar el ID para "copiar".
=================================
Tercera ronda: emulador limpio (19/10/2026, noche)
=================================
Emulador nuevo (Pixel 9, Android 16, 12 GB) para probar una instalacion desde
cero con la APK sin Karvan (codigo 102).
- Primer arranque: 18 segundos hasta la portada. Bien.
- Quien entra por primera vez no ve por donde empezar: el lanzador ocupa toda la
pantalla y el aviso "pon tu nombre y tu foto" quedaba a 1.300 px, abajo del
todo. Arreglado: el aviso va encima del lanzador. [CSS]
- El boton No molestar del Telefono ocupaba una fila entera arriba de cada
pagina. Arreglado: va en la barra de arriba, junto al avatar. [CSS]
- Ajustes marcaba English aunque se hubiera elegido otro idioma. Arreglado. [vista propia]
- LLAMADAS: en esta APK el Telefono dice "las llamadas no estan disponibles". La
APK oficial del dev trae un puente nativo de audio y camara (.audio_bridge) y
la nuestra no. Hay que escribirlo en la app Android o construir sobre la APK
oficial. Mientras tanto, en la rama no hay llamadas.
- El avatar por defecto (una calavera con gafas) asusta un poco para quien
empieza. Es del dev; se puede cambiar solo en el skin.
Con la APK montada sobre la oficial (1.3.3-hacklab, mismo emulador):
- Llamadas: ahora SI. El Telefono ofrece "Nueva llamada" y en una sala el micro
graba de verdad (comprobado en Android: VOICE_COMMUNICATION activo) y se corta
al salir.
- Turnos de palabra: las Salas del dev ya tienen "Levantar la mano". Es la base de
lo que pedia el pad; falta el cronometro.
- Dentro de una sala, los controles (silenciar, camara, levantar la mano, salir)
quedan muy abajo, debajo de la ficha de la sala. Habria que tenerlos a mano
mientras se esta dentro. [pendiente, CSS]
- La app del dev pide el permiso de camara nada mas abrirse, antes de ensenar
nada. Es su app; no se toca.
- El boton de crear sala se tapa con el teclado al escribir el titulo: hay que
cerrar el teclado para verlo.
=================================
Cuarta ronda: tamaños y sistemas (19/10/2026)
=================================
Con la navegación nueva (portada = actividad, pestaña Menú abajo).
- Android 16, Pixel 9 (APK real): menú, atrás con gesto y con tres botones, ir a un
módulo desde la hoja y volver. Bien.
- Fallaba: volver atrás desde un módulo abierto desde la hoja dejaba la portada con
la hoja abierta. Arreglado. [script propio]
- Android 9 (WebView 69, el que trae sin actualizar): fichas pegadas, hoja del menú
sin pintar, grupos montados y No molestar encima de la hoja. Arreglado. [CSS]
- Android 11 a 360 dp (motor 83): nueve pantallas sin desbordar, sin textos de
menos de 12 px ni botones de menos de 40 px. La barra de cinco cabe (70 dp cada una).
- En emulador, Android 9 y 11 no arrancan la APK (ni la nuestra ni la oficial): el
node arm64 no corre en el traductor ARM de x86. En un móvil de verdad no pasa; falta
probarlo en uno físico con Android 9-11.
- Tableta y letra al 130 %: bien.
- Bienvenida, paso Tipo: había que elegir una tarjeta y bajar seis tarjetas grandes
hasta Guardar. Ahora cada opción es un botón: un toque y queda guardado, el paso se
marca y se abre el siguiente. Una fila por opción con miniatura (en rejilla de tres,
"Conversaciones" se partía a 360 px). En Ajustes, lo mismo, y la sección va justo
después del idioma para encontrarla fácil. [vista propia + una línea en welcome_view
y settings_view]
- Bienvenida: faltaba elegir para qué se usa Oasis (el backend del dev quita ese paso
en el móvil). Vuelve como paso "Perfil", el segundo, justo después de la identidad:
Activismo primero, luego Acampada y el resto, cada uno con una línea de para qué
sirve. Un toque y guardado. En Ajustes, la misma lista. [vista propia + una línea
en welcome_view y settings_view]
- Con el perfil Activismo (45 módulos) los botones Personal/Social de la barra de
arriba no cabían a 360 px y tapaban el logo. Ya no hacían falta (filtraban el
lanzador, que ahora está en la hoja del menú): quitados. [vista propia]

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
docs/capturas/02-tribus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
docs/capturas/06-barra.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
docs/capturas/08-salas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Portada y esquemas del README, con la paleta del tema Acampada.
Las capturas de movil (01..08) se hacen aparte, con la app arrancada en un nodo de
pruebas a 412x915 y escala 2; este script compone 00-portada.png a partir de ellas y
dibuja esquema-interfaz.png, esquema-rama.png y esquema-llamadas.png.
El texto vive aqui: se cambia y se relanza, nunca se retocan los PNG a mano.
python3 docs/capturas/generar_portada.py
"""
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
HERE = Path(__file__).resolve().parent
ANTON = HERE.parents[1] / "src/client/assets/fonts/Anton-Regular.ttf"
SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
BG, FG, NEON, MUTED = "#ffffff", "#000000", "#00e5ff", "#555555"
def font(path, size):
return ImageFont.truetype(str(path), size)
def phone(shot, width):
"""Una captura dentro de un marco negro grueso con la sombra cian de los botones."""
im = Image.open(HERE / shot).convert("RGB")
h = round(im.height * width / im.width)
im = im.resize((width, h), Image.LANCZOS)
pad, radius, shadow = 10, 34, 10
frame = Image.new("RGBA", (width + 2 * pad, h + 2 * pad + shadow), (0, 0, 0, 0))
d = ImageDraw.Draw(frame)
d.rounded_rectangle((0, shadow, width + 2 * pad - 1, h + 2 * pad + shadow - 1), radius, fill=NEON)
d.rounded_rectangle((0, 0, width + 2 * pad - 1, h + 2 * pad - 1), radius, fill=FG)
mask = Image.new("L", im.size, 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, width - 1, h - 1), radius - pad, fill=255)
frame.paste(im, (pad, pad), mask)
return frame
def portada():
W, H = 1600, 1100
out = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(out)
d.text((80, 40), "OASIS MOBILE", font=font(ANTON, 92), fill=FG)
bottom = d.textbbox((80, 40), "OASIS MOBILE", font=font(ANTON, 92))[3]
d.rectangle((80, bottom + 12, 420, bottom + 20), fill=FG)
d.text((80, 196), "Rama PRUEBAS del hacklab · base Oasis 1.3.3 · tema Acampada, pensado para el pulgar",
font=font(SANS, 27), fill=MUTED)
shots = [("01-portada.png", "Actividad al abrir"), ("02-tribus.png", "Tribus en filas"),
("03-votacion.png", "Votar en semáforo"), ("07-oasismobile.png", "El menú, tema del dev")]
pw, gap, top = 320, 50, 270
x = (W - (4 * (pw + 20) + 3 * gap)) // 2
cap = font(ANTON, 34)
for shot, label in shots:
fr = phone(shot, pw)
out.paste(fr, (x, top), fr)
tw = d.textlength(label.upper(), font=cap)
d.text((x + (fr.width - tw) / 2, top + fr.height + 18), label.upper(), font=cap, fill=FG)
x += fr.width + gap
out.save(HERE / "00-portada.png", optimize=True)
def caja(d, box, titulo, lineas, relleno=BG):
x0, y0, x1, y1 = box
d.rounded_rectangle((x0, y0 + 6, x1, y1 + 6), 18, fill=FG)
d.rounded_rectangle(box, 18, fill=relleno, outline=FG, width=4)
t = font(ANTON, 30)
d.text(((x0 + x1 - d.textlength(titulo, font=t)) / 2, y0 + 18), titulo, font=t, fill=FG)
s = font(SANS, 18)
for i, l in enumerate(lineas):
d.text(((x0 + x1 - d.textlength(l, font=s)) / 2, y0 + 70 + i * 26), l, font=s, fill=FG)
def esquema():
W, H = 1400, 840
out = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(out)
d.text((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46), fill=FG)
bottom = d.textbbox((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46))[3]
d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG)
d.text((46, bottom + 30), "Casi sesenta módulos no caben en una pantalla de teléfono. Lo primero es lo que pasa; el resto, en el menú.",
font=font(SANS, 18), fill=MUTED)
# el movil esquematico
px0, py0, px1, py1 = 110, 170, 470, 790
d.rounded_rectangle((px0, py0, px1, py1), 30, outline=FG, width=5)
d.rounded_rectangle((px0 + 16, py0 + 20, px1 - 16, py0 + 84), 14, fill=BG, outline=FG, width=3)
s = font(SANS, 15)
d.text((px0 + 46, py0 + 42), "logo · no molestar · avatar", font=s, fill=FG)
# en medio, la actividad: lo que ha pasado en la red
y = py0 + 110
d.text((px0 + 40, y), "ACTIVIDAD", font=font(ANTON, 22), fill=FG)
d.line((px0 + 40, y + 32, px1 - 40, y + 32), fill=FG, width=3)
y += 52
for i in range(4):
d.rounded_rectangle((px0 + 40, y, px1 - 40, y + 86), 12, fill=BG, outline=FG, width=3)
d.rectangle((px0 + 56, y + 18, px0 + 150, y + 30), fill=FG)
d.rectangle((px0 + 56, y + 44, px1 - 70, y + 52), fill=MUTED)
d.rectangle((px0 + 56, y + 62, px1 - 120, y + 70), fill=MUTED)
y += 104
d.rounded_rectangle((px0 + 16, py1 - 84, px1 - 16, py1 - 20), 14, fill=NEON, outline=FG, width=3)
d.text((px0 + 62, py1 - 62), "4 atajos que eliges tú · MENÚ", font=s, fill=FG)
bx0, bx1 = 560, 1320
cajas = [
((bx0, 190, bx1, 340), "ARRIBA — LA BARRA SUPERIOR",
["El logo vuelve al inicio y el avatar abre tu perfil.",
"En medio, el botón de No molestar del Teléfono.",
"Con muchos módulos activos salen también los filtros Personal y Social."], BG),
((bx0, 375, bx1, 565), "EN MEDIO — LA ACTIVIDAD",
["Al abrir la app se ve lo que ha pasado en la red, no un menú.",
"Dentro de cada módulo, los filtros van en fichas pequeñas",
"y el botón principal (crear tribu, crear sala…) siempre igual."], BG),
((bx0, 600, bx1, 750), "ABAJO — LOS ATAJOS Y EL MENÚ",
["Cuatro atajos a tu elección y una quinta pestaña fija, Menú,",
"que abre desde abajo una hoja con todos los módulos por secciones",
"(Comunidad, Biblioteca, Gobernanza…). Está en todas las pantallas."], NEON),
]
flechas = [((px1 + 10, py0 + 52), (bx0 - 12, 262)), ((px1 + 10, 440), (bx0 - 12, 470)),
((px1 + 10, py1 - 52), (bx0 - 12, 675))]
for (a, b) in flechas:
ang = math.atan2(b[1] - a[1], b[0] - a[0])
tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da))
d.line((a, tip(0, 12)), fill=FG, width=4)
d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG)
for box, t, l, r in cajas:
caja(d, box, t, l, r)
out.save(HERE / "esquema-interfaz.png", optimize=True)
def flecha(d, a, b, ancho=4):
ang = math.atan2(b[1] - a[1], b[0] - a[0])
tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da))
d.line((a, tip(0, 12)), fill=FG, width=ancho)
d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG)
def titulo(d, texto, sub):
d.text((46, 26), texto, font=font(ANTON, 46), fill=FG)
bottom = d.textbbox((46, 26), texto, font=font(ANTON, 46))[3]
d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG)
d.text((46, bottom + 30), sub, font=font(SANS, 18), fill=MUTED)
def esquema_rama():
"""Como va la rama encima de Oasis: lo del dev abajo, lo nuestro encima."""
W, H = 1400, 760
out = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(out)
titulo(d, "CÓMO VA LA RAMA ENCIMA DE OASIS",
"Oasis lo cambia el dev, casi una versión al día. La rama solo cambia cómo se ve y se usa en el móvil.")
capa = [
((60, 210, 380, 400), "ASPECTO",
["mobile_base.css: la maqueta", "mobile_ux.css: márgenes, letra,", "botones y piezas nuevas",
"Acampada.css: el skin"], NEON),
((400, 210, 720, 400), "PANTALLAS",
["mobile_ui.js y las vistas", "mobile_*.js. En una vista", "del dev, como mucho una", "línea que las llama"], NEON),
((740, 210, 1060, 400), "RUTAS",
["fork_routes.js, delante", "del router del dev: lo", "que no atiende pasa al", "suyo"], NEON),
((1080, 210, 1340, 400), "ANDROID",
["la app del dev tal cual,", "con nuestro backend", "dentro y paquete", "propio"], NEON),
]
for box, t, l, r in capa:
caja(d, box, t, l, r)
d.text((60, 166), "LO DE LA RAMA", font=font(ANTON, 26), fill=FG)
for x in (220, 560, 900, 1210):
flecha(d, (x, 418), (x, 478))
caja(d, (60, 500, 1340, 690), "OASIS DEL DEV: NO SE TOCA",
["backend.js y su middleware · los modelos (lo que se publica y cómo se cuenta) · SSB y su arranque",
"las traducciones · style.css y mobile.css · los temas, también OasisMobile.css",
"Comparado con su APK oficial, la rama cambia 17 ficheros suyos y 561 líneas. Ver GUIA_DE_DESARROLLO.md"], BG)
out.save(HERE / "esquema-rama.png", optimize=True)
def esquema_llamadas():
"""Las llamadas son las del dev; en Android pasan por un puente nativo de la app."""
W, H = 1400, 700
out = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(out)
titulo(d, "LLAMADAS: LAS DEL DEV",
"Teléfono y Salas son de Oasis. En Android el sonido y la cámara pasan por un puente nativo de la app.")
cajas = [
((50, 200, 360, 430), "TELÉFONO Y SALAS",
["De Oasis, tal cual.", "Llamadas de uno a uno", "con buzón de voz; salas", "de hasta 50, con vídeo,", "grabación y emisión."], BG),
((400, 200, 700, 430), "LOS ENCHUFES",
["phone_*_android.js", "(del dev): se conectan", "a un puerto local que", "deja la app en", ".audio_bridge"], BG),
((740, 200, 1040, 430), "EL PUENTE NATIVO",
["Dentro de la app:", "R graba el micro", "P reproduce", "V saca la cámara", "S emite un fichero"], NEON),
((1080, 200, 1350, 430), "EL MÓVIL",
["Micrófono, cámara", "y altavoz, con los", "permisos que se piden", "la primera vez."], BG),
]
for box, t, l, r in cajas:
caja(d, box, t, l, r)
for a, b in (((362, 315), (398, 315)), ((702, 315), (738, 315)), ((1042, 315), (1078, 315))):
flecha(d, a, b)
caja(d, (50, 480, 1350, 630), "DÓNDE ESTÁ CADA COSA",
["El puente está en la app Android del dev, que no tiene el código publicado. Por eso la APK de la",
"rama se monta sobre la oficial: su app tal cual, nuestro backend dentro y paquete propio."], BG)
out.save(HERE / "esquema-llamadas.png", optimize=True)
if __name__ == "__main__":
portada()
esquema()
esquema_rama()
esquema_llamadas()

7
docs/guia_pdf/README.md Normal file
View file

@ -0,0 +1,7 @@
# PDF de la guia para el dev
Fuente de Oasis_Mobile_Guia_de_desarrollo.pdf (5 paginas A4). Para sacarlo:
chromium --headless=new --no-pdf-header-footer --print-to-pdf=guia.pdf guia.html
Comprobar con `pdfinfo guia.pdf` que siguen siendo 5 paginas.

340
docs/guia_pdf/guia.html Normal file
View file

@ -0,0 +1,340 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Oasis Mobile · Guía de desarrollo</title>
<style>
@font-face { font-family: "Anton"; src: url("file:///home/sito/COFRE/CODERS/OASIS_MOBILE/src/client/assets/fonts/Anton-Regular.ttf"); }
@page { size: A4; margin: 0; }
:root { --fg: #0b0b0b; --muted: #5a5a5a; --line: #0b0b0b; --cyan: #00d7f0; --soft: #f1f4f5; --red: #c4003b; --amber: #c98a00; --green: #0a8f3c; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #fff; color: var(--fg); font-family: "Inter", "DejaVu Sans", sans-serif; font-size: 10.5pt; line-height: 1.45; }
.page { width: 210mm; height: 297mm; padding: 16mm 17mm 14mm; position: relative; page-break-after: always; overflow: hidden; }
.page:last-child { page-break-after: auto; }
h1, h2, h3 { font-family: "Anton", Impact, sans-serif; font-weight: 400; text-transform: uppercase; margin: 0; letter-spacing: .01em; line-height: 1; }
h1 { font-size: 54pt; }
h2 { font-size: 30pt; margin-bottom: 4mm; }
h2 + .rule { width: 34mm; height: 2.2mm; background: var(--fg); margin: 0 0 6mm; }
h3 { font-size: 14pt; margin: 0 0 2.5mm; }
p { margin: 0 0 3mm; }
.lead { font-size: 12pt; color: var(--muted); margin-bottom: 6mm; }
ul { margin: 0 0 3mm; padding-left: 5mm; }
li { margin: 0 0 1.6mm; }
li::marker { color: var(--fg); }
code { font-family: "DejaVu Sans Mono", monospace; font-size: 8.8pt; background: var(--soft); padding: .2mm 1.2mm; border-radius: 1mm; }
.foot { position: absolute; left: 17mm; right: 17mm; bottom: 8mm; display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: .4mm solid #ddd; padding-top: 2mm; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 7mm; }
.cols3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5mm; }
.card { border: .6mm solid var(--fg); border-radius: 3mm; padding: 4.5mm 5mm; }
.card.cyan { background: #e2fbff; }
.tag { display: inline-block; font-family: "Anton"; text-transform: uppercase; font-size: 11pt; padding: .6mm 3mm; border-radius: 1.5mm; color: #fff; margin-bottom: 3mm; }
.tag.red { background: var(--red); } .tag.amber { background: var(--amber); } .tag.green { background: var(--green); }
.num { font-family: "Anton"; font-size: 34pt; line-height: 1; }
.kpi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin: 6mm 0; }
.kpi div { border-top: 1.2mm solid var(--fg); padding-top: 2mm; }
.kpi span { display: block; font-size: 8.5pt; color: var(--muted); }
figure { margin: 0 0 5mm; }
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
svg text { font-family: "Inter", sans-serif; }
.cover { background: #fff; }
.cover .band { position: absolute; left: 0; right: 0; top: 0; height: 9mm; background: var(--cyan); }
.cover h1 { margin-top: 30mm; font-size: 64pt; }
.cover .sub { font-family: "Anton"; text-transform: uppercase; font-size: 24pt; margin-top: 4mm; }
.cover .meta { margin-top: 8mm; font-size: 11pt; color: var(--muted); }
.ask { border-left: 2.2mm solid var(--cyan); background: var(--soft); padding: 4mm 5mm; border-radius: 0 3mm 3mm 0; }
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
th { text-align: left; font-family: "Anton"; font-weight: 400; text-transform: uppercase; font-size: 10pt; border-bottom: .6mm solid var(--fg); padding: 1.5mm 2mm; }
td { border-bottom: .3mm solid #ddd; padding: 1.6mm 2mm; vertical-align: top; }
table.tight td { padding: 1.1mm 2mm; }
</style>
</head>
<body>
<!-- 1. PORTADA -->
<section class="page cover">
<div class="band"></div>
<h1>Oasis Mobile</h1>
<div class="sub">Guía de desarrollo de la rama del hacklab</div>
<div class="meta">Propuesta para epsylon · base Oasis 1.3.3 · rama PRUEBAS · 19 de octubre de 2026</div>
<div class="kpi">
<div><div class="num">39</div><span>versiones de Oasis entre la 1.0.1 y la 1.3.3</span></div>
<div><div class="num">17</div><span>ficheros tuyos que la rama cambia, frente a tu APK oficial</span></div>
<div><div class="num">2</div><span>líneas nuestras en <code>backend.js</code></span></div>
<div><div class="num">0</div><span>cambios en modelos, middleware o traducciones</span></div>
</div>
<h3>Qué es esta rama</h3>
<ul>
<li>Una forma de usar Oasis en el móvil pensada para una acampada: tribus como comisiones, votaciones de asamblea, salas de voz.</li>
<li>No es otro Oasis. Es tu APK oficial con una capa encima: skins, unas pocas vistas, un router propio y textos.</li>
<li>Se instala junto a la oficial con otro paquete (<code>net.laenre.oasis</code>) y habla con la misma red.</li>
</ul>
<h3>Qué te pedimos</h3>
<ul>
<li>Que revises si la frontera que proponemos (página 3) te parece bien: qué es tuyo y no tocamos, y qué es nuestro.</li>
<li>Que nos digas si alguno de los puntos de la página 5 te interesa para Oasis, para dejar de llevarlo nosotros.</li>
</ul>
<figure style="margin-top:6mm">
<svg viewBox="0 0 700 150" width="100%">
<defs><marker id="ar0" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<rect x="2" y="20" width="200" height="110" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="2.5"/>
<text x="102" y="58" text-anchor="middle" font-size="17" font-weight="700">Tu APK oficial</text>
<text x="102" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">app Android, puente de</text>
<text x="102" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">audio, node_modules</text>
<rect x="250" y="20" width="200" height="110" rx="10" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2.5"/>
<text x="350" y="58" text-anchor="middle" font-size="17" font-weight="700">+ capa de la rama</text>
<text x="350" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">skins, vistas mobile_*,</text>
<text x="350" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">router propio, textos</text>
<rect x="498" y="20" width="200" height="110" rx="10" fill="#0b0b0b" stroke="#0b0b0b" stroke-width="2.5"/>
<text x="598" y="58" text-anchor="middle" font-size="17" font-weight="700" fill="#fff">Oasis hacklab</text>
<text x="598" y="82" text-anchor="middle" font-size="12" fill="#ddd">otro paquete, misma red,</text>
<text x="598" y="98" text-anchor="middle" font-size="12" fill="#ddd">convive con la oficial</text>
<line x1="204" y1="75" x2="244" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
<line x1="452" y1="75" x2="492" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
</svg>
</figure>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>1 / 5</span></div>
</section>
<!-- 2. LA REGLA -->
<section class="page">
<h2>La regla</h2><div class="rule"></div>
<p class="lead">Oasis lo cambias tú. Nosotros cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.</p>
<figure>
<svg viewBox="0 0 700 330" width="100%">
<defs><marker id="ar1" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<text x="0" y="16" font-size="13" font-weight="700" letter-spacing="1">LA CAPA DE LA RAMA</text>
<g font-size="12">
<rect x="0" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="83" y="54" text-anchor="middle" font-size="15" font-weight="700">Aspecto</text>
<text x="83" y="76" text-anchor="middle">mobile_base.css</text><text x="83" y="92" text-anchor="middle">mobile_ux.css</text><text x="83" y="108" text-anchor="middle">Acampada.css</text>
<rect x="178" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="261" y="54" text-anchor="middle" font-size="15" font-weight="700">Pantallas</text>
<text x="261" y="76" text-anchor="middle">mobile_ui.js</text><text x="261" y="92" text-anchor="middle">mobile_*_view.js</text><text x="261" y="108" text-anchor="middle">hive_views.js</text>
<rect x="356" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="439" y="54" text-anchor="middle" font-size="15" font-weight="700">Rutas</text>
<text x="439" y="76" text-anchor="middle">fork_routes.js</text><text x="439" y="92" text-anchor="middle">mobile_layer.js</text><text x="439" y="108" text-anchor="middle">fork_workflows.js</text>
<rect x="534" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
<text x="617" y="54" text-anchor="middle" font-size="15" font-weight="700">Textos y APK</text>
<text x="617" y="76" text-anchor="middle">i18n_fork.js</text><text x="617" y="92" text-anchor="middle">(11 idiomas)</text><text x="617" y="108" text-anchor="middle">sobre_oficial.sh</text>
</g>
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar1)">
<line x1="83" y1="130" x2="83" y2="176"/><line x1="261" y1="130" x2="261" y2="176"/><line x1="439" y1="130" x2="439" y2="176"/><line x1="617" y1="130" x2="617" y2="176"/>
</g>
<text x="270" y="158" font-size="11" fill="#5a5a5a">una línea de enganche, solo con OASIS_MOBILE=1</text>
<rect x="0" y="184" width="700" height="140" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="3"/>
<text x="350" y="216" text-anchor="middle" font-size="18" font-weight="700">OASIS: TU CÓDIGO, TAL CUAL</text>
<g font-size="12" text-anchor="middle">
<rect x="18" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="94" y="262" font-weight="700">backend.js</text><text x="94" y="280" fill="#5a5a5a">y su middleware</text>
<rect x="188" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="264" y="262" font-weight="700">modelos</text><text x="264" y="280" fill="#5a5a5a">qué se publica y se cuenta</text>
<rect x="358" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="434" y="262" font-weight="700">SSB y servidor</text><text x="434" y="280" fill="#5a5a5a">phone_module, puente</text>
<rect x="528" y="234" width="154" height="70" rx="7" fill="#f1f4f5"/><text x="605" y="262" font-weight="700">traducciones y temas</text><text x="605" y="280" fill="#5a5a5a">oasis_*.js, style.css…</text>
</g>
</svg>
<figcaption>La rama son cuatro piezas propias encima de tu código. Lo único que entra en tus ficheros son líneas de enganche.</figcaption>
</figure>
<div class="cols">
<div>
<h3>Por qué así</h3>
<ul>
<li>Sacas versión casi cada día y cada una llega en un solo commit: no se pueden coger cambios sueltos.</li>
<li><code>backend.js</code> cambió en 34 de 39 versiones, <code>main_views.js</code> en 31, las traducciones en 31.</li>
<li>Cada línea nuestra en esos ficheros sería un conflicto en la siguiente integración.</li>
<li>Si tocáramos un modelo, nuestro móvil contaría distinto que tu escritorio (votos, tribus, gobierno).</li>
</ul>
</div>
<div>
<h3>Cómo se mide</h3>
<ul>
<li>La referencia para el móvil es el <code>nodejs-project</code> de tu APK oficial, no tu git: ahí van tus cambios de móvil.</li>
<li>Hoy la rama cambia 17 ficheros tuyos y 561 líneas; en <code>backend.js</code>, 2.</li>
<li>En cada versión tuya se repite la comparación y se revisa si ha desaparecido alguna clase CSS de la que tiramos.</li>
</ul>
</div>
</div>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>2 / 5</span></div>
</section>
<!-- 3. LA FRONTERA -->
<section class="page">
<h2>Qué se toca y qué no</h2><div class="rule"></div>
<p class="lead">Tres zonas. La de en medio es la única que entra en tus ficheros, y solo con una línea.</p>
<div class="cols3">
<div class="card">
<span class="tag red">No se toca</span>
<ul>
<li><code>backend.js</code>, rutas y cadena de middleware</li>
<li>Modelos de <code>src/models</code>: lo que se publica en el log y cómo se cuenta</li>
<li><code>src/server</code>: arranque de SSB, dependencias, <code>phone_module</code></li>
<li><code>request_guards.js</code> y la CSP: nada en línea</li>
<li>Traducciones <code>oasis_*.js</code></li>
<li><code>style.css</code>, <code>mobile.css</code> y los temas, también <code>OasisMobile.css</code></li>
</ul>
</div>
<div class="card">
<span class="tag amber">Una línea</span>
<ul>
<li>Tus vistas: <code>main_views.js</code>, <code>tribes_view.js</code>, <code>vote_view.js</code>, <code>settings_view.js</code>, <code>welcome_view.js</code></li>
<li>La lógica va en <code>mobile_ui.js</code>; en tu vista, una llamada</li>
<li>Siempre detrás de <code>OASIS_MOBILE === '1'</code>: en escritorio todo queda como lo dejas</li>
<li>Si reescribes la vista, se vuelve a poner la línea</li>
</ul>
</div>
<div class="card cyan">
<span class="tag green">Nuestro</span>
<ul>
<li>Hojas: <code>mobile_base.css</code>, <code>mobile_ux.css</code>, <code>Acampada.css</code></li>
<li>Vistas: <code>mobile_ui.js</code>, <code>mobile_modules_view.js</code>, <code>hive_views.js</code>, <code>mobile_icons.js</code></li>
<li>Router: <code>fork_routes.js</code> y <code>mobile_layer.js</code></li>
<li>Workflows: <code>fork_workflows.js</code></li>
<li>Textos: <code>i18n_fork.js</code></li>
<li>APK: <code>sobre_oficial.sh</code></li>
</ul>
</div>
</div>
<h3 style="margin-top:4mm">Ejemplos concretos</h3>
<table class="tight">
<tr><th>Lo que hacía falta</th><th>Cómo se ha hecho</th><th>Zona</th></tr>
<tr><td>Votar en semáforo o termómetro, como en asamblea</td><td>Tu servidor ya acepta <code>options</code>; solo se añade el selector en el formulario</td><td>Una línea</td></tr>
<tr><td>Quien crea una votación no puede votar</td><td>Es una regla de tu modelo: no se cambia, se explica en pantalla</td><td>Nuestro</td></tr>
<tr><td>Avisar de que una sección está apagada y encenderla</td><td>Nuestro router mira tu redirección a <code>/modules</code>, sin tocar <code>checkMod</code></td><td>Nuestro</td></tr>
<tr><td>Workflow para acampadas</td><td>Se registra <code>acampada</code> en tus arrays; <code>activists</code> queda como el tuyo</td><td>Nuestro</td></tr>
<tr><td>Llamadas y salas</td><td>Las tuyas, con tu puente de audio: la APK se monta sobre la oficial</td><td>No se toca</td></tr>
<tr><td>Borrar con confirmación</td><td>Función en <code>mobile_ui.js</code>, enganchada en <code>renderContentActions</code></td><td>Una línea</td></tr>
</table>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>3 / 5</span></div>
</section>
<!-- 4. CÓMO FUNCIONA -->
<section class="page">
<h2>Cómo funciona por dentro</h2><div class="rule"></div>
<h3>Una petición en el móvil</h3>
<figure>
<svg viewBox="0 0 700 175" width="100%">
<defs><marker id="ar2" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<g font-size="12" text-anchor="middle">
<rect x="0" y="40" width="110" height="80" rx="9" fill="#fff" stroke="#0b0b0b" stroke-width="2"/><text x="55" y="76" font-weight="700">WebView</text><text x="55" y="94" fill="#5a5a5a">de tu app</text>
<rect x="140" y="40" width="130" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="205" y="70" font-weight="700">Tu middleware</text><text x="205" y="88" fill="#5a5a5a">contexto, idioma,</text><text x="205" y="102" fill="#5a5a5a">CSP</text>
<rect x="300" y="40" width="130" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="365" y="70" font-weight="700">fork_routes</text><text x="365" y="88" fill="#5a5a5a">rutas propias o</text><text x="365" y="102" fill="#5a5a5a">next() al tuyo</text>
<rect x="460" y="40" width="110" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="515" y="76" font-weight="700">Tus rutas</text><text x="515" y="94" fill="#5a5a5a">y vistas</text>
<rect x="600" y="40" width="100" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="650" y="70" font-weight="700">mobile_ui</text><text x="650" y="88" fill="#5a5a5a">si la vista</text><text x="650" y="102" fill="#5a5a5a">lo llama</text>
</g>
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar2)">
<line x1="112" y1="80" x2="134" y2="80"/><line x1="272" y1="80" x2="294" y2="80"/><line x1="432" y1="80" x2="454" y2="80"/><line x1="572" y1="80" x2="594" y2="80"/>
</g>
<path d="M365 124 C365 160, 515 160, 515 124" fill="none" stroke="#0b0b0b" stroke-width="1.6" stroke-dasharray="5 4"/>
<text x="440" y="168" text-anchor="middle" font-size="11" fill="#5a5a5a">wrapUpstream mira tu respuesta (redirecciones) sin cambiarla</text>
<text x="0" y="22" font-size="11" fill="#5a5a5a">Nuestro router va justo delante del tuyo: una sola línea en backend.js lo monta.</text>
</svg>
</figure>
<div class="cols">
<div>
<h3>Hojas de estilo, en este orden</h3>
<figure>
<svg viewBox="0 0 330 200" width="100%">
<g font-size="12">
<rect x="0" y="0" width="330" height="34" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="22">1 · style.css y mobile.css (tuyas)</text>
<rect x="0" y="40" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="62">2 · mobile_base.css: maqueta de la rama</text>
<rect x="0" y="80" width="330" height="34" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="102">3 · skin: Acampada.css o tu OasisMobile.css</text>
<rect x="0" y="120" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="142">4 · hive_acampada.css (solo con Acampada)</text>
<rect x="0" y="160" width="330" height="34" rx="6" fill="#0b0b0b"/><text x="12" y="182" fill="#fff">5 · mobile_ux.css: piezas comunes, la última</text>
</g>
</svg>
</figure>
<ul>
<li>Con tu skin OasisMobile se ve tu tema tal cual, encima de nuestra maqueta.</li>
<li>Colores por variables <code>--ux-*</code>: valen para los dos skins.</li>
</ul>
</div>
<div>
<h3>La APK, montada sobre la tuya</h3>
<figure>
<svg viewBox="0 0 330 200" width="100%">
<defs><marker id="ar3" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
<g font-size="12">
<rect x="0" y="0" width="330" height="30" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="20">tu APK oficial de la misma versión</text>
<rect x="0" y="42" width="330" height="30" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="62">se desmonta (apktool, versión fija)</text>
<rect x="0" y="84" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="104">backend de la rama + tu node_modules</text>
<rect x="0" y="126" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="146">paquete net.laenre.oasis</text>
<rect x="0" y="168" width="330" height="30" rx="6" fill="#0b0b0b"/><text x="12" y="188" fill="#fff">se monta, se alinea y se firma</text>
</g>
<g stroke="#0b0b0b" stroke-width="1.6" marker-end="url(#ar3)"><line x1="300" y1="31" x2="300" y2="40"/><line x1="300" y1="73" x2="300" y2="82"/><line x1="300" y1="115" x2="300" y2="124"/><line x1="300" y1="157" x2="300" y2="166"/></g>
</svg>
</figure>
<ul>
<li>Tu app tal cual: puente de audio, notificaciones, permisos.</li>
<li>Se cambian también los authorities, el permiso propio y las acciones de llamada, para no cruzarse con la tuya.</li>
</ul>
</div>
</div>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>4 / 5</span></div>
</section>
<!-- 5. TRABAJAR JUNTOS -->
<section class="page">
<h2>Cómo seguimos</h2><div class="rule"></div>
<div class="cols">
<div>
<h3>Antes de cada commit</h3>
<ul>
<li>Nada de la zona roja en el diff; en tus vistas, solo líneas de enganche.</li>
<li><code>node --check</code> y el servidor local responde en las pantallas principales.</li>
<li>Visto a 360 y 412 px, en tableta, con la letra al 130 % y en un WebView viejo.</li>
<li>Textos nuevos en los 11 idiomas, en <code>i18n_fork.js</code>.</li>
<li>Nada en línea: la CSP lo bloquea.</li>
</ul>
<h3>Cuando sacas versión</h3>
<ul>
<li>Se coge la tuya tal cual; para el móvil, la de tu APK.</li>
<li>Se vuelven a poner los enganches en tus vistas.</li>
<li>Se repite la medición y se revisan las clases CSS.</li>
<li>Se monta la APK sobre tu oficial de esa versión.</li>
</ul>
</div>
<div>
<h3>Navegación en el móvil</h3>
<ul>
<li>Al abrir se ve la actividad, no un menú.</li>
<li>Abajo, cuatro atajos que elige cada cual y una pestaña Menú que abre todos los módulos desde abajo.</li>
<li>Probado en el emulador: dos toques a cualquier módulo desde cualquier pantalla.</li>
</ul>
<h3>Probado</h3>
<ul>
<li>Android 16 (APK real): 412 y 360 dp, tableta de 800 dp, letra al 130 %, gestos y tres botones.</li>
<li>Motores viejos: WebView 69 (Android 9) y 83 (Android 11), con alternativas donde falta <code>:has</code>, <code>gap</code> o <code>inset</code>.</li>
<li>Llamadas: en una sala el micrófono graba y se corta al salir.</li>
</ul>
</div>
</div>
<h3 style="margin-top:2mm">Lo que podría pasar a Oasis</h3>
<p>Cosas que hoy llevamos en tus ficheros y que nos gustaría que fueran tuyas, para dejar de mantenerlas aparte:</p>
<table>
<tr><th>Qué</th><th>Dónde está ahora</th><th>Por qué te puede interesar</th></tr>
<tr><td>Limpiar metadatos de las fotos también en Android</td><td><code>blobHandler.js</code> (190 líneas)</td><td>Sin <code>sharp</code> en el móvil, hoy las fotos suben con el GPS dentro</td></tr>
<tr><td>Varias identidades en el mismo móvil</td><td><code>ssb_config.js</code> (30 líneas)</td><td>Una persona, varias cuentas, con aviso si dos comparten clave</td></tr>
<tr><td>DNS del sistema primero</td><td><code>main.js</code> (45 líneas)</td><td>Respeta la VPN y el DNS privado de Android</td></tr>
<tr><td>Selector de idioma con el idioma actual</td><td>nuestro, en <code>mobile_ui.js</code></td><td><code>settings_view</code> lee <code>selectedLanguage</code> al cargar y siempre marca English</td></tr>
</table>
<div class="ask" style="margin-top:3mm">
<h3>Para aprobar</h3>
<ul style="margin:0">
<li>¿Te parece bien la frontera de la página 3?</li>
<li>¿Te parece bien que la APK de la rama se monte sobre tu oficial, con otro paquete?</li>
<li>¿Quieres alguno de los cuatro puntos de arriba en Oasis?</li>
</ul>
</div>
<div class="foot"><span>Oasis Mobile · Guía de desarrollo · hacklab · gitea.laenre.net/hacklab/OASIS_MOBILE</span><span>5 / 5</span></div>
</section>
</body>
</html>

199
main.js
View file

@ -8,6 +8,13 @@ process.env.CHLORIDE_JS = '1';
const path = require('path');
const fs = require('fs');
try {
const androidInfo = JSON.parse(fs.readFileSync(path.join(__dirname, '.android.json'), 'utf8'));
if (androidInfo.versionName) process.env.OASIS_ANDROID_VERSION = String(androidInfo.versionName);
if (androidInfo.versionCode) process.env.OASIS_ANDROID_VERSION_CODE = String(androidInfo.versionCode);
process.env.OASIS_ANDROID_INSTALLER = String(androidInfo.installer || '');
} catch (e) {}
const tmpDir = path.join(process.env.HOME, 'tmp');
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
process.env.TMPDIR = tmpDir;
@ -19,6 +26,8 @@ try {
const dns = require('dns');
const net = require('net');
const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4'];
const systemLookup = dns.lookup;
const SYSTEM_LOOKUP_MS = 5000;
try { dns.setServers(servers); } catch (e) {}
try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {}
@ -41,11 +50,43 @@ try {
? callback(null, [direct])
: callback(null, direct.address, direct.family));
}
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
resolver.call(dns, hostname, (err, addrs) => {
if (err || !addrs || !addrs.length) return callback(err || new Error('ENOTFOUND ' + hostname));
if (options.all) return callback(null, addrs.map((a) => ({ address: a, family })));
return callback(null, addrs[0], family);
// rama movil: primero el resolutor del sistema (getaddrinfo), que es el que
// respeta la VPN y el DNS privado de Android. Upstream iba siempre a
// 8.8.8.8/1.1.1.1, y las VPN que evitan fugas de DNS (Mullvad, por ejemplo)
// o las redes que bloquean DNS externos los rechazan: no se encontraba
// ningun pub por nombre y unirse a uno "no hacia nada". Los publicos quedan
// de respaldo, para los moviles donde getaddrinfo no contesta.
// Los dos caminos compiten: el del sistema sale ya y, si en SYSTEM_LOOKUP_MS
// no ha contestado o falla, se pregunta tambien a los publicos. Gana la primera
// respuesta buena; si fallan los dos, se devuelve el error del sistema, que
// dice mas ("no existe") que el de unos servidores quiza bloqueados.
let done = false, sysErr = null, pubErr = null, sysDone = false, pubDone = false, pubStarted = false;
const finish = (err, address, fam) => {
if (done) return;
done = true;
clearTimeout(timer);
if (err) return callback(err);
if (options.all) return callback(null, Array.isArray(address) ? address : [{ address, family: fam }]);
if (Array.isArray(address)) return callback(null, address[0].address, address[0].family);
return callback(null, address, fam);
};
const bothFailed = () => { if (sysDone && pubDone) finish(sysErr || pubErr || new Error('ENOTFOUND ' + hostname)); };
const viaPublic = () => {
if (pubStarted) return;
pubStarted = true;
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
resolver.call(dns, hostname, (err, addrs) => {
pubDone = true;
if (err || !addrs || !addrs.length) { pubErr = err || new Error('ENOTFOUND ' + hostname); return bothFailed(); }
finish(null, options.all ? addrs.map((x) => ({ address: x, family })) : addrs[0], family);
});
};
const timer = setTimeout(viaPublic, SYSTEM_LOOKUP_MS);
systemLookup.call(dns, hostname, { family, all: !!options.all }, (err, address, fam) => {
sysDone = true;
if (!err) return finish(null, address, fam);
sysErr = err;
if (pubStarted) bothFailed(); else viaPublic();
});
};
@ -67,153 +108,9 @@ console.log('[Oasis] Launching Oasis Social Network Utopia...');
console.log('[Oasis] Backend path:', backendPath);
console.log('[Oasis] HOME:', process.env.HOME);
const http = require('http');
const keylock = require('./src/models/keylock');
const configPath = path.join(process.env.HOME, '.ssb');
let backendStarted = false;
function startBackend() {
if (backendStarted) return;
backendStarted = true;
try {
try {
require(backendPath);
console.log('[Oasis] Backend started on port 3000');
} catch (error) {
} catch (error) {
console.error('[Oasis] Error starting backend:', error);
}
}
function unlockAndStart(keys) {
global.__OASIS_KEYS__ = keys;
for (const s of sockets) { try { s.destroy(); } catch (_) {} }
try { lockServer.close(() => startBackend()); } catch (_) { startBackend(); }
}
const MODE = keylock.isConfigured(configPath) ? 'unlock' : 'setup';
const LOCK_LANGS = ['en','es','fr','eu','de','it','pt','zh','ar','hi','ru'];
const LOCK_I18N = {
en: { name:"English", t_setup:"Create your password", t_unlock:"Unlock Oasis", s_setup:"It encrypts your identity and keys on this device. <b>If you forget it, you lose the account</b> (no recovery).", s_unlock:"Enter your password to decrypt your keys and start Oasis.", ph:"Password", ph2:"Repeat password", b_setup:"Create and enter", b_unlock:"Enter", e_min:"Minimum 6 characters", e_mism:"Passwords do not match", e_wrong:"Incorrect password", e_pre:"Error: ", m_unlock:"Unlocking…" },
es: { name:"Español", t_setup:"Crea tu contraseña", t_unlock:"Desbloquea Oasis", s_setup:"Cifrará tu identidad y tus claves en este dispositivo. <b>Si la olvidas, pierdes la cuenta</b> (no hay recuperación).", s_unlock:"Introduce tu contraseña para descifrar tus claves y arrancar Oasis.", ph:"Contraseña", ph2:"Repite la contraseña", b_setup:"Crear y entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"Las contraseñas no coinciden", e_wrong:"Contraseña incorrecta", e_pre:"Error: ", m_unlock:"Desbloqueando…" },
fr: { name:"Français", t_setup:"Créez votre mot de passe", t_unlock:"Déverrouiller Oasis", s_setup:"Il chiffre votre identité et vos clés sur cet appareil. <b>Si vous l'oubliez, vous perdez le compte</b> (aucune récupération).", s_unlock:"Saisissez votre mot de passe pour déchiffrer vos clés et démarrer Oasis.", ph:"Mot de passe", ph2:"Répétez le mot de passe", b_setup:"Créer et entrer", b_unlock:"Entrer", e_min:"6 caractères minimum", e_mism:"Les mots de passe ne correspondent pas", e_wrong:"Mot de passe incorrect", e_pre:"Erreur : ", m_unlock:"Déverrouillage…" },
eu: { name:"Euskara", t_setup:"Sortu zure pasahitza", t_unlock:"Desblokeatu Oasis", s_setup:"Zure identitatea eta gakoak gailu honetan zifratzen ditu. <b>Ahazten baduzu, kontua galtzen duzu</b> (ez dago berreskuratzerik).", s_unlock:"Sartu zure pasahitza gakoak deszifratu eta Oasis abiarazteko.", ph:"Pasahitza", ph2:"Errepikatu pasahitza", b_setup:"Sortu eta sartu", b_unlock:"Sartu", e_min:"Gutxienez 6 karaktere", e_mism:"Pasahitzak ez datoz bat", e_wrong:"Pasahitz okerra", e_pre:"Errorea: ", m_unlock:"Desblokeatzen…" },
de: { name:"Deutsch", t_setup:"Erstelle dein Passwort", t_unlock:"Oasis entsperren", s_setup:"Es verschlüsselt deine Identität und Schlüssel auf diesem Gerät. <b>Wenn du es vergisst, verlierst du das Konto</b> (keine Wiederherstellung).", s_unlock:"Gib dein Passwort ein, um deine Schlüssel zu entschlüsseln und Oasis zu starten.", ph:"Passwort", ph2:"Passwort wiederholen", b_setup:"Erstellen und eintreten", b_unlock:"Eintreten", e_min:"Mindestens 6 Zeichen", e_mism:"Passwörter stimmen nicht überein", e_wrong:"Falsches Passwort", e_pre:"Fehler: ", m_unlock:"Entsperren…" },
it: { name:"Italiano", t_setup:"Crea la tua password", t_unlock:"Sblocca Oasis", s_setup:"Cifra la tua identità e le tue chiavi su questo dispositivo. <b>Se la dimentichi, perdi l'account</b> (nessun recupero).", s_unlock:"Inserisci la password per decifrare le chiavi e avviare Oasis.", ph:"Password", ph2:"Ripeti la password", b_setup:"Crea ed entra", b_unlock:"Entra", e_min:"Minimo 6 caratteri", e_mism:"Le password non coincidono", e_wrong:"Password errata", e_pre:"Errore: ", m_unlock:"Sblocco…" },
pt: { name:"Português", t_setup:"Crie a sua palavra-passe", t_unlock:"Desbloquear o Oasis", s_setup:"Cifra a sua identidade e chaves neste dispositivo. <b>Se a esquecer, perde a conta</b> (sem recuperação).", s_unlock:"Introduza a sua palavra-passe para decifrar as chaves e iniciar o Oasis.", ph:"Palavra-passe", ph2:"Repita a palavra-passe", b_setup:"Criar e entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"As palavras-passe não coincidem", e_wrong:"Palavra-passe incorreta", e_pre:"Erro: ", m_unlock:"A desbloquear…" },
zh: { name:"中文", t_setup:"创建你的密码", t_unlock:"解锁 Oasis", s_setup:"它会在此设备上加密你的身份和密钥。<b>如果忘记,你将失去账户</b>(无法恢复)。", s_unlock:"输入密码以解密你的密钥并启动 Oasis。", ph:"密码", ph2:"重复密码", b_setup:"创建并进入", b_unlock:"进入", e_min:"至少 6 个字符", e_mism:"密码不一致", e_wrong:"密码错误", e_pre:"错误:", m_unlock:"正在解锁…" },
ar: { name:"العربية", t_setup:"أنشئ كلمة المرور", t_unlock:"فتح Oasis", s_setup:"يشفّر هويتك ومفاتيحك على هذا الجهاز. <b>إذا نسيتها، تفقد الحساب</b> (لا استعادة).", s_unlock:"أدخل كلمة المرور لفك تشفير مفاتيحك وتشغيل Oasis.", ph:"كلمة المرور", ph2:"أعد كلمة المرور", b_setup:"إنشاء ودخول", b_unlock:"دخول", e_min:"6 أحرف على الأقل", e_mism:"كلمتا المرور غير متطابقتين", e_wrong:"كلمة مرور غير صحيحة", e_pre:"خطأ: ", m_unlock:"جارٍ الفتح…" },
hi: { name:"हिन्दी", t_setup:"अपना पासवर्ड बनाएं", t_unlock:"Oasis अनलॉक करें", s_setup:"यह इस डिवाइस पर आपकी पहचान और कुंजियाँ एन्क्रिप्ट करता है। <b>भूल गए तो खाता खो देंगे</b> (कोई रिकवरी नहीं)।", s_unlock:"अपनी कुंजियाँ डिक्रिप्ट करने और Oasis शुरू करने के लिए पासवर्ड दर्ज करें।", ph:"पासवर्ड", ph2:"पासवर्ड दोहराएँ", b_setup:"बनाएँ और प्रवेश करें", b_unlock:"प्रवेश करें", e_min:"कम से कम 6 अक्षर", e_mism:"पासवर्ड मेल नहीं खाते", e_wrong:"गलत पासवर्ड", e_pre:"त्रुटि: ", m_unlock:"अनलॉक हो रहा है…" },
ru: { name:"Русский", t_setup:"Создайте пароль", t_unlock:"Разблокировать Oasis", s_setup:"Он шифрует вашу личность и ключи на этом устройстве. <b>Если забудете, потеряете аккаунт</b> (без восстановления).", s_unlock:"Введите пароль, чтобы расшифровать ключи и запустить Oasis.", ph:"Пароль", ph2:"Повторите пароль", b_setup:"Создать и войти", b_unlock:"Войти", e_min:"Минимум 6 символов", e_mism:"Пароли не совпадают", e_wrong:"Неверный пароль", e_pre:"Ошибка: ", m_unlock:"Разблокировка…" },
};
function lockPage(mode) {
const confirmField = mode === 'setup'
? '<input id="c" type="password" inputmode="text" autocomplete="new-password" />'
: '';
return '<!doctype html><!-- OASIS_LOCK_MARKER --><html lang="en"><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
+ '<title>Oasis</title><style>'
+ '*{box-sizing:border-box}body{margin:0;background:#121212;color:#eee;font-family:system-ui,sans-serif;'
+ 'min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}'
+ '.card{width:100%;max-width:360px;text-align:center}h1{color:#FFD700;font-size:22px;margin:0 0 8px}'
+ 'p{color:#aaa;font-size:14px;line-height:1.4;margin:0 0 20px}'
+ 'input{width:100%;padding:14px;margin:8px 0;border-radius:10px;border:1px solid #333;'
+ 'background:#1c1c1c;color:#eee;font-size:16px}'
+ 'button[type=submit]{width:100%;padding:14px;margin-top:12px;border:0;border-radius:10px;background:#FFD700;'
+ 'color:#111;font-size:16px;font-weight:600}button[type=submit]:disabled{opacity:.5}'
+ '#err{color:#ff6b6b;font-size:13px;min-height:18px;margin-top:8px}#msg{color:#FFD700;font-size:14px}'
+ '#langbtn{position:fixed;top:calc(env(safe-area-inset-top,0px) + 12px);right:12px;width:42px;height:42px;'
+ 'border-radius:50%;border:1px solid #333;background:#1c1c1c;color:#FFD700;font-size:13px;font-weight:700;'
+ 'padding:0;z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center}'
+ '.langmenu{position:fixed;top:calc(env(safe-area-inset-top,0px) + 60px);right:12px;background:#1c1c1c;'
+ 'border:1px solid #333;border-radius:12px;padding:6px;z-index:11;max-height:70vh;overflow:auto;min-width:150px;'
+ 'box-shadow:0 6px 20px rgba(0,0,0,.5)}'
+ '.langmenu button{display:block;width:100%;text-align:left;background:transparent;color:#eee;border:0;'
+ 'border-radius:8px;padding:11px 12px;font-size:15px;margin:0;font-weight:400;cursor:pointer}'
+ '.langmenu button.cur{color:#FFD700;font-weight:700;background:#262626}'
+ '[dir=rtl] #langbtn,[dir=rtl] .langmenu{right:auto;left:12px}[dir=rtl] .langmenu button{text-align:right}'
+ '</style></head><body>'
+ '<button id="langbtn" type="button" aria-label="Language">EN</button>'
+ '<div id="langmenu" class="langmenu" hidden></div>'
+ '<div class="card"><h1 id="title"></h1><p id="sub"></p>'
+ '<form id="f"><input id="p" type="password" inputmode="text" autocomplete="' + (mode === 'setup' ? 'new-password' : 'current-password') + '" autofocus />'
+ confirmField
+ '<button type="submit" id="btn"></button>'
+ '<div id="err"></div><div id="msg"></div></form></div>'
+ '<script>'
+ 'var MODE="' + mode + '";var I18N=' + JSON.stringify(LOCK_I18N) + ';var LANGS=' + JSON.stringify(LOCK_LANGS) + ';'
+ 'var $=function(id){return document.getElementById(id);};'
+ 'function getLang(){try{var s=localStorage.getItem("oasisLockLang");if(s&&I18N[s])return s;}catch(e){}return "en";}'
+ 'var LANG=getLang();'
+ 'function setCookie(l){try{document.cookie="language="+l+";path=/;max-age=31536000";}catch(e){}}'
+ 'try{if(localStorage.getItem("oasisLockLang"))setCookie(LANG);}catch(e){}'
+ 'function apply(){var t=I18N[LANG]||I18N.en;'
+ 'document.documentElement.lang=LANG;document.documentElement.dir=(LANG==="ar")?"rtl":"ltr";'
+ '$("title").innerHTML=(MODE==="setup")?t.t_setup:t.t_unlock;'
+ '$("sub").innerHTML=(MODE==="setup")?t.s_setup:t.s_unlock;'
+ '$("p").placeholder=t.ph;var c=$("c");if(c)c.placeholder=t.ph2;'
+ '$("btn").textContent=(MODE==="setup")?t.b_setup:t.b_unlock;'
+ '$("langbtn").textContent=LANG.toUpperCase();}'
+ 'function buildMenu(){var m=$("langmenu");m.innerHTML="";LANGS.forEach(function(l){'
+ 'var b=document.createElement("button");b.type="button";b.textContent=(I18N[l]&&I18N[l].name)||l;'
+ 'if(l===LANG)b.className="cur";b.onclick=function(){LANG=l;try{localStorage.setItem("oasisLockLang",l);}catch(e){}'
+ 'setCookie(l);apply();buildMenu();m.hidden=true;};m.appendChild(b);});}'
+ '$("langbtn").onclick=function(){var m=$("langmenu");m.hidden=!m.hidden;};'
+ 'document.addEventListener("click",function(e){var m=$("langmenu");if(!m.hidden&&e.target!==$("langbtn")&&!m.contains(e.target))m.hidden=true;});'
+ 'buildMenu();apply();'
+ 'var f=$("f"),err=$("err"),msg=$("msg");'
+ 'f.addEventListener("submit",async function(e){e.preventDefault();var t=I18N[LANG]||I18N.en;err.textContent="";'
+ 'var p=$("p").value,c=$("c");'
+ 'if(MODE==="setup"){if(p.length<6){err.textContent=t.e_min;return;}if(c&&c.value!==p){err.textContent=t.e_mism;return;}}'
+ 'var btn=$("btn");btn.disabled=true;'
+ 'try{var r=await fetch(MODE==="setup"?"/__keylock/setup":"/__keylock/unlock",{method:"POST",'
+ 'headers:{"Content-Type":"application/json"},body:JSON.stringify({password:p})});'
+ 'if(r.ok){msg.textContent=t.m_unlock;waitReload();}else{err.textContent=t.e_wrong;btn.disabled=false;}}'
+ 'catch(e2){err.textContent=t.e_pre+e2.message;btn.disabled=false;}});'
+ 'async function waitReload(){for(var i=0;i<120;i++){try{var r=await fetch("/?_k="+Date.now(),{cache:"no-store"});'
+ 'var t=await r.text();if(t.indexOf("OASIS_LOCK_MARKER")===-1){location.reload();return;}}catch(e){}'
+ 'await new Promise(function(s){setTimeout(s,500);});}location.reload();}'
+ '</script></body></html>';
}
function readBody(req) {
return new Promise((resolve) => {
let b = '';
req.on('data', (d) => { b += d; if (b.length > 1e5) req.destroy(); });
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (_) { resolve({}); } });
req.on('error', () => resolve({}));
});
}
const sockets = new Set();
const lockServer = http.createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/__keylock/setup') {
const { password } = await readBody(req);
if (!password || String(password).length < 6) { res.writeHead(400, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
keylock.setPassword(configPath, password);
const keys = keylock.loadOrCreateSecret(configPath);
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
res.end('{"ok":true}');
return unlockAndStart(keys);
}
if (req.method === 'POST' && req.url === '/__keylock/unlock') {
const { password } = await readBody(req);
const kek = password ? keylock.verifyPassword(configPath, password) : null;
if (!kek) { res.writeHead(401, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
const keys = keylock.loadOrCreateSecret(configPath);
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
res.end('{"ok":true}');
return unlockAndStart(keys);
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
res.end(lockPage(MODE));
} catch (e) {
res.writeHead(500, { 'Connection': 'close' });
res.end('{"ok":false,"error":"' + String(e && e.message).replace(/"/g, '') + '"}');
}
});
lockServer.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
lockServer.listen(3000, '127.0.0.1', () => {
console.log('[Oasis] keylock gate escuchando en 127.0.0.1:3000 (modo: ' + MODE + ')');
});

View file

@ -1,6 +1,6 @@
{
"name": "@krakenslab/oasis",
"version": "0.8.7",
"version": "1.3.3",
"description": "Oasis - Social Networking Utopia",
"repository": {
"type": "git",
@ -156,4 +156,4 @@
"node": "^10.0.0 || >=12.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}

105
src/AI/ai_client.js Normal file
View file

@ -0,0 +1,105 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { spawn } = require('child_process');
const axiosMod = require('../server/node_modules/axios');
const axios = axiosMod.default || axiosMod;
const net = require('net');
const BASE_PORT = Number(process.env.OASIS_AI_PORT) || 4001;
let PORT = BASE_PORT;
const portIsFree = (port) => new Promise((resolve) => {
const probe = net.createServer();
probe.once('error', () => resolve(false));
probe.once('listening', () => probe.close(() => resolve(true)));
probe.listen(port, '127.0.0.1');
});
const pickPort = async () => {
for (let p = BASE_PORT; p < BASE_PORT + 20; p++) {
if (await portIsFree(p)) return p;
}
return BASE_PORT;
};
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
const ASK_TIMEOUT_MS = 300000;
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
const STATUS_TIMEOUT_MS = 3000;
const token = crypto.randomBytes(24).toString('hex');
let child = null;
let started = false;
const isModelInstalled = () => { try { return fs.existsSync(MODEL_PATH); } catch (_) { return false; } };
let starting = null;
const start = () => {
if (started) return starting || Promise.resolve();
started = true;
if (process.env.OASIS_TEST) return Promise.resolve();
starting = pickPort().then((port) => { PORT = port; spawnService(); }).catch(() => { spawnService(); }).finally(() => { starting = null; });
return starting;
};
const spawnService = () => {
try {
child = spawn(process.execPath, [path.join(__dirname, 'ai_service.mjs')], {
stdio: ['ignore', 'ignore', DEBUG ? 'inherit' : 'ignore'],
env: { ...process.env, OASIS_AI_TOKEN: token, OASIS_AI_PORT: String(PORT), OASIS_AI_PARENT_PID: String(process.pid), OASIS_AI_MODEL: MODEL_PATH }
});
child.on('exit', () => { child = null; started = false; });
} catch (_) { child = null; started = false; }
};
const stop = () => {
if (!child) return;
try { child.kill('SIGTERM'); } catch (_) {}
child = null;
started = false;
};
const headers = () => ({ 'x-oasis-ai-token': token });
const status = async () => {
if (!isModelInstalled()) return { installed: false, ready: false, loading: false, error: 'model_missing' };
await start();
try {
const r = await axios.get(`http://127.0.0.1:${PORT}/status`, { headers: headers(), timeout: STATUS_TIMEOUT_MS, proxy: false });
return { installed: true, ...(r.data || {}) };
} catch (_) {
return { installed: true, ready: false, loading: true, error: null };
}
};
const CONNECT_RETRIES = 300;
const CONNECT_RETRY_MS = 1000;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const ask = async ({ system = '', history = [], context = [], input = '', lang = 'en', maxTokens } = {}) => {
if (!isModelInstalled()) throw new Error('model_missing');
await start();
let lastErr = null;
for (let attempt = 0; attempt < CONNECT_RETRIES; attempt++) {
try {
const r = await axios.post(`http://127.0.0.1:${PORT}/ai`, { system, history, context, input, lang, maxTokens }, { headers: headers(), timeout: ASK_TIMEOUT_MS, proxy: false });
if (!r.data || typeof r.data.answer !== 'string') throw new Error('bad_answer');
return r.data.answer;
} catch (e) {
lastErr = e;
const httpStatus = e && e.response && e.response.status;
const retryable = (e && e.code === 'ECONNREFUSED') || httpStatus === 503 || httpStatus === 403;
if (!retryable) break;
await sleep(CONNECT_RETRY_MS);
}
}
throw lastErr || new Error('not_ready');
};
process.on('exit', stop);
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig, () => { stop(); process.exit(0); });
}
module.exports = { start, stop, status, ask, isModelInstalled, MODEL_PATH, MODEL_FILE, port: () => PORT };

168
src/AI/ai_service.mjs Normal file
View file

@ -0,0 +1,168 @@
import path from 'path';
import fs from 'fs';
import os from 'os';
import http from 'http';
import { fileURLToPath } from 'url';
import { getLlama, LlamaChatSession } from './node_modules/node-llama-cpp/dist/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number(process.env.OASIS_AI_PORT) || 4001;
const TOKEN = String(process.env.OASIS_AI_TOKEN || '');
const PARENT_PID = Number(process.env.OASIS_AI_PARENT_PID) || 0;
const MODEL_FILE = 'oasis-42-1-chat.Q4_K_M.gguf';
const MODEL_PATH = process.env.OASIS_AI_MODEL || path.join(__dirname, MODEL_FILE);
const GENERATION_TIMEOUT_MS = 120000;
const MAX_TOKENS_DEFAULT = 400;
const MAX_INPUT_CHARS = 5000;
const MAX_CONTEXT_CHARS = 6000;
const state = { loading: false, ready: false, error: null, model: null, contextSize: 0, threads: 0 };
let llama, model, context;
let queue = Promise.resolve();
let loadingPromise = null;
const pickThreads = () => Math.max(1, Math.min(os.cpus().length - 1, 6));
const pickContextSize = () => {
const gb = os.totalmem() / (1024 ** 3);
if (gb < 6) return 2048;
if (gb < 12) return 4096;
return 8192;
};
async function initModel() {
if (state.ready) return;
if (loadingPromise) return loadingPromise;
state.loading = true;
loadingPromise = loadModel().finally(() => { loadingPromise = null; });
return loadingPromise;
}
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
const log = (msg) => { if (!DEBUG) return; try { process.stderr.write(`[ai-service] ${msg}\n`); } catch (_) {} };
async function loadModel() {
const t0 = Date.now();
try {
if (!fs.existsSync(MODEL_PATH)) throw new Error(`model_missing:${MODEL_PATH}`);
log(`loading ${path.basename(MODEL_PATH)}`);
llama = await getLlama({ gpu: false });
log(`llama ready (${Date.now() - t0} ms)`);
model = await llama.loadModel({ modelPath: MODEL_PATH });
log(`model loaded (${Date.now() - t0} ms)`);
state.threads = pickThreads();
state.contextSize = pickContextSize();
context = await model.createContext({ contextSize: state.contextSize, threads: state.threads });
log(`context ready: ${state.contextSize} tokens, ${state.threads} threads (${Date.now() - t0} ms)`);
state.model = path.basename(MODEL_PATH);
state.ready = true;
state.error = null;
} catch (e) {
state.error = String((e && e.message) || e);
log(`load failed: ${state.error}`);
} finally {
state.loading = false;
}
}
const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
const buildHistory = ({ system, history, context: ctxLines }) => {
const items = [];
const sys = [clip(system, 2000)];
const lines = Array.isArray(ctxLines) ? ctxLines.map(x => clip(x, 800)).filter(Boolean) : [];
if (lines.length) {
let block = '';
for (const l of lines) { if ((block + l).length > MAX_CONTEXT_CHARS) break; block += `- ${l}\n`; }
sys.push(`Knowledge from the Oasis network (use it when relevant, never invent beyond it):\n${block}`);
}
items.push({ type: 'system', text: sys.join('\n\n') });
for (const turn of (Array.isArray(history) ? history : []).slice(-8)) {
if (!turn || typeof turn.text !== 'string' || !turn.text.trim()) continue;
if (turn.type === 'user') items.push({ type: 'user', text: clip(turn.text, MAX_INPUT_CHARS) });
else if (turn.type === 'model') items.push({ type: 'model', response: [clip(turn.text, MAX_INPUT_CHARS)] });
}
return items;
};
async function answer(body) {
await initModel();
if (!state.ready) throw new Error(state.error || 'not_ready');
const input = clip(body.input, MAX_INPUT_CHARS).trim();
if (!input) throw new Error('empty_input');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GENERATION_TIMEOUT_MS);
const sequence = context.getSequence();
const session = new LlamaChatSession({ contextSequence: sequence });
try {
session.setChatHistory(buildHistory(body));
const text = await session.prompt(input, {
maxTokens: Math.max(32, Math.min(1024, Number(body.maxTokens) || MAX_TOKENS_DEFAULT)),
temperature: 0.6,
signal: controller.signal,
stopOnAbortSignal: true
});
return String(text || '').trim();
} finally {
clearTimeout(timer);
try { session.dispose(); } catch (_) {}
try { sequence.dispose(); } catch (_) {}
}
}
const readJson = (req) => new Promise((resolve, reject) => {
let raw = '';
req.on('data', (c) => { raw += c; if (raw.length > 200000) { reject(new Error('body_too_large')); req.destroy(); } });
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(e); } });
req.on('error', reject);
});
const send = (res, status, obj) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
};
const server = http.createServer(async (req, res) => {
if (TOKEN && req.headers['x-oasis-ai-token'] !== TOKEN) return send(res, 403, { error: 'forbidden' });
if (req.method === 'GET' && req.url === '/status') {
if (!state.ready && !state.loading && !state.error) initModel();
return send(res, 200, { ...state });
}
if (req.method === 'POST' && req.url === '/ai') {
let body;
try { body = await readJson(req); } catch (e) { return send(res, 400, { error: 'bad_request' }); }
const job = queue.then(() => answer(body));
queue = job.catch(() => {});
try {
const text = await job;
return send(res, 200, { answer: text });
} catch (e) {
const msg = String((e && e.message) || e);
const status = msg.startsWith('model_missing') ? 503 : (msg === 'not_ready' ? 503 : 500);
log(`answer failed: ${msg}`);
return send(res, status, { error: msg, ...state });
}
}
send(res, 404, { error: 'not_found' });
});
let listenAttempts = 0;
server.on('error', (e) => {
if (e && e.code === 'EADDRINUSE' && listenAttempts < 20) {
listenAttempts += 1;
setTimeout(() => server.listen(PORT, '127.0.0.1'), 1000);
return;
}
process.exit(1);
});
server.listen(PORT, '127.0.0.1', () => { initModel(); });
const shutdown = () => { try { server.close(); } catch (_) {} process.exit(0); };
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
if (PARENT_PID > 0) {
setInterval(() => {
try { process.kill(PARENT_PID, 0); } catch (_) { shutdown(); }
}, 5000).unref();
}

238
src/AI/buildAIContext.js Normal file
View file

@ -0,0 +1,238 @@
const { readTyped } = require('../models/typed_log')
const { getConfig } = require('../configs/config-manager.js')
const logLimit = getConfig().ssbLogStream?.limit || 1000
let cooler = null
let ssb = null
let opening = null
function getCooler() {
let ssbPath = null
try { ssbPath = require.resolve('../server/SSB_server.js') } catch {}
if (ssbPath && require.cache[ssbPath]) {
if (!cooler) {
const gui = require('../client/gui.js')
cooler = gui({ offline: false })
}
return cooler
}
return null
}
function useCooler(c) {
cooler = c || null
ssb = null
opening = null
}
const DEBUG = process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true'
const debug = (msg) => { if (DEBUG) console.error(`[ai] ${msg}`) }
async function openSsb() {
const c = cooler || getCooler()
if (!c) return null
if (ssb && ssb.closed === false) return ssb
if (!opening) {
debug('opening the SSB connection for the network knowledge')
opening = c.open().then(x => { debug('SSB connection open'); return (ssb = x) }).finally(() => { opening = null })
}
await opening
return ssb
}
const clip = (s, n) => String(s || '').slice(0, n)
const squash = s => String(s || '').replace(/\s+/g, ' ').trim()
const compact = s => squash(clip(s, 160))
const normalize = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w\u0080-\uFFFF\s]+/g, '').trim()
function fieldsForSnippet(type, c) {
if (type === 'aiExchange') return [c?.question, clip(squash(c?.answer || ''), 120)]
return []
}
async function publishExchange({ q, a, ctx = [], tokens = {}, lang = '', tags = [], rating = 0 }) {
const s = await openSsb()
if (!s) return null
const safeLang = String(lang || '').trim().slice(0, 8).toLowerCase()
const safeTags = Array.isArray(tags)
? Array.from(new Set(tags.map(t => String(t || '').trim().slice(0, 32)).filter(Boolean))).slice(0, 10)
: []
const safeRating = Math.max(0, Math.min(5, Math.round(Number(rating) || 0)))
const content = {
type: 'aiExchange',
question: clip(String(q || ''), 2000),
answer: clip(String(a || ''), 5000),
ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
timestamp: Date.now()
}
if (safeLang) content.lang = safeLang
if (safeTags.length) content.tags = safeTags
if (safeRating > 0) content.rating = safeRating
return new Promise((resolve, reject) => {
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
})
}
async function publishExchangeVote({ targetId, helpful = true }) {
const s = await openSsb()
if (!s || !targetId) return null
const content = {
type: 'aiExchangeVote',
target: String(targetId),
helpful: !!helpful,
timestamp: Date.now()
}
return new Promise((resolve, reject) => {
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
})
}
async function buildContext(maxItems = 100) {
const s = await openSsb()
if (!s) return ''
const msgs = (await readTyped(s, ['aiExchange'], { limit: logLimit }).catch(() => null) || []).reverse()
const lines = []
for (const { value } of msgs) {
const c = value && value.content || {}
if (c.type !== 'aiExchange') continue
const d = new Date(value.timestamp || 0).toISOString().slice(0, 10)
const q = compact(c.question)
const a = compact(c.answer)
lines.push(`[${d}] (AIExchange) Q: ${q} | A: ${a}`)
if (lines.length >= maxItems) break
}
if (lines.length === 0) return ''
return `## AIEXCHANGE\n\n${lines.join('\n')}`
}
const fs = require('fs')
const VECTORS_FILE = 'AI-vectors.json'
const VECTORS_MAX = 4000
const RATING_WEIGHT = 0.03
const VOTE_WEIGHT = 0.02
const CONTEXT_MIN_SCORE = 0.35
const DIRECT_ANSWER_MIN = 0.92
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
const exchangeText = (c) => clip(squash(c.question), 300)
async function trustedAuthors(s) {
const me = s.id
const msgs = await readTyped(s, ['contact'], { limit: logLimit }).catch(() => null) || []
const latest = new Map()
for (const m of msgs) {
const v = m && m.value || {}
const c = v.content || {}
if (v.author !== me || typeof c.contact !== 'string') continue
const ts = Number(v.timestamp) || 0
const prev = latest.get(c.contact)
if (prev && prev.ts > ts) continue
latest.set(c.contact, { ts, on: c.following === true && c.blocking !== true })
}
const out = new Set([me])
for (const [id, st] of latest) if (st.on) out.add(id)
return out
}
async function listExchanges() {
const s = await openSsb()
if (!s) return { exchanges: [], votes: new Map() }
const msgs = await readTyped(s, ['aiExchange', 'aiExchangeVote'], { limit: logLimit }).catch(() => null) || []
const trusted = await trustedAuthors(s)
const exchanges = []
const votes = new Map()
for (const m of msgs) {
const c = m && m.value && m.value.content || {}
if (!trusted.has(m.value.author)) continue
if (c.type === 'aiExchange' && c.question && c.answer) {
exchanges.push({ key: m.key, author: m.value.author, ts: m.value.timestamp || m.timestamp || 0, question: String(c.question), answer: String(c.answer), rating: Number(c.rating) || 0, tags: Array.isArray(c.tags) ? c.tags : [], lang: c.lang || '' })
} else if (c.type === 'aiExchangeVote' && c.target) {
const k = String(c.target)
const per = votes.get(k) || new Map()
per.set(m.value.author, c.helpful !== false ? 1 : -1)
votes.set(k, per)
}
}
return { exchanges, votes }
}
const netVotes = (votes, key) => { const per = votes.get(key); if (!per) return 0; let n = 0; for (const v of per.values()) n += v; return n }
async function rankExchanges(question, { embed, cosine, k = 5, excludeAuthors = null } = {}) {
const listed = await listExchanges()
const votes = listed.votes
const exchanges = excludeAuthors instanceof Set && excludeAuthors.size
? listed.exchanges.filter(ex => !excludeAuthors.has(String(ex.author)))
: listed.exchanges
if (!exchanges.length) return []
const scored = []
if (typeof embed === 'function' && typeof cosine === 'function') {
const qv = await embed(question).catch(() => null)
if (qv) {
const cache = readVectors()
let dirty = false
const newest = exchanges.slice().sort((a, b) => b.ts - a.ts).slice(0, VECTORS_MAX)
for (const ex of newest) {
let v = cache[ex.key]
if (!Array.isArray(v)) {
v = await embed(exchangeText(ex)).catch(() => null)
if (!v) continue
cache[ex.key] = v
dirty = true
}
const sim = cosine(qv, v)
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
}
if (dirty) {
const keep = new Set(newest.map(x => x.key))
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
writeVectors(cache)
}
}
}
if (!scored.length) {
const want = normalize(question)
const terms = want.split(' ').filter(t => t.length > 2)
for (const ex of exchanges) {
const text = normalize(ex.question + ' ' + ex.answer)
const hits = terms.filter(t => text.includes(t)).length
if (!hits) continue
const sim = normalize(ex.question) === want ? 1 : hits / Math.max(1, terms.length)
scored.push({ ...ex, similarity: sim, votes: netVotes(votes, ex.key), score: sim + RATING_WEIGHT * ex.rating + VOTE_WEIGHT * Math.max(-5, Math.min(5, netVotes(votes, ex.key))) })
}
}
scored.sort((a, b) => b.score - a.score)
return scored.filter(x => x.similarity >= CONTEXT_MIN_SCORE).slice(0, k)
}
async function rankContext(question, opts = {}) {
const top = await rankExchanges(question, opts)
return top.map(ex => `Q: ${compact(ex.question)} | A: ${clip(squash(ex.answer), 400)}`)
}
async function getBestTrainedAnswer(question, opts = {}) {
const top = await rankExchanges(question, { ...opts, k: 1 })
const best = top[0]
if (!best || best.similarity < DIRECT_ANSWER_MIN || best.votes < 0) return null
return { answer: String(best.answer || '').trim(), ctx: [`Q: ${compact(best.question)}`], key: best.key }
}
async function exportFineTuning({ system = '' } = {}) {
const { exchanges, votes } = await listExchanges()
const lines = []
for (const ex of exchanges.sort((a, b) => a.ts - b.ts)) {
if (netVotes(votes, ex.key) < 0) continue
const messages = []
if (system) messages.push({ role: 'system', content: system })
messages.push({ role: 'user', content: ex.question })
messages.push({ role: 'assistant', content: ex.answer })
lines.push(JSON.stringify({ messages, meta: { key: ex.key, author: ex.author, lang: ex.lang, tags: ex.tags, rating: ex.rating, helpful: netVotes(votes, ex.key), ts: ex.ts } }))
}
return lines.join('\n') + (lines.length ? '\n' : '')
}
module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, publishExchangeVote, getBestTrainedAnswer, rankContext, rankExchanges, listExchanges, exportFineTuning, useCooler, DIRECT_ANSWER_MIN, CONTEXT_MIN_SCORE }

138
src/AI/embedder.js Normal file
View file

@ -0,0 +1,138 @@
const path = require('path')
const fs = require('fs')
const { fork } = require('child_process')
const MODEL_DIR = path.join(__dirname, 'embeddings')
const MODEL_FILE = path.join(MODEL_DIR, 'onnx', 'model_quantized.onnx')
const EMBED_TIMEOUT_MS = 30000
let worker = null
let workerReady = false
let permanentFail = false
let unavailableReason = null
let seq = 0
const pending = new Map()
const isInstalled = () => {
try {
return fs.existsSync(MODEL_FILE)
} catch (_) {
return false
}
}
const dot = (a, b) => {
let s = 0
for (let i = 0; i < a.length; i++) s += a[i] * b[i]
return s
}
const norm = (a) => Math.sqrt(dot(a, a)) || 1
const cosine = (a, b) => dot(a, b) / (norm(a) * norm(b))
const settlePending = () => {
for (const p of pending.values()) p.resolve(null)
pending.clear()
}
const ensureWorker = () => {
if (worker || permanentFail) return
const child = fork(__filename, ['--embed-worker'], { silent: true })
if (child.stdout) child.stdout.resume()
if (child.stderr) child.stderr.resume()
child.on('message', (msg) => {
const p = pending.get(msg && msg.id)
if (!p) return
pending.delete(msg.id)
workerReady = true
p.resolve(Array.isArray(msg.vec) ? msg.vec : null)
})
child.on('error', () => {})
child.on('exit', () => {
settlePending()
if (worker === child) {
worker = null
if (!workerReady) {
permanentFail = true
unavailableReason = 'embedder_native_crash'
}
workerReady = false
}
})
worker = child
}
const embed = async (text, { timeoutMs = EMBED_TIMEOUT_MS } = {}) => {
if (!isInstalled() || permanentFail) return null
ensureWorker()
const child = worker
if (!child) return null
const id = ++seq
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id)
try { child.kill() } catch (_) {}
resolve(null)
}
}, Math.max(1000, Number(timeoutMs) || EMBED_TIMEOUT_MS))
pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v) } })
try {
child.send({ id, text: String(text || '').trim() })
} catch (_) {
pending.delete(id)
clearTimeout(timer)
resolve(null)
}
})
}
const status = () => ({
installed: isInstalled(),
loaded: workerReady,
unavailableReason
})
module.exports = { embed, cosine, isInstalled, status, MODEL_DIR, MODEL_FILE }
if (process.argv[2] === '--embed-worker') {
let pipelinePromise = null
const ensurePipeline = async () => {
if (pipelinePromise) return pipelinePromise
if (!isInstalled()) return null
pipelinePromise = (async () => {
let mod
try {
const url = require('url')
const transformersPath = path.join(__dirname, 'node_modules', '@xenova', 'transformers', 'src', 'transformers.js')
mod = await import(url.pathToFileURL(transformersPath).href)
} catch (_) {
return null
}
const { pipeline, env } = mod
env.allowRemoteModels = false
env.localModelPath = path.join(__dirname)
env.cacheDir = path.join(__dirname, '.cache')
try {
return await pipeline('feature-extraction', 'embeddings', { quantized: true })
} catch (_) {
return null
}
})()
const fe = await pipelinePromise
if (!fe) pipelinePromise = null
return fe
}
process.on('message', async (msg) => {
let vec = null
try {
const fe = await ensurePipeline()
if (fe) {
const out = await fe(String((msg && msg.text) || ''), { pooling: 'mean', normalize: true })
vec = Array.from(out.data)
}
} catch (_) {}
try { process.send({ id: msg && msg.id, vec }) } catch (_) {}
})
}

397
src/AI/intents.js Normal file
View file

@ -0,0 +1,397 @@
const INTENTS = [
{ key: 'funds', description: 'my balance, how much ECO do I have, my wallet funds, my ECOin address', keywords: ['balance', 'saldo', 'funds', 'fondos', 'wallet', 'cartera', 'address', 'dirección', 'my ecoin', 'mi ecoin', 'fons', 'moneder', 'carteira', 'adreça', 'enderezo', 'el meu ecoin', 'o meu ecoin'] },
{ key: 'exchange', description: 'the value of one ECO, ECO price, ECOin exchange rate, how much is an ECO worth, ECO per hour, supply and inflation of ECOin', keywords: ['value of', 'valor de', 'eco value', 'ecoin value', 'valor del eco', 'price', 'precio', 'worth', 'vale un eco', 'cuánto vale', 'exchange', 'cotización', 'cambio', 'supply', 'suministro', 'inflation', 'inflación', 'eco/h', '1 eco', 'un eco', 'preu', 'prezo', 'quant val', 'canto vale', 'cotització', 'canvi', 'oferta total', 'inflació'] },
{ key: 'ubi', description: 'my UBI, universal basic income, can I claim the UBI, when did I claim, how much UBI did I receive', keywords: ['ubi', 'rbu', 'basic income', 'renta básica', 'claim', 'reclamar', 'cobrar', 'renda bàsica', 'renda básica'] },
{ key: 'karma', description: 'my karma score, my taxes, eco tax, arch tax, engagement score', keywords: ['karma', 'tax', 'impuesto', 'engagement', 'impost', 'imposto', 'taxa eco', 'taxa arch'] },
{ key: 'agenda', description: 'my agenda, what do I have coming up, upcoming dates, next days, schedule', keywords: ['agenda', 'schedule', 'próximo', 'upcoming', 'coming', 'esta semana', 'this week', 'calendar', 'calendario', 'aquesta setmana', 'propers dies', 'calendari', 'axenda', 'próximos días'] },
{ key: 'events', description: 'upcoming events, next event, events near me, what events are there', keywords: ['event', 'evento', 'esdeveniment'] },
{ key: 'tasks', description: 'my open tasks, pending tasks, to-do, what do I have to do', keywords: ['task', 'tarea', 'to-do', 'todo', 'pendiente', 'tasca', 'tarefa', 'pendent', 'tasques'] },
{ key: 'transfers', description: 'pending transfers, contracts waiting for my confirmation, payments to confirm', keywords: ['transfer', 'transferencia', 'contract', 'contrato', 'confirm', 'confirmar', 'payment', 'pago', 'contracte', 'pagament', 'pagamento'] },
{ key: 'relationships', description: 'who follows me, who do I follow, my followers, my mutual contacts, friends', keywords: ['follow', 'sigue', 'seguidor', 'follower', 'mutual', 'friend', 'amigo', 'contacto', 'segueix', 'segue', 'amic', 'contacte', 'mutus'] },
{ key: 'inbox', description: 'my inbox, unread messages, private messages, notifications, mentions', keywords: ['inbox', 'unread', 'no leído', 'mensaje', 'message', 'pm', 'notification', 'notificación', 'mention', 'mención', 'no llegit', 'non lido', 'missatge', 'mensaxe', 'notificació', 'bústia', 'buzón', 'caixa de entrada', 'menció'] },
{ key: 'government', description: 'current government, parliament, who rules, governance cycle, LARP house', keywords: ['government', 'gobierno', 'parliament', 'parlamento', 'rules', 'gobierna', 'larp', 'house', 'casa', 'govern', 'goberno', 'parlament', 'governa', 'goberna'] },
{ key: 'emergencies', description: 'active emergencies, alerts, is there any emergency', keywords: ['emergency', 'emergencia', 'alert', 'alerta', 'emergència', 'emerxencia', 'emergències'] },
{ key: 'jobs', description: 'open job offers, jobs for me, work, salary', keywords: ['job', 'trabajo', 'empleo', 'offer', 'oferta', 'salary', 'salario', 'vacante', 'feina', 'treball', 'ocupació', 'traballo', 'emprego', 'salari', 'feines'] },
{ key: 'market', description: 'items for sale in the market, second hand, auctions, what can I buy', keywords: ['market', 'mercado', 'buy', 'comprar', 'vender', 'item', 'auction', 'subasta', 'mercat', 'vendre', 'subhasta', 'poxa', 'subhastes'] },
{ key: 'tribes', description: 'my tribes, which tribes am I in, tribe members', keywords: ['tribe', 'tribu', 'tribo'] },
{ key: 'network', description: 'network status, inhabitants count, peers online, last sync, population', keywords: ['network', 'red', 'peer', 'inhabitant', 'habitante', 'population', 'población', 'online', 'sync', 'sincron', 'xarxa', 'rede', 'habitant', 'població', 'poboación'] },
{ key: 'me', description: 'who am I, my identity, my id, my name, my profile, oasis version', keywords: ['who am i', 'quién soy', 'my id', 'mi id', 'identity', 'identidad', 'profile', 'perfil', 'version', 'versión', 'qui sóc', 'quen son', 'el meu id', 'o meu id', 'identitat', 'versió'] },
{ key: 'shops', description: 'products in the shops, what do the shops sell, shop catalogue, buy a product', keywords: ['shop', 'tienda', 'product', 'producto', 'catálogo', 'catalog', 'botiga', 'tenda', 'producte', 'produto', 'catàleg', 'botigues'] },
{ key: 'school', description: 'courses in the school, lessons, my courses, enrol, learn, certificates', keywords: ['school', 'escuela', 'course', 'curso', 'lesson', 'lección', 'learn', 'aprender', 'certificate', 'certificado', 'student', 'estudiante', 'escola', ' curs', 'lliçó', 'aprendre', 'certificat', 'estudiant', 'estudante'] },
{ key: 'projects', description: 'crowdfunding projects, projects looking for funding, pledges, project goals', keywords: ['project', 'proyecto', 'crowdfunding', 'pledge', 'aportación', 'financiar', 'funding', 'projecte', 'proxecto', 'aportació', 'achega', 'finançar'] },
{ key: 'housing', description: 'housing, places offered, rooms, homes, rent a place, accommodation', keywords: ['housing', 'vivienda', 'room', 'habitación', 'rent', 'alquil', 'accommodation', 'alojamiento', 'habitatge', 'vivenda', 'habitació', 'lloguer', 'aluguer', 'allotjament', 'aloxamento'] },
{ key: 'industry', description: 'industry facilities, factories, builds, production, what is being built', keywords: ['industry', 'industria', 'facility', 'fábrica', 'factory', 'build', 'construir', 'production', 'producción', 'indústria', 'fàbrica', 'producció', 'produción'] },
{ key: 'campaigns', description: 'campaigns, petitions, signatures, collective demands', keywords: ['campaign', 'campaña', 'petition', 'petición', 'signature', 'firma', 'demand', 'campanya', 'petició', 'signatura', 'sinatura', 'campanyes', 'signatures'] },
{ key: 'logistics', description: 'logistics routes, deliveries, transport, shipping, packages, bookings', keywords: ['logistic', 'logística', 'route', 'ruta', 'delivery', 'entrega', 'transport', 'envío', 'shipping', 'package', 'paquete', 'loxística', 'lliurament', 'enviament', 'paquet'] },
{ key: 'calendars', description: 'calendars, dates, upcoming dates in my calendars, deadlines, reminders', keywords: ['calendar', 'calendario', 'date', 'fecha', 'deadline', 'recordatorio', 'reminder', 'calendari', 'termini', 'prazo', 'recordatori'] },
{ key: 'votes', description: 'votations, polls, open votes, voting, ballots, what can I vote on', keywords: ['vote', 'votación', 'votar', 'poll', 'encuesta', 'ballot', 'referendum', 'votació', 'enquesta', 'enquisa', 'enquestes'] },
{ key: 'wiki', description: 'wiki pages, knowledge base, documentation written by inhabitants', keywords: ['wiki'] },
{ key: 'chats', description: 'my chats, group chats, conversations, chat rooms', keywords: ['chat', 'conversación', 'sala', 'xat', 'conversa'] },
{ key: 'mailing', description: 'mailing lists, subscriptions, newsletters, discussion lists', keywords: ['mailing', 'lista de correo', 'newsletter', 'subscri', 'llista de correu'] },
{ key: 'podcasts', description: 'podcasts, audio shows, episodes, radio', keywords: ['podcast', 'episode', 'episodio', 'radio', 'episodi'] },
{ key: 'media', description: 'audios, videos, images, documents, files, media library, how many files', keywords: ['audio', 'video', 'vídeo', 'image', 'imagen', 'photo', 'foto', 'document', 'documento', 'file', 'archivo', 'media', 'gallery', 'galería', 'torrent', 'àudio', 'imatge', 'imaxe', 'fitxer', 'arxiu', 'ficheiro', 'arquivo', 'galeria'] },
{ key: 'blogs', description: 'blogs, blog entries, articles, latest posts written by inhabitants', keywords: ['blog', 'entrada', 'article', 'artículo', 'artigo'] },
{ key: 'reports', description: 'reports, complaints, flagged content, incidents', keywords: ['report', 'reporte', 'denuncia', 'complaint', 'queja', 'flag', 'incident', 'denúncia', 'queixa', 'informe'] },
{ key: 'games', description: 'games, hall of fame, scores, best players, play', keywords: ['game', 'juego', 'play', 'jugar', 'score', 'puntuación', 'hall of fame', 'ranking', ' joc', 'xogo', 'xogar', 'puntuació'] },
{ key: 'tags', description: 'tags, hashtags, topics, most used tags, what is the network talking about', keywords: ['tag', 'etiqueta', 'hashtag', 'topic', 'tema', 'trending', 'tendencia', 'tendència', 'etiquetes'] },
{ key: 'favorites', description: 'my favorites, things I starred, saved items, bookmarks', keywords: ['favorite', 'favorito', 'starred', 'saved', 'guardado', 'bookmark', 'marcador', 'preferit', 'favorit', 'desat', 'gardado'] },
{ key: 'modules', description: 'which modules are enabled, what features does this node have, oasis modules', keywords: ['module', 'módulo', 'feature', 'funcionalidad', 'enabled', 'activado', 'disabled', 'desactivado', 'plugin', 'mòdul', 'funcionalitat', 'activat', 'desactivat'] },
{ key: 'help', description: 'what can you do, help, how can you help me, what do you know, commands', keywords: ['what can you', 'qué puedes', 'help', 'ayuda', 'how can you', 'cómo puedes', 'what do you know', 'qué sabes', 'capabilities', 'què pots', 'que podes', 'ajuda', 'axuda', 'com pots', 'como podes', 'què saps'] }
];
const KEYWORD_MIN = 1;
const EMBED_MIN = 0.55;
const EMBED_MARGIN = 0.05;
const norm = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const detectByKeywords = (question) => {
const q = ' ' + norm(question) + ' ';
let best = null;
for (const it of INTENTS) {
const hits = it.keywords.filter(k => q.includes(norm(k))).length;
if (hits >= KEYWORD_MIN && (!best || hits > best.hits)) best = { key: it.key, hits };
}
return best ? best.key : null;
};
let intentVectors = null;
const detect = async (question, { embed, cosine } = {}) => {
const byKeywords = detectByKeywords(question);
if (byKeywords) return byKeywords;
if (typeof embed !== 'function' || typeof cosine !== 'function') return null;
const qv = await embed(question).catch(() => null);
if (!qv) return null;
if (!intentVectors) {
const out = [];
for (const it of INTENTS) { const v = await embed(it.description).catch(() => null); if (v) out.push({ key: it.key, v }); }
intentVectors = out;
}
const scored = intentVectors.map(it => ({ key: it.key, s: cosine(qv, it.v) })).sort((a, b) => b.s - a.s);
const best = scored[0];
const second = scored[1];
if (!best || best.s < EMBED_MIN) return null;
if (second && best.s - second.s < EMBED_MARGIN) return null;
return best.key;
};
const fmtEco = (n) => `${Number(n || 0).toFixed(6)} ECO`;
const fmtDate = (d) => { const t = new Date(d || 0); return Number.isFinite(t.getTime()) && t.getTime() > 0 ? t.toISOString().replace('T', ' ').slice(0, 16) : ''; };
const titleOf = (c) => String(c.title || c.name || c.question || c.concept || c.subject || '').trim();
const dateOf = (c) => c.date || c.startTime || c.startDate || c.deadline || c.endTime || c.createdAt || null;
const line = (text, href) => ({ text, href: href || null });
const RUNNERS = {
async funds({ models, me }) {
const out = [];
const data = await models.banking.listBanking('overview', me).catch(() => null);
const summary = (data && data.summary) || {};
const addr = await models.banking.getUserAddress(me).catch(() => null);
if (summary.hasValidWallet) out.push(line(`Wallet balance: ${fmtEco(summary.userBalance)}`, '/wallet'));
else out.push(line('Wallet: not connected (Settings › Wallet)', '/settings#wallet'));
out.push(line(addr ? `ECOin address: ${addr}` : 'ECOin address: none published', '/banking?filter=addresses'));
if (data && data.exchange && data.exchange.isSynced) out.push(line(`ECO value (1h): ${Number(data.exchange.ecoValue || 0).toFixed(4)} ECO`, '/banking?filter=exchange'));
if (Number.isFinite(Number(summary.industryBalance)) && Number(summary.industryBalance) !== 0) out.push(line(`Industry balance: ${fmtEco(summary.industryBalance)}`, '/industry'));
return out;
},
async exchange({ models, me }) {
const data = await models.banking.listBanking('exchange', me).catch(() => null);
const ex = (data && data.exchange) || {};
if (!ex.isSynced) return [line('ECO value: unknown right now, the wallet is not synced with the ECOin network', '/banking?filter=exchange')];
const hours = Number(ex.ecoTimeMs || 0) / 3600000;
const out = [
line(`Value of 1 ECO right now: ${Number(ex.ecoValue || 0).toFixed(4)} ECO per hour of UBI (1 ECO ≈ ${hours > 0 ? hours.toFixed(2) + ' hours' : 'n/a'})`, '/banking?filter=exchange'),
line(`ECOin total supply: ${fmtEco(ex.totalSupply)} · current supply: ${fmtEco(ex.currentSupply)}`, '/banking?filter=exchange'),
line(`Inflation: ${Number(ex.inflationFactor || 0).toFixed(2)}% annual · ${Number(ex.inflationMonthly || 0).toFixed(2)}% monthly`, '/banking?filter=exchange')
];
if (ex.pubsSupply != null) out.push(line(`PUBs pool supply: ${fmtEco(ex.pubsSupply)}${ex.holdingSupply != null ? ` · holding supply: ${fmtEco(ex.holdingSupply)}` : ''}`, '/banking?filter=exchange'));
return out;
},
async ubi({ models, me }) {
const out = [];
const data = await models.banking.listBanking('ubi', me).catch(() => null);
const summary = (data && data.summary) || {};
const bank = await models.banking.getBankingData(me).catch(() => ({}));
if (summary.epochId) out.push(line(`Current epoch: ${summary.epochId}`, '/banking?filter=ubi'));
if (summary.alreadyClaimed) out.push(line('This month: already claimed', '/banking?filter=ubi'));
else if (summary.alreadyRefused) out.push(line('This month: refused', '/banking?filter=ubi'));
else if (data && data.pendingUBI) out.push(line(`This month: ${fmtEco(data.pendingUBI.amount)} available to claim`, '/banking?filter=ubi'));
else out.push(line(`This month: nothing to claim yet (${summary.ubiAvailability === 'NO_FUNDS' ? 'PUB without funds' : 'no allocation'})`, '/banking?filter=ubi'));
if (bank.estimatedUBI != null) out.push(line(`Estimated UBI: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi'));
out.push(line(`Total received: ${fmtEco(bank.totalClaimed)}${bank.lastClaimedDate ? ` (last: ${fmtDate(bank.lastClaimedDate)})` : ''}`, '/banking?filter=ubi'));
const pubs = Array.isArray(data && data.ubiPubs) ? data.ubiPubs : [];
if (pubs.length) out.push(line(`PUBs announcing UBI: ${pubs.length}`, '/banking?filter=ubi'));
return out;
},
async karma({ models, me }) {
const bank = await models.banking.getBankingData(me).catch(() => ({}));
return [
line(`Karma score: ${Number(bank.karmaScore || 0).toFixed(2)}`, '/banking?filter=overview'),
line(`ECOin tax: ${fmtEco(bank.userEcoinTax)} · ARCH tax: ${fmtEco(bank.userArchTax)} · total: ${fmtEco(bank.userTotalTax)}`, '/banking?filter=taxes'),
line(`Estimated UBI after taxes: ${fmtEco(bank.estimatedUBI)}`, '/banking?filter=ubi')
];
},
async agenda({ models, hrefFor }) {
const items = await models.agenda.listAgenda('all').catch(() => []);
const now = Date.now();
const soon = (Array.isArray(items) ? items : [])
.map(it => ({ it, t: new Date(dateOf(it) || 0).getTime() }))
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000)
.sort((a, b) => a.t - b.t)
.slice(0, 8);
if (!soon.length) return [line('Agenda: nothing upcoming', '/agenda')];
return soon.map(({ it, t }) => line(`${fmtDate(t)} · ${titleOf(it) || it.type || 'item'}${it.type ? ` (${it.type})` : ''}`, hrefFor(it.type, it.id || it.tipId)));
},
async events({ models, hrefFor }) {
const events = await models.events.listAll(null, 'all').catch(() => []);
const now = Date.now();
const upcoming = (Array.isArray(events) ? events : [])
.map(e => ({ e, t: new Date(e.date || 0).getTime() }))
.filter(x => Number.isFinite(x.t) && x.t >= now - 3600000 && String(x.e.status || '').toUpperCase() !== 'CLOSED')
.sort((a, b) => a.t - b.t).slice(0, 8);
if (!upcoming.length) return [line('Events: none upcoming', '/events')];
return upcoming.map(({ e, t }) => line(`${fmtDate(t)} · ${titleOf(e)}${e.location ? ` · ${e.location}` : ''}${Number(e.price) > 0 ? ` · ${fmtEco(e.price)}` : ''}`, hrefFor('event', e.id)));
},
async tasks({ models, me, hrefFor }) {
const tasks = await models.tasks.listAll().catch(() => []);
const mine = (Array.isArray(tasks) ? tasks : []).filter(t => String(t.status || '').toUpperCase() === 'OPEN' && (t.author === me || (Array.isArray(t.assignees) && t.assignees.includes(me))));
if (!mine.length) return [line('Tasks: nothing open assigned to you', '/tasks')];
return mine.slice(0, 10).map(t => line(`${titleOf(t)}${t.endTime ? ` · due ${fmtDate(t.endTime)}` : ''}`, hrefFor('task', t.id)));
},
async transfers({ models, me, hrefFor }) {
const all = await models.transfers.listAll('all').catch(() => []);
const pending = (Array.isArray(all) ? all : []).filter(t => String(t.status || '').toUpperCase() === 'UNCONFIRMED' && (t.to === me || t.from === me) && !(Array.isArray(t.confirmedBy) && t.confirmedBy.includes(me)));
if (!pending.length) return [line('Transfers: nothing waiting for your confirmation', '/transfers?filter=pending')];
return pending.slice(0, 10).map(t => line(`${fmtEco(t.amount)} · ${t.concept || t.id} · ${t.from === me ? 'to' : 'from'} ${t.from === me ? t.to : t.from}`, hrefFor('transfer', t.id)));
},
async relationships({ models, me }) {
const users = await models.inhabitants.listInhabitants({ filter: 'all', includeInactive: true }).catch(() => []);
const followers = [], following = [], mutuals = [];
for (const u of (Array.isArray(users) ? users : []).slice(0, 400)) {
if (!u || !u.id || u.id === me) continue;
const rel = await models.friend.getRelationship(u.id).catch(() => null);
if (!rel) continue;
const name = u.name || u.id.slice(0, 9);
if (rel.following && rel.followsMe) mutuals.push(name);
else if (rel.following) following.push(name);
else if (rel.followsMe) followers.push(name);
}
const list = (arr) => arr.slice(0, 12).join(', ') + (arr.length > 12 ? '…' : '');
return [
line(`Mutual contacts: ${mutuals.length}${mutuals.length ? ` · ${list(mutuals)}` : ''}`, '/inhabitants?filter=mutuals'),
line(`You follow: ${following.length + mutuals.length}${following.length ? ` · also ${list(following)}` : ''}`, '/inhabitants?filter=following'),
line(`Follow you: ${followers.length + mutuals.length}${followers.length ? ` · also ${list(followers)}` : ''}`, '/inhabitants?filter=followers')
];
},
async inbox({ sharedState }) {
return [
line(`Unread private messages: ${Number(sharedState.getInboxPmCount ? sharedState.getInboxPmCount() : 0) || 0}`, '/inbox?filter=pms'),
line(`Unread bot notifications: ${Number(sharedState.getInboxNotifCount ? sharedState.getInboxNotifCount() : 0) || 0}`, '/inbox?filter=notifications'),
line(`Unread mentions: ${Number(sharedState.getMentionsCount ? sharedState.getMentionsCount() : 0) || 0}`, '/mentions')
];
},
async government({ models, me, nameOf }) {
const out = [];
const card = await models.parliament.getLatestGovernmentCard().catch(() => null);
if (card) {
const leader = card.leaderId ? await nameOf(card.leaderId) : '';
out.push(line(`Government: ${String(card.method || '').toUpperCase()}${leader ? ` · leader ${leader}` : ''}${card.end ? ` · cycle ends ${fmtDate(card.end)}` : ''}`, '/parliament'));
} else out.push(line('Government: no cycle published yet', '/parliament'));
if (models.larp && typeof models.larp.getUserHouse === 'function') {
const house = await models.larp.getUserHouse(me).catch(() => null);
if (house) out.push(line(`Your LARP house: ${house}`, '/larp'));
}
return out;
},
async emergencies({ models, me, hrefFor }) {
const all = await models.emergencies.listAll({ filter: 'all', viewerId: me }).catch(() => []);
const active = (Array.isArray(all) ? all : []).filter(e => String(e.status || '').toUpperCase() !== 'RESOLVED').slice(0, 8);
if (!active.length) return [line('Emergencies: none active', '/emergencies')];
return active.map(e => line(`${titleOf(e)}${e.status ? ` · ${e.status}` : ''}`, hrefFor('emergency', e.id)));
},
async jobs({ models, me, hrefFor }) {
const jobs = await models.jobs.listJobs('ALL', me).catch(() => []);
const open = (Array.isArray(jobs) ? jobs : []).filter(j => String(j.status || 'OPEN').toUpperCase() !== 'CLOSED').slice(0, 8);
if (!open.length) return [line('Jobs: no open offers', '/jobs')];
return open.map(j => line(`${titleOf(j)}${Number(j.salary) > 0 ? ` · ${fmtEco(j.salary)}` : ''}`, hrefFor('job', j.id)));
},
async market({ models, hrefFor }) {
const items = await models.market.listAllItems('all').catch(() => []);
const forSale = (Array.isArray(items) ? items : []).filter(i => String(i.status || '').toUpperCase() === 'FOR SALE' || String(i.status || '').toUpperCase() === 'OPEN' || !i.status).slice(0, 8);
if (!forSale.length) return [line('Market: nothing for sale right now', '/market')];
return forSale.map(i => line(`${titleOf(i)}${i.price != null ? ` · ${fmtEco(i.price)}` : ''}`, hrefFor('market', i.id)));
},
async tribes({ models, me, hrefFor }) {
const all = await models.tribes.listAll().catch(() => []);
const mine = (Array.isArray(all) ? all : []).filter(t => Array.isArray(t.members) && t.members.includes(me));
if (!mine.length) return [line('Tribes: you are not in any tribe', '/tribes')];
return mine.slice(0, 10).map(t => line(`${titleOf(t)} · ${t.members.length} members`, hrefFor('tribe', t.id)));
},
async network({ sharedState }) {
const ts = sharedState.getLastSyncTs ? sharedState.getLastSyncTs() : null;
return [
line(`Inhabitants: ${Number(sharedState.getInhabitantCount ? sharedState.getInhabitantCount() : 0) || 0}`, '/inhabitants'),
line(`Tribes: ${Number(sharedState.getTribesCount ? sharedState.getTribesCount() : 0) || 0}`, '/tribes'),
line(`Peers online: ${Number(sharedState.getOnlinePeerCount ? sharedState.getOnlinePeerCount() : 0) || 0}`, '/peers'),
line(`Last sync: ${ts ? fmtDate(ts) : 'never'}`, '/peers'),
line(`ECO value (1h): ${sharedState.getEcoValue && sharedState.getEcoValue() ? sharedState.getEcoValue() + ' ECO' : 'unknown (wallet not synced)'}`, '/banking?filter=exchange')
];
},
async me({ me, nameOf, version }) {
const name = await nameOf(me);
return [
line(`You are ${name} (${me})`, '/profile'),
line(`Oasis version: ${version || 'unknown'}`, '/settings')
];
}
};
const listOf = (x) => Array.isArray(x) ? x : (x && Array.isArray(x.items) ? x.items : []);
const isOpen = (x) => { const s = String(x && x.status || 'OPEN').toUpperCase(); return !['CLOSED', 'RESOLVED', 'EXPIRED', 'DISSOLVED', 'CANCELLED', 'DELETED'].includes(s); };
const few = (arr, n = 8) => arr.slice(0, n);
Object.assign(RUNNERS, {
async shops({ models, hrefFor }) {
const items = listOf(await models.shops.listAllProducts({ filter: 'all' }).catch(() => []));
const open = few(items.filter(isOpen));
if (!open.length) return [line('Shops: no products on sale right now', '/shops')];
return open.map(p => line(`${titleOf(p)}${p.price != null ? ` · ${fmtEco(p.price)}` : ''}${Number(p.stock) > 0 ? ` · stock ${p.stock}` : ''}`, hrefFor('shopProduct', p.id || p.key)));
},
async school({ models, me, hrefFor }) {
const courses = listOf(await models.school.listCourses('ALL', me, {}).catch(() => []));
const mine = courses.filter(c => c.author === me || (Array.isArray(c.students) && c.students.includes(me)));
const open = few(courses.filter(isOpen));
const out = [line(`School: ${courses.length} courses, ${mine.length} of them yours (teaching or enrolled)`, '/school')];
return out.concat(open.map(c => line(`${titleOf(c)}${Number(c.price) > 0 ? ` · ${fmtEco(c.price)}` : ' · free'}${Array.isArray(c.students) ? ` · ${c.students.length} students` : ''}`, hrefFor('schoolCourse', c.id))));
},
async projects({ models, hrefFor }) {
const projects = listOf(await models.projects.listProjects('all', {}).catch(() => []));
const open = few(projects.filter(isOpen));
if (!open.length) return [line('Projects: none looking for funding right now', '/projects')];
return open.map(p => line(`${titleOf(p)}${p.goal != null ? ` · goal ${fmtEco(p.goal)}` : ''}${p.pledged != null ? ` · pledged ${fmtEco(p.pledged)}` : ''}${p.deadline ? ` · until ${fmtDate(p.deadline)}` : ''}`, hrefFor('project', p.id)));
},
async housing({ models, me, hrefFor }) {
const items = listOf(await models.housing.listHousing('ALL', me, {}).catch(() => []));
const open = few(items.filter(isOpen));
if (!open.length) return [line('Housing: no places offered right now', '/housing')];
return open.map(h => line(`${titleOf(h)}${h.price != null ? ` · ${fmtEco(h.price)}` : ''}${h.location ? ` · ${h.location}` : ''}`, hrefFor('housing', h.id)));
},
async industry({ models, me, hrefFor }) {
const facilities = listOf(await models.industry.listFacilities('ALL').catch(() => []));
const mine = facilities.filter(f => Array.isArray(f.members) && f.members.includes(me));
const out = [line(`Industry: ${facilities.length} facilities, you belong to ${mine.length}`, '/industry')];
return out.concat(few(facilities.filter(isOpen)).map(f => line(`${titleOf(f)}${Array.isArray(f.members) ? ` · ${f.members.length} members` : ''}`, hrefFor('industry', f.id))));
},
async campaigns({ models, me, hrefFor }) {
const items = listOf(await models.campaigns.listAll({ filter: 'all', viewerId: me }).catch(() => []));
const open = few(items.filter(isOpen));
if (!open.length) return [line('Campaigns: none open right now', '/campaigns')];
return open.map(c => line(`${titleOf(c)}${Array.isArray(c.signers) ? ` · ${c.signers.length} signatures` : ''}${c.goal ? ` of ${c.goal}` : ''}`, hrefFor('campaign', c.id)));
},
async logistics({ models, hrefFor }) {
const routes = listOf(await models.logistics.listAll({ filter: 'all' }).catch(() => []));
const open = few(routes.filter(isOpen));
if (!open.length) return [line('Logistics: no open routes right now', '/logistics')];
return open.map(r => line(`${titleOf(r)}${r.price != null && Number(r.price) > 0 ? ` · ${Number(r.price)} ${r.priceType === 'TIME' ? 'h' : 'ECO'}` : ''}${r.status ? ` · ${r.status}` : ''}`, hrefFor('logisticsRoute', r.id)));
},
async calendars({ models, me, hrefFor }) {
const cals = listOf(await models.calendars.listAll({ filter: 'all', viewerId: me }).catch(() => []));
if (!cals.length) return [line('Calendars: you are not in any calendar', '/calendars')];
const now = Date.now();
const soon = cals.map(c => ({ c, t: new Date(dateOf(c) || 0).getTime() })).filter(x => Number.isFinite(x.t) && x.t >= now - 3600000).sort((a, b) => a.t - b.t);
const out = [line(`Calendars: ${cals.length}`, '/calendars')];
return out.concat(few(soon).map(({ c, t }) => line(`${fmtDate(t)} · ${titleOf(c)}`, hrefFor('calendar', c.id))));
},
async votes({ models, hrefFor }) {
const votes = listOf(await models.votes.listAll('all').catch(() => []));
const polls = listOf(await models.polls.listAll('ALL', {}).catch(() => []));
const openV = few(votes.filter(isOpen), 5);
const openP = few(polls.filter(isOpen), 5);
if (!openV.length && !openP.length) return [line('Votes: nothing open to vote on right now', '/votes')];
return openV.map(v => line(`Votation: ${titleOf(v)}${v.deadline ? ` · until ${fmtDate(v.deadline)}` : ''}`, hrefFor('vote', v.id)))
.concat(openP.map(p => line(`Poll: ${titleOf(p)}`, hrefFor('poll', p.id))));
},
async wiki({ models, me, hrefFor }) {
const pages = listOf(await models.wiki.listPages({ viewerId: me }).catch(() => []));
if (!pages.length) return [line('Wiki: no pages yet', '/wiki')];
const out = [line(`Wiki: ${pages.length} pages`, '/wiki')];
return out.concat(few(pages, 6).map(p => line(titleOf(p), hrefFor('wikiPage', p.id || p.slug))));
},
async chats({ models, me, hrefFor }) {
const chats = listOf(await models.chats.listAll({ filter: 'all', viewerId: me }).catch(() => []));
const mine = chats.filter(c => c.author === me || (Array.isArray(c.members) && c.members.includes(me)) || (Array.isArray(c.participants) && c.participants.includes(me)));
if (!mine.length) return [line('Chats: you are not in any chat', '/chats')];
return few(mine).map(c => line(`${titleOf(c)}${Array.isArray(c.members) ? ` · ${c.members.length} members` : ''}`, hrefFor('chat', c.key || c.id)));
},
async mailing({ models, hrefFor }) {
const lists = listOf(await models.mailing.listAll({ filter: 'all' }).catch(() => []));
const active = few(lists.filter(l => String(l.status || 'ACTIVE').toUpperCase() === 'ACTIVE'));
if (!active.length) return [line('Mailing lists: none active', '/mailing')];
return active.map(l => line(`${titleOf(l)}${Array.isArray(l.subscribers) ? ` · ${l.subscribers.length} subscribers` : ''}`, hrefFor('mailingList', l.id)));
},
async podcasts({ models, me, hrefFor }) {
const channels = listOf(await models.podcasts.listAll({ filter: 'all', viewerId: me }).catch(() => []));
if (!channels.length) return [line('Podcasts: none yet', '/podcasts')];
return few(channels).map(c => line(`${titleOf(c)}${Array.isArray(c.episodes) ? ` · ${c.episodes.length} episodes` : ''}`, hrefFor('podcast', c.id)));
},
async media({ models }) {
const count = async (m) => m && typeof m.listAll === 'function' ? listOf(await m.listAll('all').catch(() => [])).length : 0;
const [audios, videos, images, documents, torrents, files] = await Promise.all([count(models.audios), count(models.videos), count(models.images), count(models.documents), count(models.torrents), count(models.files)]);
return [
line(`Audios: ${audios}`, '/audios'), line(`Videos: ${videos}`, '/videos'), line(`Images: ${images}`, '/images'),
line(`Documents: ${documents}`, '/documents'), line(`Files: ${files}`, '/files'), line(`Torrents: ${torrents}`, '/torrents')
];
},
async blogs({ models }) {
const blogs = listOf(await models.blog.listAll('all').catch(() => []));
if (!blogs.length) return [line('Blogs: no entries yet', '/blogs')];
const latest = blogs.slice().sort((a, b) => new Date(dateOf(b) || 0) - new Date(dateOf(a) || 0));
return [line(`Blogs: ${blogs.length} entries`, '/blogs')].concat(few(latest, 6).map(b => line(`${titleOf(b) || 'Untitled'}${dateOf(b) ? ` · ${fmtDate(dateOf(b))}` : ''}`, b.key || b.id ? `/blogs/${encodeURIComponent(b.key || b.id)}` : '/blogs')));
},
async reports({ models, hrefFor }) {
const reports = listOf(await models.reports.listAll().catch(() => []));
const open = few(reports.filter(isOpen));
if (!open.length) return [line('Reports: none open', '/reports')];
return open.map(r => line(`${titleOf(r)}${r.status ? ` · ${r.status}` : ''}`, hrefFor('report', r.id)));
},
async games({ models, nameOf }) {
const hall = listOf(await models.games.getHallOfFame().catch(() => []));
if (!hall.length) return [line('Games: no scores yet', '/games')];
const out = [];
for (const h of few(hall, 6)) out.push(line(`${h.game || 'game'} · ${await nameOf(h.author)} · ${h.score != null ? h.score : ''}`, '/games'));
return out;
},
async tags({ models }) {
const tags = listOf(await models.tags.listTags('all', '').catch(() => []));
if (!tags.length) return [line('Tags: none yet', '/tags')];
const top = tags.slice().sort((a, b) => (Number(b.count) || 0) - (Number(a.count) || 0));
return [line(`Tags in use: ${tags.length}`, '/tags')].concat(few(top, 10).map(t => line(`#${t.name || t.tag || t.id || t}${t.count != null ? ` · ${t.count}` : ''}`, `/tags/${encodeURIComponent(String(t.name || t.tag || t.id || t))}`)));
},
async favorites({ models }) {
const res = await models.favorites.listAll({}).catch(() => null);
const counts = res && res.counts && typeof res.counts === 'object' ? res.counts : {};
const keys = Object.keys(counts).filter(k => Number(counts[k]) > 0);
if (!keys.length) return [line('Favorites: nothing starred yet', '/favorites')];
return keys.map(k => line(`${k}: ${counts[k]}`, `/favorites?filter=${encodeURIComponent(k)}`));
},
async modules({ config }) {
const mods = (config && config.modules) || {};
const on = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] === 'on').map(k => k.slice(0, -3));
const off = Object.keys(mods).filter(k => k.endsWith('Mod') && mods[k] !== 'on').map(k => k.slice(0, -3));
return [line(`Enabled modules (${on.length}): ${on.join(', ')}`, '/modules'), line(`Disabled modules (${off.length}): ${off.join(', ') || 'none'}`, '/modules')];
},
async help() {
return [
line('I answer from the live data of this node: your funds and ECO value, your UBI and karma, your agenda, events, tasks, transfers, who follows you, your inbox and mentions, the government, emergencies, jobs, market, shops, school, projects, housing, industry, campaigns, logistics, calendars, votes, wiki, chats, mailing lists, podcasts, media, blogs, reports, games, tags, favorites, the modules and the network status.', '/ai'),
line('Anything else I answer with the model, helped by the answers the network approved. Rate my answers with the stars so they are shared and I learn.', '/ai')
];
}
});
const run = async (key, deps) => {
const runner = RUNNERS[key];
if (!runner) return null;
try { return await runner(deps); } catch (_) { return null; }
};
module.exports = { INTENTS, detect, detectByKeywords, run, RUNNERS };

2677
src/AI/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
src/AI/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@krakenslab/oasis-ai",
"version": "1.0.0",
"private": true,
"description": "AI stack of Oasis (assistant and embeddings); installed only when AI features are enabled",
"dependencies": {
"@xenova/transformers": "^2.17.2",
"onnxruntime-node": "1.19.2"
},
"optionalDependencies": {
"node-llama-cpp": "^3.10.0"
},
"overrides": {
"onnxruntime-node": "1.19.2",
"onnxruntime-common": "1.19.2",
"onnxruntime-web": "1.19.2"
}
}

275
src/AI/routes_index.js Normal file
View file

@ -0,0 +1,275 @@
const path = require('path')
const fs = require('fs')
const ROUTES = [
{ path: '/blogs', mod: 'blogsMod', description: 'blogs, blog posts, articles, written entries, long posts, public writing' },
{ path: '/polls', mod: 'pollsMod', description: 'polls, survey, ask a question with options, multiple choice, anonymous poll, count answers' },
{ path: '/polls?filter=CREATE', mod: 'pollsMod', description: 'create a poll, new survey, ask the network with options' },
{ path: '/blogs?filter=CREATE', mod: 'blogsMod', description: 'write a blog, publish a blog, compose an article, new blog entry' },
{ path: '/blogs?filter=TOP', mod: 'blogsMod', description: 'best blogs, most commented blogs, most valued blog entries' },
{ path: '/feed', mod: 'feedMod', description: 'feed, microblog, opinions, share thoughts, vote on posts, refeeds' },
{ path: '/forum', mod: 'forumMod', description: 'forum, discussions, threads, debates, conversation by category' },
{ path: '/inhabitants', mod: null, description: 'inhabitants, users, people, profiles, contacts, follow, block' },
{ path: '/inhabitants?filter=SUGGESTED', mod: null, description: 'suggested inhabitants, recommendations, who to follow, similar people, people you might know, friend suggestions' },
{ path: '/data', mod: null, description: 'matches, match skills, cross-data, big data of the network, affinities, cohesion coefficient, who shares my expertise, skill overlap, algorithmic suggestions' },
{ path: '/data?filter=INHABITANTS', mod: null, description: 'people with the same skills, professional matches, find collaborators' },
{ path: '/data?filter=JOBS', mod: null, description: 'jobs that match my skills, work suited to my profile' },
{ path: '/inhabitants?filter=CVs', mod: null, description: 'curriculums, CVs, resumes, professional profiles, people with experience, find expertise' },
{ path: '/inhabitants?filter=TOP%20KARMA', mod: null, description: 'top karma, most active inhabitants, highest reputation, leaderboard' },
{ path: '/inhabitants?filter=TOP%20ECO', mod: null, description: 'top eco, most ecological, least carbon footprint, sustainable users, efficient inhabitants' },
{ path: '/inhabitants?filter=TOP%20ACTIVITY', mod: null, description: 'top activity, most recently active inhabitants, fresh users, recently online' },
{ path: '/inhabitants?filter=contacts', mod: null, description: 'my contacts, who I follow, my network, friends list, mutuals' },
{ path: '/inhabitants?filter=GALLERY', mod: null, description: 'gallery of inhabitants, all avatars, visual list, photos' },
{ path: '/tribes', mod: 'tribesMod', description: 'tribes, groups, communities, private rooms, sub-tribes, governance, public tribes, browse all visible tribes' },
{ path: '/tribes?filter=mine', mod: 'tribesMod', description: 'my tribes, tribes I created, tribes I authored, my groups' },
{ path: '/tribes?filter=membership', mod: 'tribesMod', description: 'tribes I am a member of, my memberships, joined tribes, where I belong' },
{ path: '/larp', mod: 'larpMod', description: 'larp, role playing, nine houses, academia, solaris, arrakis, terraverde, unsystem, dogma, helix, quark, hermandad, governance cycle, ruling house, will test' },
{ path: '/larp?filter=houses', mod: 'larpMod', description: 'list of houses, all houses, browse houses, every house, choose a house, house directory, house grid' },
{ path: '/larp?filter=rules', mod: 'larpMod', description: 'larp faq, rules, governance wall, starting house, will test, invitation code, larp emblem' },
{ path: '/larp/academia', mod: 'larpMod', description: 'ACADEMIA house, aca, education, teachers, coordinators, newcomers, talent discovery, selection process, nosce te ipsum, take will test, leave house, return to academia' },
{ path: '/larp/solaris', mod: 'larpMod', description: 'SolarIS house, sol, governance, law, diplomacy, politics, rulers, lawyers, diplomats, communication, mediation' },
{ path: '/larp/arrakis', mod: 'larpMod', description: 'ARRakis house, arr, engineers, scientists, technicians, technology, invention, problem solving, building, prototypes, repairs, automation' },
{ path: '/larp/terraverde', mod: 'larpMod', description: 'TERRA.VErDE house, ter, farmers, ecologists, doctors, nutritionists, healthcare, food, nature, climate, biosphere, regeneration' },
{ path: '/larp/unsystem', mod: 'larpMod', description: 'UNSYSTem house, uns, chaos agents, trolls, punks, sabotage, tactical chaos, provocation, anti-system' },
{ path: '/larp/dogma', mod: 'larpMod', description: 'DogmA house, dog, thinkers, journalists, philosophers, information control, knowledge, archive, narratives, ai, memory' },
{ path: '/larp/helix', mod: 'larpMod', description: 'HeliX house, hlx, clowns, influencers, musicians, priests, entertainment, culture, humor, celebration, ritual, joy, festival' },
{ path: '/larp/quark', mod: 'larpMod', description: 'QuarK house, quk, athletes, soldiers, street people, protection, security, defense, survival, family, mutual aid' },
{ path: '/larp/hermandad', mod: 'larpMod', description: 'HERmanDAD house, hrm, architects, builders, investors, industrialists, construction, development, sustainability, logistics, production, supply chain' },
{ path: '/larp/test', mod: 'larpMod', description: 'will test, psychological test, take house test, assigned to house, find my house, profile questions' },
{ path: '/chats', mod: 'chatsMod', description: 'chats, messaging, encrypted rooms, group conversations' },
{ path: '/pads', mod: 'padsMod', description: 'pads, collaborative editor, shared notes, encrypted documents' },
{ path: '/rooms', mod: 'roomsMod', description: 'rooms, audio rooms, voice rooms, group audio, talk together, live conversation, salas, salas de audio' },
{ path: '/wiki', mod: 'wikiMod', description: 'wiki, knowledge base, shared pages, articles, documentation, glossary, versioned pages' },
{ path: '/campaigns', mod: 'campaignsMod', description: 'campaigns, petitions, signatures, sign a petition, collect signatures, change.org, goal, supporters' },
{ path: '/podcasts', mod: 'podcastsMod', description: 'podcasts, podcast channels, episodes, audio series, video series, listen, subscribe to a podcast, download a whole podcast' },
{ path: '/logistics', mod: 'logisticsMod', description: 'logistics, trips, rides, carpool, ride sharing, shipments, deliveries, carry goods, move something, transport, book a seat, route ratings' },
{ path: '/mailing', mod: 'mailingMod', description: 'mailing lists, mailing list, mailman, newsletter, list of subscribers, write to a list, list archive, threads' },
{ path: '/emergencies', mod: 'emergenciesMod', description: 'emergencies, alerts, warnings, weather warning, power outage, water cut, health alert, security, lost and found, neighborhood notice' },
{ path: '/calendars', mod: 'calendarsMod', description: 'calendar, events by date, schedule, reminders, recurring dates' },
{ path: '/maps', mod: 'mapsMod', description: 'maps, locations, markers, geography, places' },
{ path: '/events', mod: 'eventsMod', description: 'events, agenda, meetups, gatherings, RSVP' },
{ path: '/agenda', mod: 'agendaMod', description: 'agenda, scheduled items, upcoming, my dates, my tasks events transfers projects jobs market reports tribes' },
{ path: '/agenda?filter=discarded', mod: 'agendaMod', description: 'discarded agenda items, archived agenda, removed from agenda, hidden tasks' },
{ path: '/tasks', mod: 'tasksMod', description: 'tasks, todo, assignments, work items, priorities' },
{ path: '/projects', mod: 'projectsMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
{ path: '/school', mod: 'schoolMod', description: 'school, courses, classes, lessons, learning, teach, study, education, enroll, students, certificates, diploma pdf, exams, course invite code, join course with code' },
{ path: '/industry', mod: 'industryMod', description: 'industry, facilities, factories, workshops, means of manufacturing, commons, cooperatives, blueprints, builds' },
{ path: '/industry?filter=BLUEPRINTS', mod: 'industryMod', description: 'blueprints, designs, recipes, open hardware designs, copyleft designs, bill of materials, materials list' },
{ path: '/industry?filter=BUILDS', mod: 'industryMod', description: 'builds, manufacturing runs, fabrication orders, batches, build status, contributions to a build' },
{ path: '/industry?filter=MEMBER', mod: 'industryMod', description: 'facilities I belong to, my industry memberships, where I am a worker' },
{ path: '/industry?filter=MINE', mod: 'industryMod', description: 'facilities I steward, industries I created' },
{ path: '/industry?filter=ACTIVE', mod: 'industryMod', description: 'working facilities, active factories, industries manufacturing right now' },
{ path: '/industry?filter=PAUSED', mod: 'industryMod', description: 'paused facilities, halted manufacturing, stopped industries' },
{ path: '/industry?filter=DISSOLVED', mod: 'industryMod', description: 'dissolved facilities, closed factories, ended cooperatives' },
{ path: '/industry?filter=RULES', mod: 'industryMod', description: 'industry rules, how facilities work, stewardship, quorum, majority, karma shares, distribution' },
{ path: '/housing', mod: 'housingMod', description: 'housing, homes, flats, apartments, rooms, lodging, accommodation, real estate, places to live' },
{ path: '/housing?filter=SALE', mod: 'housingMod', description: 'homes for sale, property for sale, real estate on sale, houses being sold' },
{ path: '/housing?filter=RENT', mod: 'housingMod', description: 'places to rent, rentals, monthly rent, flat to let, room to rent' },
{ path: '/housing?filter=COUCHSURFING', mod: 'housingMod', description: 'couchsurfing, free stay, hospitality, host me, sofa to sleep, guest room offered for free' },
{ path: '/housing?filter=REQUESTED', mod: 'housingMod', description: 'places I requested, my housing requests, stays I asked for' },
{ path: '/housing?filter=MINE', mod: 'housingMod', description: 'my places, housing listings I published, my properties' },
{ path: '/jobs', mod: 'jobsMod', description: 'jobs, work, hiring, salaries, vacancies, applications' },
{ path: '/market', mod: 'marketMod', description: 'market, marketplace, buy, sell, items, auctions, ECO' },
{ path: '/shops', mod: 'shopsMod', description: 'shops, stores, products, ecommerce, vendors' },
{ path: '/banking', mod: 'bankingMod', description: 'banking, wallet, ECO balance, send money, transfers, payments, UBI claim, karma score, eco tax penalty, ECOin value' },
{ path: '/transfers', mod: 'transfersMod', description: 'transfers, payments, money movements, ECO transactions, history, smart contracts, contract PDF, export contract' },
{ path: '/wallet', mod: 'walletMod', description: 'wallet, ECOin address, send and receive, QR code, balance' },
{ path: '/parliament', mod: 'parliamentMod', description: 'parliament, governance, government, proposals, laws, leaders, voting' },
{ path: '/courts', mod: 'courtsMod', description: 'courts, judges, accusations, mediators, justice, disputes' },
{ path: '/votes', mod: 'votesMod', description: 'votes, votations, ballots, decisions, polling, voting, polls, surveys, multi-option votes' },
{ path: '/opinions', mod: 'opinionsMod', description: 'opinions, reactions, ratings, sentiment, expressing views, interesting useful funny boring sad joyful angry confused inspiring frustrating curious sympathetic challenged surprised exited categories' },
{ path: '/trending', mod: 'trendingMod', description: 'trending, popular, hot, top voted, what is being discussed, most opinions, top spread, viral content' },
{ path: '/activity', mod: null, description: 'activity, recent actions, what is happening, my history, others actions, feed votes, opinions activity, spread activity' },
{ path: '/activity?filter=mine', mod: null, description: 'my activity, my actions, what I did, my history, what I published' },
{ path: '/activity?filter=today', mod: null, description: 'today activity, last 24h, recent today' },
{ path: '/reports', mod: 'reportsMod', description: 'reports, bug reports, abuse, incidents, severity, confirmations' },
{ path: '/audios', mod: 'audiosMod', description: 'audios, music, podcasts, voice recordings, sound files' },
{ path: '/audios?filter=bcs', mod: 'audiosMod', description: 'BCS audios, blockchain sonification audios, melody compositions published by inhabitants, transcode source audio, embedded steganography' },
{ path: '/audios?filter=favorites', mod: 'audiosMod', description: 'favorite audios, starred audios, saved music' },
{ path: '/videos', mod: 'videosMod', description: 'videos, films, clips, recordings, watch' },
{ path: '/images', mod: 'imagesMod', description: 'images, photos, pictures, gallery, memes' },
{ path: '/documents', mod: 'docsMod', description: 'documents, PDFs, files, papers, references' },
{ path: '/bookmarks', mod: 'bookmarksMod', description: 'bookmarks, links, saved websites, favorites' },
{ path: '/files', mod: 'filesMod', description: 'files, any file type, uploads, downloads, binaries, archives' },
{ path: '/torrents', mod: 'torrentsMod', description: 'torrents, magnet links, file sharing, downloads' },
{ path: '/tags', mod: 'tagsMod', description: 'tags, hashtags, topics, categories, labels' },
{ path: '/search', mod: null, description: 'search, find, query, lookup' },
{ path: '/inbox', mod: null, description: 'inbox, notifications, mentions, emergencies, messages addressed to me, received PM' },
{ path: '/inbox?filter=sent', mod: null, description: 'sent messages, outgoing PM, my sent private messages, what I wrote' },
{ path: '/inbox?filter=reminders', mod: null, description: 'reminders, task reminders, calendar reminders, automatic notifications' },
{ path: '/phone', mod: 'phoneMod', description: 'phone, call, calls, voice call, call someone, ring, telephone, voip, audio call, private audio message, PaM, voicemail, voice message, missed calls, call history' },
{ path: '/inbox?filter=notifications', mod: null, description: 'bot notifications, BankingBot, PoliticalBot, MarketBot, notices per bot, unread notices' },
{ path: '/inbox?filter=archived', mod: null, description: 'archived messages, archived PMs, put away conversations' },
{ path: '/pm', mod: null, description: 'private messages, direct messages, DMs, encrypted PM, compose new PM' },
{ path: '/mentions', mod: null, description: 'mentions, who mentioned me, tagged me, my mentions, posts mentioning me, tribe mentions' },
{ path: '/games', mod: 'gamesMod', description: 'games, play, mini-games, scoring, fun' },
{ path: '/ai', mod: 'aiMod', description: '42, AI assistant, ask the AI, chat with AI, oasis assistant, artificial intelligence, ai help, ai answers' },
{ path: '/pixelia', mod: 'pixeliaMod', description: 'pixelia, pixel canvas, draw, collaborative pixel art' },
{ path: '/cv', mod: null, description: 'cv, curriculum, resume, my profile, skills, experiences' },
{ path: '/backup', mod: 'backupMod', description: 'backup, export keys, import keys, recovery kit, full copy of my data, restore on another device, encrypted backup' },
{ path: '/cipher', mod: 'cipherMod', description: 'cipher, encrypt, decrypt, password, vault' },
{ path: '/dev', mod: 'devMod', description: 'source code, oasis codebase, file tree, browse files, read the code' },
{ path: '/dev/search', mod: 'devMod', description: 'grep the codebase, find a function, find a string in source files' },
{ path: '/dev/map', mod: 'devMod', description: 'module map, which file implements a module, models views routes tests' },
{ path: '/stats', mod: null, description: 'stats, statistics, KPIs, metrics, dashboard, carbon footprint' },
{ path: '/peers', mod: null, description: 'peers, connections, network, nodes, who am I connected to, LAN, refresh discovery, export peer list, import peer list, remove idle' },
{ path: '/invites', mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB, federations, federated networks, import pubs, export pubs, unreachable pubs, redeem tribe invite code, join tribe by code, redeem course code, join school course by code, accept invitation' },
{ path: '/graphos', mod: 'graphosMod', description: 'graphos, network map, visualization, relationship graph' },
{ path: '/modules', mod: null, description: 'modules, features, enable disable plugins, settings' },
{ path: '/settings', mod: null, description: 'settings, preferences, language, theme, configuration' },
{ path: '/favorites', mod: 'favoritesMod', description: 'favorites, starred items, saved content, my bookmarks audios videos images documents files pads rooms chats calendars maps torrents' },
{ path: '/favorites?filter=recent', mod: 'favoritesMod', description: 'recent favorites, latest starred items' },
{ path: '/logs', mod: 'logsMod', description: 'logs, life log, personal records, journal, experiences' },
{ path: '/melody', mod: 'melodyMod', description: 'melody, sound of my blockchain, music, generate sound, audio of blocks, sonification, publish BCS, hidden message, steganography, embed text in waveform' },
{ path: '/melody?filter=all', mod: 'melodyMod', description: 'BCS from other inhabitants, all blockchain compositions, listen to peers melodies, transcode audio of others' },
{ path: '/profile', mod: null, description: 'my profile, my avatar, my page, my identity, my data, my clearnet link' },
{ path: '/profile/edit', mod: null, description: 'edit profile, edit avatar, change name, change description, visibility prefs, sensors, eco tax toggle, clearnet toggle, GPG fingerprint' },
{ path: '/blockexplorer', mod: null, description: 'blockexplorer, blockchain, blockchain explorer, blocks, ledger, chain, carbon footprint per block, chain history' },
{ path: '/fediverse', mod: 'fediverseMod', description: 'multiverse, fediverse, mastodon, telegram, activitypub, external social networks, federated timeline, telegram chats and channels, cross-post, fediverse handle, follow external accounts' },
{ path: '/stats?filter=ALL', mod: null, description: 'global stats, network kpis, total carbon footprint, total inhabitants, network size' },
{ path: '/stats?filter=MINE', mod: null, description: 'my stats, my carbon footprint, my activity numbers, personal kpis' },
{ path: '/tribes/create', mod: 'tribesMod', description: 'create tribe, new tribe, new group, start community, create private room' },
{ path: '/chats', mod: 'chatsMod', description: 'create chat, new chat, start conversation, new encrypted room' },
{ path: '/pads', mod: 'padsMod', description: 'create pad, new pad, new collaborative document, start shared note' },
{ path: '/rooms?filter=create', mod: 'roomsMod', description: 'create room, new room, open an audio room, start a voice room, crear sala' },
{ path: '/wiki?filter=create', mod: 'wikiMod', description: 'create wiki page, new wiki page, write article, document knowledge' },
{ path: '/campaigns?filter=CREATE', mod: 'campaignsMod', description: 'start a campaign, create a petition, collect signatures for a cause' },
{ path: '/podcasts?filter=CREATE', mod: 'podcastsMod', description: 'create podcast, new podcast channel, start a podcast, publish episodes' },
{ path: '/logistics?filter=CREATE', mod: 'logisticsMod', description: 'offer a ride, request a ride, offer transport, need to move something, publish a shipment, new route' },
{ path: '/mailing?filter=CREATE', mod: 'mailingMod', description: 'create mailing list, new mailing list, open a list, closed encrypted list' },
{ path: '/emergencies?filter=CREATE', mod: 'emergenciesMod', description: 'create emergency, raise emergency, raise alert, warn the network, report outage' },
{ path: '/calendars', mod: 'calendarsMod', description: 'create calendar, new calendar, start schedule' },
{ path: '/maps', mod: 'mapsMod', description: 'create map, new map, new offline map' },
{ path: '/events', mod: 'eventsMod', description: 'create event, new event, schedule meetup' },
{ path: '/projects', mod: 'projectsMod', description: 'create project, new project, start crowdfunding' },
{ path: '/school?filter=create', mod: 'schoolMod', description: 'create course, new course, teach a class, offer lessons' },
{ path: '/industry?filter=CREATE', mod: 'industryMod', description: 'create facility, new factory, new workshop, found a cooperative, means of manufacturing' },
{ path: '/housing?filter=CREATE', mod: 'housingMod', description: 'publish a place, offer accommodation, list a home, new housing listing, host someone' },
{ path: '/jobs', mod: 'jobsMod', description: 'create job, post job offer, new vacancy, hire' },
{ path: '/market', mod: 'marketMod', description: 'create market item, sell something, new auction, list for sale' },
{ path: '/shops', mod: 'shopsMod', description: 'create shop, open store, new vendor, list products' },
{ path: '/tasks', mod: 'tasksMod', description: 'create task, new todo, new assignment' },
{ path: '/reports', mod: 'reportsMod', description: 'create report, file bug, report issue, report abuse' }
]
const CACHE_FILE = path.join(__dirname, 'embeddings', 'routes_cache.json')
let cache = null
const buildCacheKey = () => ROUTES.map(r => r.path + '|' + r.description).join('\n')
const loadCache = () => {
try {
if (!fs.existsSync(CACHE_FILE)) return null
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
if (data.key === buildCacheKey() && Array.isArray(data.entries)) return data.entries
return null
} catch (_) {
return null
}
}
const saveCache = (entries) => {
try {
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
fs.writeFileSync(CACHE_FILE, JSON.stringify({ key: buildCacheKey(), entries }, null, 2), 'utf8')
} catch (_) {}
}
const ensureIndex = async ({ embed }) => {
if (cache) return cache
const cached = loadCache()
if (cached) { cache = cached; return cache }
const entries = []
for (const r of ROUTES) {
const vec = await embed(r.description)
if (!vec) return null
entries.push({ path: r.path, mod: r.mod, vector: vec })
}
cache = entries
saveCache(entries)
return cache
}
const dot = (a, b) => {
let s = 0
const n = Math.min(a.length, b.length)
for (let i = 0; i < n; i++) s += a[i] * b[i]
return s
}
const descriptionByPath = (p) => {
const r = ROUTES.find(x => x.path === p)
return r ? r.description : ''
}
const dedupeByPath = (list) => {
const seen = new Set()
const out = []
for (const item of list) {
if (seen.has(item.path)) continue
seen.add(item.path)
out.push(item)
}
return out
}
const resolveBest = async (queryVector, { isModuleEnabled, threshold = 0.4, embed } = {}) => {
const idx = await ensureIndex({ embed })
if (!idx) return null
let best = null
for (const entry of idx) {
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
const score = dot(queryVector, entry.vector)
if (!best || score > best.score) best = { path: entry.path, score }
}
if (!best || best.score < threshold) return null
return best
}
const resolveTopK = async (queryVector, { isModuleEnabled, threshold = 0.35, embed } = {}, k = 5) => {
const idx = await ensureIndex({ embed })
if (!idx) return []
const all = []
for (const entry of idx) {
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
const score = dot(queryVector, entry.vector)
if (score < threshold) continue
all.push({ path: entry.path, mod: entry.mod, score, description: descriptionByPath(entry.path) })
}
all.sort((a, b) => b.score - a.score)
return dedupeByPath(all).slice(0, Math.max(1, k|0))
}
const KEYWORD_STOPWORDS = new Set([
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into', 'about', 'want', 'show', 'see', 'find', 'give', 'get', 'go', 'my', 'me', 'to', 'of', 'in', 'on', 'at', 'an', 'is', 'are', 'do', 'does',
'week', 'year', 'month', 'today', 'tomorrow', 'now', 'next', 'last',
'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'de', 'del', 'este', 'esta', 'esto', 'estos', 'estas', 'ese', 'esa', 'eso', 'que', 'quiero', 'ver', 'buscar', 'dame', 'ir', 'mi', 'mis', 'lo', 'se', 'al', 'en', 'con', 'para', 'por',
'semana', 'mes', 'hoy', 'ahora', 'proximo', 'próximo', 'proxima', 'próxima', 'ultimo', 'último', 'ultima', 'última',
'els', 'les', 'uns', 'unes', 'dels', 'aquest', 'aquesta', 'aquests', 'aquestes', 'això', 'aquell', 'aquella', 'vull', 'veure', 'cercar', 'anar', 'meu', 'meva', 'meus', 'meves', 'amb', 'per', 'als', 'on', 'què', 'com',
'setmana', 'avui', 'ara', 'proper', 'propera', 'darrer', 'darrera',
'os', 'as', 'unha', 'unhas', 'do', 'da', 'dos', 'das', 'isto', 'iso', 'estes', 'quero', 'miña', 'miñas', 'meu', 'meus', 'no', 'na', 'nos', 'nas', 'co', 'coa', 'onde', 'como',
'hoxe', 'agora'
])
const resolveKeywordTopK = ({ isModuleEnabled } = {}, query, k = 8) => {
let tokens = String(query || '').toLowerCase().split(/[^a-z0-9À-ſ]+/).filter(t => t && t.length >= 2)
const meaningful = tokens.filter(t => !KEYWORD_STOPWORDS.has(t))
if (meaningful.length) tokens = meaningful
if (!tokens.length) return []
const all = []
for (const entry of ROUTES) {
if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
const haystack = (entry.description || '').toLowerCase() + ' ' + entry.path.toLowerCase()
let hits = 0
for (const t of tokens) {
if (haystack.includes(t)) hits += 1
}
if (hits === 0) continue
all.push({ path: entry.path, mod: entry.mod, score: hits / tokens.length, description: entry.description })
}
all.sort((a, b) => b.score - a.score)
return dedupeByPath(all).slice(0, Math.max(1, k|0))
}
module.exports = { ROUTES, ensureIndex, resolveBest, resolveTopK, resolveKeywordTopK }

77
src/AI/semantic_search.js Normal file
View file

@ -0,0 +1,77 @@
const fs = require('fs')
const { readTyped } = require('../models/typed_log')
const { buildValidatedTombstoneSet } = require('../models/tombstone_validator')
const { versionGroups, versionEntry } = require('../backend/dedupe')
const VECTORS_FILE = 'AI-search-vectors.json'
const MAX_ITEMS = 4000
const MIN_SCORE = 0.42
const TEXT_MAX = 400
const TYPES = ['post', 'event', 'job', 'project', 'market', 'shop', 'shopProduct', 'tribe', 'task', 'wikiPage', 'document', 'audio', 'video', 'image', 'bookmark', 'forum', 'campaign', 'emergency', 'podcast', 'schoolCourse', 'housing', 'logisticsRoute', 'mailingList', 'room', 'curriculum', 'blog', 'report', 'poll', 'torrent', 'file']
const vectorsPath = () => { try { return require('../configs/state-manager').statePath(VECTORS_FILE) } catch (_) { return null } }
const readVectors = () => { try { const j = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); return j && typeof j === 'object' ? j : {} } catch (_) { return {} } }
const writeVectors = (map) => { try { const p = vectorsPath(); if (p) fs.writeFileSync(p, JSON.stringify(map)) } catch (_) {} }
const squash = (s) => String(s || '').replace(/\s+/g, ' ').trim()
const textOf = (c) => {
if (!c || typeof c !== 'object') return ''
if (c.encryptedPayload || c.encryptedQuestion) return ''
const title = squash(c.title || c.name || c.question || c.subject || c.concept || '')
const body = squash(c.description || c.text || c.body || c.contentWarning || '')
const tags = Array.isArray(c.tags) ? c.tags.map(t => String(t || '')).join(' ') : ''
return `${title} ${body} ${tags}`.trim().slice(0, TEXT_MAX)
}
let indexing = null
const indexOnce = async (ssb, { embed, limit }) => {
const msgs = await readTyped(ssb, [...TYPES, 'tombstone'], { limit }).catch(() => [])
const tomb = buildValidatedTombstoneSet(msgs)
const content = msgs.filter(m => m && m.key && m.value && m.value.content && TYPES.includes(m.value.content.type))
const byKey = new Map(content.map(m => [m.key, m]))
const vg = versionGroups(content.map(versionEntry))
const usable = [...vg.roots].map(r => byKey.get(vg.tipOf(r)))
.filter(m => m && !tomb.has(m.key) && textOf(m.value.content))
.sort((a, b) => ((a.value.timestamp || 0) - (b.value.timestamp || 0)))
const newest = usable.slice(-MAX_ITEMS)
const cache = readVectors()
let dirty = false
const items = []
for (const m of newest) {
let v = cache[m.key]
if (!Array.isArray(v)) {
v = await embed(textOf(m.value.content)).catch(() => null)
if (!v) continue
cache[m.key] = v
dirty = true
}
items.push({ msg: m, vector: v })
}
if (dirty) {
const keep = new Set(newest.map(m => m.key))
for (const key of Object.keys(cache)) if (!keep.has(key)) delete cache[key]
writeVectors(cache)
}
return items
}
const search = async (ssb, query, { embed, cosine, limit = 1000, k = 20 } = {}) => {
if (typeof embed !== 'function' || typeof cosine !== 'function' || !ssb) return []
const q = squash(query)
if (!q) return []
const qv = await embed(q).catch(() => null)
if (!qv) return []
if (!indexing) indexing = indexOnce(ssb, { embed, limit }).finally(() => { indexing = null })
const items = await indexing.catch(() => [])
const scored = []
for (const it of items) {
const s = cosine(qv, it.vector)
if (s >= MIN_SCORE) scored.push({ msg: it.msg, score: s })
}
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, k)
}
module.exports = { search, textOf, TYPES, MIN_SCORE }

74
src/backend/accounts.js Normal file
View file

@ -0,0 +1,74 @@
/**
* Identidades locales.
*
* Cada identidad vive en su propio directorio ~/.<cuenta>, con su secret, su base
* de datos y sus blobs. Cual esta activa lo decide ssb_config.js al arrancar:
* primero OASIS_ACCOUNT, y si no, el fichero que escribe setActive().
*
* Aqui no se cambia de identidad en caliente: medio Oasis captura la referencia al
* sbot al cargarse, asi que cambiarla con el proceso vivo dejaria modulos hablando
* con la identidad anterior. El cambio se aplica al reiniciar.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// mismo criterio que ssb_config.js: este nombre acaba en rutas de fichero
const ACCOUNT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/;
const ACTIVE_FILE = path.join(__dirname, '..', 'configs', 'active-account');
const isValid = (name) => typeof name === 'string' && ACCOUNT_RE.test(name);
/** Cuenta anotada como activa, o null si no hay ninguna anotada. */
const readActive = () => {
try {
const raw = fs.readFileSync(ACTIVE_FILE, 'utf8').trim();
return isValid(raw) ? raw : null;
} catch (_) { return null; }
};
/** Anota cual sera la cuenta activa en el proximo arranque. */
const setActive = (name) => {
if (!isValid(name)) throw new Error('nombre de cuenta no valido');
fs.mkdirSync(path.dirname(ACTIVE_FILE), { recursive: true });
fs.writeFileSync(ACTIVE_FILE, name, { mode: 0o600 });
return name;
};
/** Directorio de una cuenta. */
const dirOf = (name) => path.join(os.homedir(), `.${name}`);
/**
* Identidades que existen en el disco: directorios ~/.<algo> con un secret dentro.
* El secret es lo que hace que un directorio sea una identidad y no otra cosa.
*/
const list = () => {
let entries = [];
try {
entries = fs.readdirSync(os.homedir(), { withFileTypes: true });
} catch (_) { return []; }
return entries
.filter((e) => e.isDirectory() && e.name.startsWith('.'))
.map((e) => e.name.slice(1))
.filter(isValid)
.filter((name) => {
try { return fs.statSync(path.join(dirOf(name), 'secret')).isFile(); }
catch (_) { return false; }
})
.sort();
};
/** Identidad publica (@...) de una cuenta, leyendo su secret. Null si no se puede. */
const feedIdOf = (name) => {
if (!isValid(name)) return null;
try {
const raw = fs.readFileSync(path.join(dirOf(name), 'secret'), 'utf8');
const clean = raw.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
const id = JSON.parse(clean).id;
return typeof id === 'string' ? id : null;
} catch (_) { return null; }
};
module.exports = { isValid, list, readActive, setActive, feedIdOf, dirOf, ACTIVE_FILE };

File diff suppressed because it is too large Load diff

View file

@ -5,19 +5,202 @@ const ssb = require("../client/gui");
const config = require("../server/SSB_server").config;
const cooler = ssb({ offline: config.offline });
let sharp;
try {
sharp = require("sharp");
} catch (e) {
}
let sharpLib;
const getSharp = () => {
if (sharpLib === undefined) {
try { sharpLib = require('../server/node_modules/sharp'); } catch (_) { sharpLib = null; }
}
return sharpLib;
};
/**
* Quitar metadatos de una imagen SIN sharp.
*
* sharp no se empaqueta en Android (lleva binarios nativos y el bundle del movil
* va sin ninguno), asi que alli esta funcion devolvia la imagen intacta y las
* fotos subian con su EXIF entero, ubicacion GPS incluida.
*
* Esto recorre el contenedor y tira los bloques de metadatos, sin decodificar ni
* recomprimir la imagen: los pixeles se quedan byte a byte como estaban.
*
* De la orientacion se guarda solo eso, la orientacion. Sin sharp no se pueden
* girar los pixeles, y tirar la etiqueta dejaria las fotos verticales tumbadas;
* asi que se reconstruye un bloque EXIF minimo con esa unica etiqueta, que no
* dice nada de quien hizo la foto ni donde.
*/
// JPEG: cada segmento es FF <marca> <longitud:2> <datos>. Se tiran los que llevan
// metadatos y se dejan JFIF (APP0) y el perfil de color (APP2), que afectan a como
// se ve la imagen.
const JPEG_DROP = new Set([
0xE1, // APP1 Exif y XMP
0xE5, // APP5
0xE6, // APP6
0xEC, // APP12 Ducky/Picture Info
0xED, // APP13 IPTC / Photoshop
0xEE, // APP14 Adobe
0xFE // COM comentario
]);
const jpegOrientation = (seg) => {
// seg empieza en "Exif\0\0"; detras va una cabecera TIFF con la IFD0
if (seg.length < 14 || seg.toString("latin1", 0, 6) !== "Exif\0\0") return 0;
const tiff = seg.subarray(6);
const le = tiff.toString("latin1", 0, 2) === "II";
const u16 = (o) => (le ? tiff.readUInt16LE(o) : tiff.readUInt16BE(o));
const u32 = (o) => (le ? tiff.readUInt32LE(o) : tiff.readUInt32BE(o));
if (u16(2) !== 0x002A) return 0;
const ifd0 = u32(4);
if (ifd0 + 2 > tiff.length) return 0;
const n = u16(ifd0);
for (let i = 0; i < n; i++) {
const e = ifd0 + 2 + i * 12;
if (e + 12 > tiff.length) break;
if (u16(e) === 0x0112) { // Orientation
const v = u16(e + 8);
return v >= 1 && v <= 8 ? v : 0;
}
}
return 0;
};
/** Bloque APP1 de 32 bytes con la orientacion y nada mas. */
const exifOnlyOrientation = (orientation) => {
const b = Buffer.alloc(32);
b.write("Exif\0\0", 0, "latin1");
b.write("II", 6, "latin1");
b.writeUInt16LE(0x002A, 8);
b.writeUInt32LE(8, 10); // la IFD0 empieza justo detras de la cabecera TIFF
b.writeUInt16LE(1, 14); // una sola entrada
b.writeUInt16LE(0x0112, 16); // Orientation
b.writeUInt16LE(3, 18); // SHORT
b.writeUInt32LE(1, 20); // un valor
b.writeUInt16LE(orientation, 24);
b.writeUInt32LE(0, 28); // no hay mas IFDs
return b;
};
const stripJpeg = (buf) => {
if (buf.length < 4 || buf[0] !== 0xFF || buf[1] !== 0xD8) return null;
const out = [buf.subarray(0, 2)];
let orientation = 0;
let i = 2;
let scan = false;
while (i + 4 <= buf.length) {
if (buf[i] !== 0xFF) return null; // no es un JPEG sano: no tocarlo
const marker = buf[i + 1];
if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; }
if (marker === 0xDA) { out.push(buf.subarray(i)); scan = true; break; } // datos comprimidos: hasta el final
const len = buf.readUInt16BE(i + 2);
if (len < 2 || i + 2 + len > buf.length) return null;
const seg = buf.subarray(i + 4, i + 2 + len);
if (marker === 0xE1 && !orientation) orientation = jpegOrientation(seg);
if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len));
i += 2 + len;
}
// sin datos de imagen (SOS) es un JPEG cortado: devolver la cabecera sola subiria una foto vacia
if (!scan) return null;
if (orientation > 1) {
const exif = exifOnlyOrientation(orientation);
const head = Buffer.alloc(4);
head.writeUInt16BE(0xFFE1, 0);
head.writeUInt16BE(exif.length + 2, 2);
out.splice(1, 0, head, exif);
}
return Buffer.concat(out);
};
// PNG: cadena de bloques <longitud:4> <tipo:4> <datos> <crc:4>.
const PNG_DROP = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME", "dSIG"]);
const stripPng = (buf) => {
const SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
if (buf.length < 8 || !buf.subarray(0, 8).equals(SIG)) return null;
const out = [buf.subarray(0, 8)];
let i = 8;
while (i + 8 <= buf.length) {
const len = buf.readUInt32BE(i);
const type = buf.toString("latin1", i + 4, i + 8);
const end = i + 12 + len;
if (end > buf.length) return null;
if (!PNG_DROP.has(type)) out.push(buf.subarray(i, end));
i = end;
if (type === "IEND") break;
}
return Buffer.concat(out);
};
// WebP: contenedor RIFF con bloques <tipo:4> <longitud:4> <datos> (+1 de relleno
// si la longitud es impar).
const stripWebp = (buf) => {
if (buf.length < 12) return null;
if (buf.toString("latin1", 0, 4) !== "RIFF" || buf.toString("latin1", 8, 12) !== "WEBP") return null;
const out = [];
let i = 12;
while (i + 8 <= buf.length) {
const type = buf.toString("latin1", i, i + 4);
const len = buf.readUInt32LE(i + 4);
const end = i + 8 + len + (len % 2);
if (end > buf.length) return null;
if (type !== "EXIF" && type !== "XMP ") out.push(buf.subarray(i, end));
i = end;
}
const body = Buffer.concat(out);
const head = Buffer.alloc(12);
head.write("RIFF", 0, "latin1");
head.writeUInt32LE(body.length + 4, 4);
head.write("WEBP", 8, "latin1");
return Buffer.concat([head, body]);
};
// Formatos que pueden llevar EXIF (GPS incluido) y que aqui no se saben limpiar:
// HEIC/HEIF/AVIF (caja ftyp) y TIFF. Antes subian tal cual, con la ubicacion.
const isUncleanable = (buf) => {
if (buf.length >= 12 && buf.toString("latin1", 4, 8) === "ftyp") {
return /^(heic|heix|hevc|hevx|heim|heis|mif1|msf1|avif|avis)$/.test(buf.toString("latin1", 8, 12));
}
return buf.length >= 4 && (buf.toString("latin1", 0, 4) === "II*\0" || buf.toString("latin1", 0, 4) === "MM\0*");
};
const looksCleanable = (buf) =>
(buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xD8) ||
(buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) ||
(buf.length >= 12 && buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP");
const unsafe = () => {
let msg = "This image could not be cleaned of its metadata, so it has not been uploaded.";
try { msg = require("../views/main_views").i18n.imageMetadataUnsafe || msg; } catch (_) {}
const err = new Error(msg);
err.status = 400;
return err;
};
/**
* Limpia segun el formato. Si es JPEG/PNG/WebP y no se puede limpiar, o es un
* formato con EXIF que no sabemos limpiar, LANZA: es mejor no subir la foto que
* subirla con la ubicacion. Formatos sin metadatos de ese tipo (BMP, SVG, ICO...)
* pasan tal cual.
*/
const stripMetadataPure = (buffer) => {
if (isUncleanable(buffer)) throw unsafe();
if (!looksCleanable(buffer)) return buffer;
let out = null;
try { out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer); } catch (_) { out = null; }
if (!out || !out.length) throw unsafe();
return out;
};
const stripImageMetadata = async (buffer) => {
if (typeof sharp !== "function") return buffer;
try {
return await sharp(buffer).rotate().toBuffer();
} catch {
return buffer;
// Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
// orientacion girando los pixeles de verdad.
const sharp = getSharp();
if (sharp) {
try {
return await sharp(buffer).rotate().toBuffer();
} catch {
return stripMetadataPure(buffer);
}
}
return stripMetadataPure(buffer);
};
const PDF_METADATA_KEYS = [
@ -47,7 +230,7 @@ const stripPdfMetadata = (buffer) => {
}
};
const MAX_BLOB_SIZE = 50 * 1024 * 1024;
const MAX_BLOB_SIZE = 75 * 1024 * 1024;
class FileTooLargeError extends Error {
constructor(fileName, fileSize) {
@ -58,25 +241,32 @@ class FileTooLargeError extends Error {
}
}
const handleBlobUpload = async function (ctx, fileFieldName) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
return null;
}
const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac'];
const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex'];
const blobUpload = ctx.request.files[fileFieldName];
if (!blobUpload) return null;
const oggMime = (buffer) => {
const head = buffer.slice(0, 65536);
for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg';
for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg';
return 'video/ogg';
};
const sealedRef = (sealed, mime, name) => `/blob/sealed/${encodeURIComponent(sealed.manifestBlobId)}?k=${encodeURIComponent(sealed.key)}&t=${encodeURIComponent(mime)}&n=${encodeURIComponent(name)}`;
const storeUploadedFile = async function (blobUpload, seal = null) {
if (!blobUpload || !blobUpload.filepath) return null;
let data = await promisesFs.readFile(blobUpload.filepath);
if (data.length === 0) return null;
if (data.length > MAX_BLOB_SIZE) {
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length);
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length);
}
const EXTENSION_MIME_MAP = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
'.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
@ -93,9 +283,12 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
blob.mime = null;
}
if (!blob.mime && blob.name) {
if (blob.mime === 'application/ogg') blob.mime = oggMime(data);
const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']);
if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) {
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream';
}
if (!blob.mime) {
@ -108,14 +301,19 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
data = stripPdfMetadata(data);
}
const ssbClient = await cooler.open();
blob.id = await new Promise((resolve, reject) => {
pull(
pull.values([data]),
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
);
});
if (typeof seal === 'function') {
await promisesFs.writeFile(blobUpload.filepath, data);
const sealed = await seal({ filepath: blobUpload.filepath, size: data.length, name: blob.name, mime: blob.mime });
blob.id = sealedRef(sealed, blob.mime, blob.name);
} else {
const ssbClient = await cooler.open();
blob.id = await new Promise((resolve, reject) => {
pull(
pull.values([data]),
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
);
});
}
if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
@ -126,6 +324,25 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
return `\n[${blob.name}](${blob.id})`;
};
const handleBlobUpload = async function (ctx, fileFieldName, seal = null) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
const entry = ctx.request.files[fileFieldName];
const first = Array.isArray(entry) ? entry[0] : entry;
return storeUploadedFile(first, seal);
};
const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return [];
const entry = ctx.request.files[fileFieldName];
const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max));
const out = [];
for (const file of files) {
const stored = await storeUploadedFile(file, seal);
if (stored) out.push(stored);
}
return out;
};
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
return new Promise((resolve, reject) => {
let done = false;
@ -168,6 +385,52 @@ function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
});
}
const contentDisposition = (type, name) => {
const clean = String(name || 'file').replace(/["\r\n\\/]/g, '').trim() || 'file';
const ascii = clean.replace(/[^\x20-\x7e]/g, '_');
if (ascii === clean) return `${type}; filename="${clean}"`;
return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}`;
};
const sendBlobBuffer = (ctx, buffer) => {
const size = buffer.length;
const range = ctx.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
let start = match[1] ? parseInt(match[1], 10) : 0;
let end = match[2] ? parseInt(match[2], 10) : size - 1;
if (Number.isNaN(start) || start < 0) start = 0;
if (Number.isNaN(end) || end >= size) end = size - 1;
if (start > end || start >= size) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
const chunk = buffer.slice(start, end + 1);
ctx.status = 206;
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(chunk.length));
ctx.body = chunk;
} else {
ctx.status = 200;
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(size));
ctx.body = buffer;
}
};
const serveBlob = async function (ctx) {
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
const raw = decodeURIComponent(encodedParam);
@ -207,8 +470,6 @@ const serveBlob = async function (ctx) {
return;
}
const size = buffer.length;
let mime = 'application/octet-stream';
try {
const ft = await FileType.fromBuffer(buffer);
@ -216,7 +477,7 @@ const serveBlob = async function (ctx) {
} catch {}
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
const head = buffer.slice(0, 128).toString('ascii');
const head = buffer.slice(0, 512).toString('ascii');
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
}
@ -229,51 +490,22 @@ const serveBlob = async function (ctx) {
}
}
if (mime === 'application/ogg') mime = oggMime(buffer);
const isSvg = mime === 'image/svg+xml';
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
const disposition = isSvg ? 'attachment' : 'inline';
const wantsDownload = String(ctx.query.download || '') === '1';
const extByMime = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'application/pdf': 'pdf', 'application/x-bittorrent': 'torrent' };
const ext = extByMime[mime] || String(mime.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : (wantsDownload ? `oasis-${safeRaw.replace(/^&/, '').slice(0, 8)}.${ext}` : safeRaw));
const disposition = isSvg || wantsDownload ? 'attachment' : 'inline';
ctx.type = mime;
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
ctx.set('Content-Disposition', contentDisposition(disposition, filename));
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
const range = ctx.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
let start = match[1] ? parseInt(match[1], 10) : 0;
let end = match[2] ? parseInt(match[2], 10) : size - 1;
if (Number.isNaN(start) || start < 0) start = 0;
if (Number.isNaN(end) || end >= size) end = size - 1;
if (start > end || start >= size) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
const chunk = buffer.slice(start, end + 1);
ctx.status = 206;
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(chunk.length));
ctx.body = chunk;
} else {
ctx.status = 200;
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(size));
ctx.body = buffer;
}
sendBlobBuffer(ctx, buffer);
};
module.exports = { handleBlobUpload, serveBlob, FileTooLargeError };
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, stripImageMetadata };

View file

@ -1,22 +1,43 @@
const fs = require("fs");
const path = require("path");
const FILE = path.join(__dirname, "../configs/media-favorites.json");
const storePath = () => require("../configs/state-manager").statePath("content_favorites.json");
const DEFAULT = {
emergencies: [],
audios: [],
blogs: [],
bookmarks: [],
calendars: [],
campaigns: [],
chats: [],
documents: [],
events: [],
forum: [],
housing: [],
images: [],
jobs: [],
logistics: [],
logs: [],
mailing: [],
maps: [],
pads: [],
rooms: [],
podcasts: [],
polls: [],
projects: [],
reports: [],
school: [],
shops: [],
market: [],
shopProducts: [],
tasks: [],
torrents: [],
videos: []
files: [],
transfers: [],
videos: [],
votes: [],
wiki: []
};
const safeArr = (v) => (Array.isArray(v) ? v : []);
@ -39,32 +60,32 @@ const normalize = (raw) => {
const ensureFile = async () => {
try {
await fs.promises.access(FILE);
await fs.promises.access(storePath());
} catch (e) {
const dir = path.dirname(FILE);
const dir = path.dirname(storePath());
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8");
await fs.promises.writeFile(storePath(), JSON.stringify(DEFAULT, null, 2), "utf8");
}
};
const readAll = async () => {
await ensureFile();
try {
const txt = await fs.promises.readFile(FILE, "utf8");
const txt = await fs.promises.readFile(storePath(), "utf8");
return normalize(JSON.parse(txt || "{}"));
} catch (e) {
const fixed = normalize(DEFAULT);
await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8");
await fs.promises.writeFile(storePath(), JSON.stringify(fixed, null, 2), "utf8");
return fixed;
}
};
const writeAll = async (data) => {
const dir = path.dirname(FILE);
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
const dir = path.dirname(storePath());
const tmp = path.join(dir, `.content_favorites.${process.pid}.${Date.now()}.tmp`);
const txt = JSON.stringify(normalize(data), null, 2);
await fs.promises.writeFile(tmp, txt, "utf8");
await fs.promises.rename(tmp, FILE);
await fs.promises.rename(tmp, storePath());
};
const assertKind = (kind) => {
@ -88,6 +109,15 @@ exports.getFavoriteSet = async (kind) => {
return new Set(safeArr(data[k]).map(String));
};
exports.getFavoriteIndex = async () => {
const data = await readAll();
const index = new Map();
for (const kind of Object.keys(DEFAULT)) {
for (const id of safeArr(data[kind])) index.set(String(id), kind);
}
return index;
};
exports.addFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
@ -123,4 +153,4 @@ exports.removeFromFavorites = async (...args) => {
const id = idFromArgs(args);
return exports.removeFavorite(kind, id);
};
exports.storePath = storePath;

View file

@ -49,4 +49,79 @@ const dedupeByPreferring = (items, keyFn, scoreFn) => {
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
};
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm };
const versionGroups = (entries) => {
const byKey = new Map();
for (const e of (entries || [])) if (e && e.key) byKey.set(e.key, e);
const memo = new Map();
const rootOf = (key) => {
if (memo.has(key)) return memo.get(key);
const path = [];
const seen = new Set([key]);
let cur = key;
let root = null;
while (root === null) {
if (memo.has(cur)) { root = memo.get(cur); break; }
const e = byKey.get(cur);
const p = e && e.replaces;
if (!p || !byKey.has(p) || seen.has(p)) { root = cur; break; }
path.push(cur);
seen.add(p);
cur = p;
}
memo.set(cur, root);
for (const k of path) memo.set(k, root);
return root;
};
const isOrphan = (key) => {
const e = byKey.get(key);
return !!(e && e.replaces && !byKey.has(e.replaces));
};
const isForeignCopy = (key) => {
const e = byKey.get(key);
return !!(e && isOrphan(key) && typeof e.claimedAuthor === 'string' && e.claimedAuthor && e.claimedAuthor !== e.author);
};
const groups = new Map();
for (const k of byKey.keys()) {
const r = rootOf(k);
if (!groups.has(r)) groups.set(r, []);
groups.get(r).push(k);
}
const later = (a, b) => (a.seq || 0) > (b.seq || 0) || ((a.seq || 0) === (b.seq || 0) && (a.ts || 0) >= (b.ts || 0));
for (const [r, keys] of Array.from(groups.entries())) {
if (!isForeignCopy(r)) continue;
const owner = byKey.get(r).claimedAuthor;
let first = null;
for (const k of keys) { const e = byKey.get(k); if (e.author === owner && (!first || later(byKey.get(first), e))) first = k; }
if (!first) continue;
groups.delete(r);
groups.set(first, keys);
for (const k of keys) memo.set(k, first);
}
const ownerTip = new Map();
for (const [r, keys] of groups) {
const re = byKey.get(r);
for (const k of keys) {
const e = byKey.get(k);
if (!re || e.author !== re.author) continue;
const prev = ownerTip.has(r) ? byKey.get(ownerTip.get(r)) : null;
if (!prev || later(e, prev)) ownerTip.set(r, k);
}
}
const isForeign = (key) => {
const e = byKey.get(key);
const re = byKey.get(rootOf(key));
return !!(e && re && e.author !== re.author);
};
const roots = new Set();
for (const r of groups.keys()) if (!isForeignCopy(r)) roots.add(r);
const tipOf = (key) => { const r = rootOf(key); return ownerTip.get(r) || r; };
return { rootOf, tipOf, roots, groups, isOrphan, isForeign, isForeignCopy };
};
const versionEntry = (m) => {
const v = (m && m.value) || {};
const c = v.content || {};
return { key: m && m.key, author: v.author, seq: v.sequence || 0, ts: v.timestamp || (m && m.timestamp) || 0, replaces: typeof c.replaces === 'string' ? c.replaces : null, claimedAuthor: typeof c.author === 'string' ? c.author : null };
};
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm, versionGroups, versionEntry };

View file

@ -0,0 +1,80 @@
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const { getConfig } = require('../configs/config-manager.js');
const REPEAT_MS = 60000;
const ICON = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const recent = new Map();
const onPath = (bin) => (process.env.PATH || '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin)));
const i18nNow = () => {
const all = require('../client/assets/translations/i18n');
let lang = 'en';
try { lang = getConfig().language || 'en'; } catch (_) {}
return { ...all.en, ...(all[lang] || {}) };
};
const displayName = (id, name) => {
const n = String(name || '').trim();
if (!n || n === 'Redacted' || n === String(id || '').replace(/^@/, '').slice(0, 8)) return String(id || '');
return n.startsWith('@') ? n : `@${n}`;
};
const MOBILE_QUEUE = path.join(__dirname, '..', '..', '.notifications.json');
const queueForAndroid = (keyword, body, line, key) => {
try {
let list = [];
try { list = JSON.parse(fs.readFileSync(MOBILE_QUEUE, 'utf8')); } catch (_) { list = []; }
if (!Array.isArray(list)) list = [];
const t = i18nNow();
const k = String(keyword || '');
const route = k === t.phoneTitle ? '/phone' : k === t.inbox ? '/inbox' : k === t.emergenciesTitle ? '/emergencies' : '/';
const entry = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 8), keyword: k || 'Oasis', body, line, route, key: key ? String(key) : null, ts: Date.now() };
if (entry.key && entry.key.indexOf('call:') === 0) {
const ringing = String(t.phoneIsCalling || '');
entry.caller = ringing && body.endsWith(ringing) ? body.slice(0, body.length - ringing.length).trim() : body;
entry.accept = String(t.phoneAnswer || '');
entry.reject = String(t.phoneReject || '');
}
list.push(entry);
while (list.length > 20) list.shift();
fs.writeFileSync(MOBILE_QUEUE, JSON.stringify(list));
return true;
} catch (_) {
return false;
}
};
const notify = (keyword, text, key = null, opts = {}) => {
const onMobile = process.env.OASIS_MOBILE === '1';
if (!onMobile && !onPath('notify-send')) return false;
const body = String(text || '').replace(/\s+/g, ' ').trim().replace(/[.。!…]+$/, '').slice(0, 200);
const line = `${String(keyword || 'Oasis').toUpperCase()}: ${body}...`;
const now = Date.now();
const dedupe = key || line;
for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k);
if (recent.has(dedupe)) return false;
recent.set(dedupe, now);
if (onMobile) return queueForAndroid(keyword, body, line, key);
const actions = Array.isArray(opts.actions) ? opts.actions.filter(x => x && x.key && x.label) : [];
const extra = [
...(opts.timeoutMs > 0 ? ['-t', String(Math.round(opts.timeoutMs))] : []),
...actions.flatMap(x => ['-A', `${x.key}=${x.label}`])
];
try {
const p = spawn('notify-send', ['-a', 'Oasis', ...(fs.existsSync(ICON) ? ['-i', ICON] : []), ...extra, line], { stdio: ['ignore', actions.length ? 'pipe' : 'ignore', 'ignore'] });
p.on('error', () => {});
if (actions.length && typeof opts.onAction === 'function') {
let out = '';
p.stdout.on('data', (d) => { out += d; });
p.on('close', () => { const chosen = out.trim(); if (actions.some(x => x.key === chosen)) opts.onAction(chosen); });
}
return true;
} catch (_) {
return false;
}
};
module.exports = { notify, i18nNow, displayName };

View file

@ -0,0 +1,74 @@
const crypto = require('crypto');
const ALG = 'aes-256-gcm';
const IV_LEN = 12;
const TAG_LEN = 16;
const KEY_LEN = 32;
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const generateFileKey = () => crypto.randomBytes(KEY_LEN);
const keyToHex = (key) => Buffer.isBuffer(key) ? key.toString('hex') : String(key);
const keyFromHex = (hex) => {
const buf = Buffer.from(String(hex), 'hex');
if (buf.length !== KEY_LEN) throw new Error('Invalid file key');
return buf;
};
const encryptChunk = (plaintext, key) => {
const iv = crypto.randomBytes(IV_LEN);
const cipher = crypto.createCipheriv(ALG, key, iv);
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, ct]);
};
const decryptChunk = (payload, key) => {
if (!Buffer.isBuffer(payload) || payload.length < IV_LEN + TAG_LEN) throw new Error('Corrupt chunk');
const iv = payload.subarray(0, IV_LEN);
const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ct = payload.subarray(IV_LEN + TAG_LEN);
const decipher = crypto.createDecipheriv(ALG, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]);
};
const sha256Hex = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex');
const splitBuffer = (buffer, chunkSize = DEFAULT_CHUNK_SIZE) => {
const size = Math.max(1, Number(chunkSize) || DEFAULT_CHUNK_SIZE);
const out = [];
for (let off = 0; off < buffer.length; off += size) {
out.push(buffer.subarray(off, Math.min(off + size, buffer.length)));
}
if (!out.length) out.push(Buffer.alloc(0));
return out;
};
const buildManifest = ({ filename, mime, size, chunkSize, chunkHashes, plainSha256 }) => ({
v: 1,
alg: ALG,
filename: String(filename || 'file'),
mime: String(mime || 'application/octet-stream'),
size: Number(size) || 0,
chunkSize: Number(chunkSize) || DEFAULT_CHUNK_SIZE,
chunks: Array.isArray(chunkHashes) ? chunkHashes.slice() : [],
plainSha256: plainSha256 || null
});
const encryptManifest = (manifest, key) => encryptChunk(Buffer.from(JSON.stringify(manifest), 'utf8'), key);
const decryptManifest = (payload, key) => {
const json = decryptChunk(payload, key).toString('utf8');
const m = JSON.parse(json);
if (!m || m.v !== 1 || !Array.isArray(m.chunks)) throw new Error('Invalid manifest');
return m;
};
module.exports = {
ALG, IV_LEN, TAG_LEN, KEY_LEN, DEFAULT_CHUNK_SIZE,
generateFileKey, keyToHex, keyFromHex,
encryptChunk, decryptChunk, sha256Hex, splitBuffer,
buildManifest, encryptManifest, decryptManifest
};

152
src/backend/fork_routes.js Normal file
View file

@ -0,0 +1,152 @@
"use strict";
// Router propio de la rama movil.
//
// Va en la cadena justo delante de las rutas del dev (backend.js lo monta con una
// sola linea). Lo que no atiende pasa al del dev con next(). Aqui viven:
// - mobile_layer.js: lanzador, filtro y avatar de la barra de arriba
// - wrapUpstream: mira lo que contesta el dev (seccion apagada, guardar modulos)
// - las rutas propias: tema y Modulos en el movil, barra de abajo, identidades
//
// backend.js es del dev y cambia en 34 de cada 39 versiones: nada de esto va alli.
// Ver GUIA_DE_DESARROLLO.md.
const koaRouter = require("../server/node_modules/@koa/router");
const { koaBody } = require("../server/node_modules/koa-body");
const compose = require("../server/node_modules/koa-compose");
const { isLocalPath } = require("./request_guards");
const { ALL_MODULES } = require("../models/workflows_model");
const { mobileModulesView, STEMS } = require("../views/mobile_modules_view");
require("../models/fork_workflows").register();
const isMobile = () => process.env.OASIS_MOBILE === '1';
// Seccion apagada: upstream responde con una redireccion a /modules sin decir de
// donde venia. Este envoltorio la ve pasar y anota que modulo era, para que la
// pantalla de Modulos del movil lo diga y ofrezca activarlo. La app es de una
// persona, asi que basta con recordar el ultimo unos segundos.
const SEGMENT_MOD = { tribe: 'tribes', vote: 'votes', document: 'docs', documents: 'docs', blog: 'blogs', event: 'events',
task: 'tasks', calendar: 'calendars', image: 'images', video: 'videos', audio: 'audios', bookmark: 'bookmarks',
file: 'files', poll: 'polls', report: 'reports', project: 'projects', shop: 'shops', job: 'jobs', campaign: 'campaigns',
podcast: 'podcasts', room: 'rooms', map: 'maps', transfer: 'transfers', pad: 'pads', chat: 'chats', torrent: 'torrents' };
const modOfPath = (p) => {
const seg = String(p || '').split('/')[1] || '';
const mod = STEMS[seg] ? seg : SEGMENT_MOD[seg];
return mod && ALL_MODULES.includes(mod) ? mod : null;
};
let lastBlocked = null;
const takeBlocked = () => {
const b = lastBlocked && Date.now() - lastBlocked.at < 15000 ? lastBlocked : null;
lastBlocked = null;
return b;
};
const wrapUpstream = async (ctx, next) => {
await next();
if (!isMobile() || ctx.status < 300 || ctx.status >= 400) return;
const to = String(ctx.response.get('Location') || '');
// guardar en Modulos: volver con el aviso de guardado
if (ctx.method === 'POST' && ctx.path === '/save-modules') { ctx.redirect('/modules?saved=1'); return; }
if (ctx.method === 'GET' && ctx.path !== '/modules' && /^(https?:\/\/[^/]+)?\/modules$/.test(to)) {
const mod = modOfPath(ctx.path);
if (mod) lastBlocked = { mod, path: ctx.path, at: Date.now() };
}
};
module.exports = ({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage, about }) => {
const mobileLayer = require('./mobile_layer')({ getViewerId, about });
const forkRouter = new koaRouter();
forkRouter
// tema en el movil: solo los skins de movil (uno de escritorio deja la app sin
// maqueta). Hace lo mismo que la ruta del dev, con esa comprobacion delante
.post('/settings/theme', koaBody(), async (ctx, next) => {
if (!isMobile()) return next();
const theme = String((ctx.request.body || {}).theme || '').trim();
const { MOBILE_THEMES } = require('../views/main_views');
if (!MOBILE_THEMES.some(([value]) => value === theme)) { ctx.redirect('/settings#theme'); return; }
const cfg = getConfig();
cfg.themes.current = theme;
saveConfig(cfg);
ctx.cookies.set('theme', theme, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
ctx.redirect('/settings#theme');
})
// Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream
.get('/modules', async (ctx, next) => {
if (!isMobile()) return next();
ctx.body = mobileModulesView({ blocked: takeBlocked(), saved: ctx.query.saved === '1' });
})
// activar un solo modulo desde el aviso de Modulos y volver a donde se queria ir
.post('/modules/enable', koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const mod = String((ctx.request.body || {}).mod || '').replace(/Mod$/, '');
if (!ALL_MODULES.includes(mod)) { ctx.redirect('/modules'); return; }
const cfg = getConfig();
cfg.modules[`${mod}Mod`] = 'on';
saveConfig(cfg);
const back = String((ctx.request.body || {}).returnTo || '');
ctx.redirect(isLocalPath(back) ? back : `/${mod}`);
})
.get("/settings/bottombar", async (ctx) => {
ctx.body = require("../views/main_views").bottomBarPickerView();
})
.post("/settings/bottombar", koaBody(), async (ctx) => {
const cfg = getConfig();
const MAX = 4;
const body = ctx.request.body || {};
const action = String(body.action || '').trim();
const key = String(body.key || '').trim();
const catalog = require("../views/main_views").PINNABLE_MODULES || [];
let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : [];
if (action === 'add') {
if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key);
} else if (action === 'remove') {
pins = pins.filter(k => k !== key);
} else if (action === 'clear') {
pins = [];
}
cfg.bottomBarPins = pins.slice(0, MAX);
saveConfig(cfg);
ctx.redirect('/settings/bottombar');
})
.post("/settings/identity/use", koaBody(), async (ctx) => {
// solo anota cual sera la del proximo arranque: cambiarla en caliente dejaria
// media aplicacion hablando con el sbot anterior
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name) || !accounts.list().includes(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/create", koaBody(), async (ctx) => {
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
// no se genera el secret aqui: lo crea ssb-config al arrancar con esa cuenta,
// que es quien sabe como hacerlo. Basta con anotarla y reiniciar.
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/restart", koaBody(), async (ctx) => {
// Solo en movil: alli el backend vive en un servicio en primer plano que
// sobrevive a cerrar la app, asi que la unica forma de arrancar con otra
// identidad es terminar el proceso. nodejs-mobile no permite volver a
// levantar el runtime dentro del mismo proceso: hay que salir y que la app
// lo relance al abrirse.
if (process.env.OASIS_MOBILE !== '1') { ctx.status = 404; ctx.body = ''; return; }
ctx.body = require("../views/main_views").template(
require('../views/main_views').i18n.identitiesTitle,
require("../server/node_modules/hyperaxe").section(
require("../server/node_modules/hyperaxe").p(require('../views/main_views').i18n.identitiesRestarting)
)
);
// se responde primero: si se sale antes, el navegador se queda sin pagina
setTimeout(() => process.exit(0), 500);
});
return compose([mobileLayer, wrapUpstream, forkRouter.routes()]);
};

View file

@ -1,206 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
const splitSegments = (line) => {
const segs = [];
let last = 0;
const re = new RegExp(linkPattern.source, 'g');
let m;
while ((m = re.exec(line)) !== null) {
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
segs.push({ t: m[0], l: true });
last = m.index + m[0].length;
}
if (last < line.length) segs.push({ t: line.slice(last), l: false });
return segs;
};
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildLogsPdf(entries, oasisId, opts = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 22;
const bodyBottom = footerH + 10;
const lineH = 12;
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const allLines = [];
for (const e of entries) {
const ts = new Date(e.ts);
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
allLines.push({ kind: 'header', text: `[${when}]:` });
allLines.push({ kind: 'blank', text: '' });
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
allLines.push({ kind: 'blank', text: '' });
}
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
const pages = [];
for (let i = 0; i < allLines.length; i += maxBodyLines) {
pages.push(allLines.slice(i, i + maxBodyLines));
}
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
const logoX = marginX;
const logoY = pageH - headerH + 15;
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
const inhabitantPrefix = 'Inhabitant: ';
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
const charW = 6;
for (const ln of pg) {
if (ln.kind === 'header') {
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.text) {
const segs = splitSegments(ln.text);
let x = marginX;
for (const s of segs) {
if (!s.t) continue;
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
x += s.t.length * charW;
}
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) {
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildLogsPdf };

195
src/backend/long_text.js Normal file
View file

@ -0,0 +1,195 @@
const crypto = require('crypto');
const CHUNK_TYPE = 'textChunk';
const TEXT_CAP = 60000;
const CONTENT_BYTES = { plain: 6500, boxed: 4500, sealed: 3300 };
const MIN_HEAD_BYTES = 200;
const CACHE_MAX = 400;
const KEY_RE = /^%[A-Za-z0-9+/]{43}=?\.sha256$/;
const KEY_SAMPLE = '%' + 'A'.repeat(44) + '.sha256';
const byteLength = (s) => Buffer.byteLength(String(s == null ? '' : s), 'utf8');
const contentBytes = (content) => { try { return byteLength(JSON.stringify(content)); } catch (_) { return Infinity; } };
const isChunkKey = (k) => typeof k === 'string' && KEY_RE.test(k);
const tooLong = (text) => String(text == null ? '' : text).length > TEXT_CAP;
const cutAt = (text, maxBytes) => {
let i = 0, bytes = 0;
while (i < text.length) {
const cp = text.codePointAt(i);
const len = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
if (bytes + len > maxBytes) break;
bytes += len;
i += cp >= 0x10000 ? 2 : 1;
}
if (i >= text.length) return text.length;
const min = Math.floor(i / 2);
const para = text.lastIndexOf('\n\n', i);
if (para > min) return para + 2;
const line = text.lastIndexOf('\n', i);
if (line > min) return line + 1;
const space = text.lastIndexOf(' ', i);
if (space > min) return space + 1;
return Math.max(i, 1);
};
const splitText = (text, maxBytes, firstMax = maxBytes) => {
const s = String(text == null ? '' : text);
const out = [];
let rest = s;
let limit = Math.max(1, firstMax);
while (rest.length) {
const cut = cutAt(rest, limit);
out.push(rest.slice(0, cut));
rest = rest.slice(cut);
limit = Math.max(1, maxBytes);
}
return out.length ? out : [''];
};
const chunkTemplate = (extra) => ({ type: CHUNK_TYPE, text: '', index: 999, total: 999, group: 'g'.repeat(16), ...extra });
const chunkBudget = (extra, maxBytes) => maxBytes - contentBytes(chunkTemplate(extra));
const headBudget = (content, field, maxBytes, count) => maxBytes - contentBytes({ ...content, [field]: '', chunks: new Array(count).fill(KEY_SAMPLE) });
const needsChunking = (content, maxBytes = CONTENT_BYTES.plain) => contentBytes(content) > maxBytes;
const planPieces = (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {} } = {}) => {
const text = String(content[field] == null ? '' : content[field]);
if (tooLong(text)) throw new Error('Text too long');
if (!needsChunking(content, maxBytes)) return null;
const perChunk = chunkBudget(extra, maxBytes);
if (perChunk < MIN_HEAD_BYTES) throw new Error('Text does not fit');
let count = 0;
let pieces = null;
for (let guard = 0; guard < 12; guard++) {
const head = headBudget(content, field, maxBytes, count);
if (head < MIN_HEAD_BYTES) throw new Error('Text does not fit');
pieces = splitText(text, perChunk, head);
if (pieces.length - 1 <= count) return pieces;
count = pieces.length - 1;
}
return pieces;
};
const publishChunks = async (pieces, publish, { extra = {}, group } = {}) => {
const g = group || crypto.randomBytes(8).toString('hex');
const total = pieces.length;
const keys = [];
for (let i = 0; i < pieces.length; i++) {
const r = await publish({ type: CHUNK_TYPE, text: pieces[i], index: i + 1, total, group: g, ...extra });
const key = r && typeof r === 'object' ? r.key : r;
if (!isChunkKey(key)) throw new Error('Chunk publish failed');
keys.push(key);
}
return keys;
};
const chunkContent = async (content, field, { maxBytes = CONTENT_BYTES.plain, extra = {}, publish } = {}) => {
const base = { ...content };
delete base.chunks;
const pieces = planPieces(base, field, { maxBytes, extra });
if (!pieces) return base;
const keys = await publishChunks(pieces.slice(1), publish, { extra });
return { ...base, [field]: pieces[0], chunks: keys };
};
const chunkOf = (author, c) => {
if (!c || typeof c !== 'object' || typeof c.text !== 'string') return null;
if (c.type !== CHUNK_TYPE && c.k !== CHUNK_TYPE) return null;
return { author, text: c.text, index: Number(c.index) || 0, total: Number(c.total) || 0, group: String(c.group || '') };
};
const joinText = (head, keys, lookup, author) => {
let out = String(head == null ? '' : head);
if (!Array.isArray(keys) || !keys.length) return out;
let group = null;
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!isChunkKey(k)) break;
const ch = lookup(k);
if (!ch || (author && ch.author !== author)) break;
if (group === null) group = ch.group; else if (ch.group !== group) break;
if (ch.index !== i + 1 || ch.total !== keys.length) break;
out += ch.text;
}
return out;
};
const hasChunks = (content) => !!content && Array.isArray(content.chunks) && content.chunks.length > 0;
const resolveField = (content, field, author, lookup) => {
if (!hasChunks(content)) return content;
return { ...content, [field]: joinText(content[field], content.chunks, lookup, author) };
};
const indexChunks = (messages, decode) => {
const index = new Map();
for (const m of messages || []) {
const v = m && m.value;
if (!v || !m.key) continue;
const raw = v.content;
const c = decode ? decode(raw, m) : raw;
const ch = chunkOf(v.author, c);
if (ch) index.set(m.key, ch);
}
return index;
};
const lookupIn = (index) => (k) => index.get(k) || null;
const caches = new WeakMap();
const cacheFor = (ssb) => {
let c = caches.get(ssb);
if (!c) { c = new Map(); caches.set(ssb, c); }
return c;
};
const remember = (cache, key, value) => {
if (cache.has(key)) cache.delete(key);
cache.set(key, value);
while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return value;
};
const fetchChunk = async (ssb, key, unbox) => {
const cache = cacheFor(ssb);
if (cache.has(key)) { const v = cache.get(key); cache.delete(key); cache.set(key, v); return v; }
const raw = await new Promise((resolve) => { try { ssb.get(key, (err, m) => resolve(err ? null : m)); } catch (_) { resolve(null); } });
if (!raw) return null;
let content = raw.content;
if (typeof content === 'string' && unbox) { try { content = unbox(content); } catch (_) { content = null; } }
const ch = chunkOf(raw.author, content);
return ch ? remember(cache, key, ch) : null;
};
const joinChunks = async (ssb, content, author, { field = 'text', unbox = null } = {}) => {
if (!hasChunks(content)) return content;
const found = new Map();
for (const k of content.chunks) {
if (!isChunkKey(k)) break;
const ch = await fetchChunk(ssb, k, unbox);
if (!ch) break;
found.set(k, ch);
}
return resolveField(content, field, author, lookupIn(found));
};
module.exports = {
CHUNK_TYPE,
TEXT_CAP,
CONTENT_BYTES,
byteLength,
contentBytes,
tooLong,
splitText,
needsChunking,
planPieces,
publishChunks,
chunkContent,
chunkOf,
joinText,
hasChunks,
resolveField,
indexChunks,
lookupIn,
joinChunks
};

View file

@ -0,0 +1,40 @@
"use strict";
// Lo que la interfaz de la rama necesita del backend, fuera de backend.js.
//
// Prepara lo que leen la barra de arriba y el lanzador de hive_views.js: el filtro
// Personal/Social, que el lanzador no va en la portada y el avatar propio. Lo
// monta el router propio (fork_routes.js) delante de las rutas del dev. Nada de
// esto corre en escritorio. Ver GUIA_DE_DESARROLLO.md.
//
// Lo demas que el movil necesita del backend (modulos que no van, rutas que se
// cortan, retoques del HTML, descargas de la copia de seguridad) lo hace el propio
// dev en la version de movil de su backend.js, la que va dentro de su APK.
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
const isMobile = () => process.env.OASIS_MOBILE === '1';
const isPage = (p) => !/^\/(assets|image|blob|qr|c)(\/|$)/.test(p);
module.exports = ({ getViewerId, about }) => {
let avatarAt = 0;
const refreshAvatar = async () => {
avatarAt = Date.now();
try {
const me = getViewerId();
const img = me ? await about.image(me) : null;
if (typeof img === 'string' && img[0] === '&' && img !== NULL_IMG) global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(img)}`;
else if (typeof img === 'string' && img[0] === '/') global.__OASIS_SELF_AVATAR__ = img;
else global.__OASIS_SELF_AVATAR__ = null;
} catch (_) {}
};
return async (ctx, next) => {
if (!isMobile() || !isPage(ctx.path)) return next();
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
// el lanzador ya no va en la portada: la portada es la actividad y los modulos
// estan en la hoja de la pestaña Menu (docs/ESTUDIO_NAVEGACION.md)
global.__OASIS_SHOW_HIVE__ = false;
if (!avatarAt) await refreshAvatar();
else if (Date.now() - avatarAt > 60000) refreshAvatar();
return next();
};
};

View file

@ -46,5 +46,40 @@ const all = [...positive, ...constructive, ...moderation];
all.positive = positive;
all.constructive = constructive;
all.moderation = moderation;
const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources"];
const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"];
all.karma = {
positive,
neutral: karmaNeutral,
constructive: constructive.filter(c => !karmaNeutral.includes(c) && !karmaModeration.includes(c)),
moderation: karmaModeration
};
all.messageTypes = {
bookmark: 'bookmarkOpinion',
votes: 'votesOpinion',
poll: 'pollOpinion',
transfer: 'transferOpinion',
feed: 'feedOpinion',
post: 'blogOpinion',
image: 'imageOpinion',
audio: 'audioOpinion',
video: 'videoOpinion',
document: 'documentOpinion',
torrent: 'torrentOpinion',
file: 'fileOpinion',
industry: 'industryOpinion',
project: 'projectOpinion',
report: 'reportOpinion',
task: 'taskOpinion',
event: 'eventOpinion',
shopProduct: 'shopOpinion',
housing: 'housingOpinion',
market: 'marketOpinion',
schoolCourse: 'schoolOpinion',
podcast: 'podcastOpinion',
podcastEpisode: 'podcastOpinion',
campaign: 'campaignOpinion',
logisticsRoute: 'logisticsOpinion'
};
module.exports = all;

1485
src/backend/pdf.js Normal file

File diff suppressed because it is too large Load diff

51
src/backend/pm_policy.js Normal file
View file

@ -0,0 +1,51 @@
const fs = require('fs');
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
if (recipientId && viewerId && recipientId === viewerId) return true;
if (pmVisibility === 'mutuals') return isMutual(relationship);
if (pmVisibility === 'following') return !!(relationship && relationship.following);
return true;
};
const announceSeenPath = () => {
try { return require('../configs/state-manager').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
};
const readAnnounceSeen = () => {
const file = announceSeenPath();
if (!file) return {};
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) { return {}; }
};
const writeAnnounceSeen = (state) => {
const file = announceSeenPath();
if (!file) return false;
try { fs.writeFileSync(file, JSON.stringify(state || {})); return true; } catch (_) { return false; }
};
const everAnnounced = (announced, subject) => {
for (const entry of (announced || [])) {
if (String(entry).startsWith(`${subject}|`)) return true;
}
return false;
};
const decideAnnouncement = ({ subject, ref, announced = new Set(), seen = {} } = {}) => {
if (!subject || !ref) return { send: false, remember: false };
const key = String(ref);
if (announced.has(`${subject}|${key}`)) return { send: false, remember: false };
if (seen[subject] === key) return { send: false, remember: false };
if (!everAnnounced(announced, subject) && seen[subject] === undefined) {
return { send: false, remember: true };
}
return { send: true, remember: true };
};
module.exports = { isRecipientAllowed, isMutual, announceSeenPath, readAnnounceSeen, writeAnnounceSeen, decideAnnouncement };

View file

@ -0,0 +1,325 @@
const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea } = require("../server/node_modules/hyperaxe");
const i18nBase = require("../client/assets/translations/i18n");
const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model");
let zoomSeq = 0;
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
const ESCAPE_RE = /\\([\\*_~`#>\-\[\]!])/g;
const CODE_BLOCK_RE = /```([\s\S]*?)```[ \t]*(?:\n(?:[ \t]*\n)*|$)/g;
const RULE_RE = /^[ \t]*---[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const HEADER_CLOSED_RE = /^[ \t]*(#{1,6})[ \t]*([^\n]{1,200}?)[ \t]*\1[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const HEADER_SPACED_RE = /^[ \t]*(#{1,6})[ \t]+([^\n]{1,200}?)[ \t]*#*[ \t]*(?:\n(?:[ \t]*\n)*|$)/gm;
const QUOTE_RE = /^[ \t]*>[ \t]?([^\n]*)(?:\n(?:[ \t]*\n)*|$)/gm;
const BULLET_RE = /^([ \t]*)-[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
const NUMBER_RE = /^([ \t]*)(\d{1,3})([.)])[ \t]+([^\n]+)(?:\n(?:[ \t]*\n)*|$)/gm;
const BOLD_RE = /\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g;
const UNDERLINE_RE = /(?<![A-Za-z0-9])__(?!\s)((?:[^_\n]|_(?!_))+?)(?<!\s)__(?![A-Za-z0-9])/g;
const STRIKE_RE = /~~(?!\s)([^~\n]+?)(?<!\s)~~/g;
const ITALIC_RE = /\*(?!\s)((?:[^*\n]|\*\*)+?)(?<!\s)\*(?!\*)/g;
const INLINE_CODE_RE = /`([^`\n]+)`/g;
const BLOB_IMAGE_RE = /!\[([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_VIDEO_RE = /\[video:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_AUDIO_RE = /\[audio:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_PDF_RE = /\[pdf:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_TORRENT_RE = /\[torrent:([^\]\[\n]{0,160})\]\(\s*(&[^)\s]{1,120}\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const BLOB_FILE_RE = /(?<!!)\[(?!(?:video|audio|pdf|torrent):)([^\]\n]{1,160})\]\(\s*(&[^)\s]+\.sha256|\/blob\/sealed\/[^)\s]{1,600})\s*\)/g;
const MD_MENTION_RE = /\[@([^\]\[\n]{1,120})\]\(@?([A-Za-z0-9+/=.\-]{1,120}\.ed25519)\)/g;
const RAW_MENTION_RE = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
const BARE_MENTION_RE = /(^|[^\w\u0080-\uFFFF\[\]/@])@([\w\u0080-\uFFFF.\-]{1,64})(?=$|[^\w\u0080-\uFFFF/+=])/g;
let mentionResolver = null;
const setMentionResolver = (fn) => { mentionResolver = typeof fn === 'function' ? fn : null; };
const bareMentionName = (token) => (/\.ed25519$/i.test(token) ? '' : token.replace(/[.\-]+$/, ''));
const MSG_REF_RE = /%[A-Za-z0-9+/=]{44}\.sha256/g;
const MD_LINK_RE = /!?\[([^\]\n]{1,160})\]\((https?:\/\/[^)\s]+|\/(?![\/\\])[^)\s]*)\)/g;
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`]+/gi;
const EMAIL_RE = /\b[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,24}\b/g;
const HASHTAG_RE = /#[\w\u0080-\uFFFF]{1,32}(?![\w\u0080-\uFFFF])/g;
const URL_TAIL_RE = /[.,;:!?»"')\]}>]+$/;
const SELF_HOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i;
const INTERNAL_PATHS = [
'agenda','ai','audios','author','backup','banking','blockexplorer','blogs','bookmarks','calendars','campaigns',
'chats','cipher','courts','cv','dev','docs','emergencies','events','favorites','feed','files','forum','games','graphos',
'hashtag','housing','images','industry','inbox','inhabitants','invites','jobs','larp','logistics','logs','mailing',
'maps','market','melody','mentions','modules','multiverse','opinions','pads','parliament','rooms','peers','pixelia','pm',
'podcasts','polls','popular','profile','projects','publish','reports','school','search','settings','shops','spread',
'stats','tags','tasks','thread','torrents','transfers','trending','tribes','tribe','videos','votes','votations','wallet','wiki'
];
const INTERNAL_PATH_SET = new Set(INTERNAL_PATHS);
const SSB_ID_RE = /^(?:%[A-Za-z0-9+/=]{20,}\.sha256|@[A-Za-z0-9+/=]{20,}\.ed25519|&[A-Za-z0-9+/=]{20,}\.sha256)$/;
const internalTargetOf = (url) => {
let parsed = null;
try { parsed = new URL(url); } catch (_) { return null; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
const segments = parsed.pathname.split('/').filter(Boolean).map(seg => { try { return decodeURIComponent(seg); } catch (_) { return seg; } });
if (!segments.length || !INTERNAL_PATH_SET.has(segments[0].toLowerCase())) return null;
const sameHost = SELF_HOST_RE.test(url);
const hasSsbId = segments.slice(1).some(seg => SSB_ID_RE.test(seg));
if (!sameHost && !hasSsbId) return null;
const id = segments.slice(1).find(seg => SSB_ID_RE.test(seg)) || segments[1] || '';
return { href: `${parsed.pathname}${parsed.search}${parsed.hash}`, kind: segments[0].toLowerCase(), id };
};
const shortInternalId = (id) => {
const value = String(id || '');
if (!value) return '';
const sigil = /^[%@&]/.test(value) ? value[0] : '';
const body = sigil ? value.slice(1) : value;
return body.length > 8 ? `${sigil}${body.slice(0, 8)}…` : `${sigil}${body}`;
};
const MAX_DEPTH = 8;
const headerLevel = (hashes) => Math.min(3, String(hashes || '#').length);
const indentLevel = (indent) => {
const width = String(indent || '').replace(/\t/g, ' ').length;
return Math.min(4, Math.floor(width / 2) + 1);
};
const codeBlockBody = (raw) => {
const s = String(raw);
const nl = s.indexOf('\n');
if (nl === -1) return s;
return s.slice(nl + 1).replace(/\n[ \t]*$/, '');
};
function renderStyledText(value, opts = {}) {
if (typeof value !== 'string') return value === null || value === undefined ? [] : [String(value)];
const text = value.indexOf('\r') === -1 ? value : value.replace(/\r\n?/g, '\n');
const depth = Number(opts.depth) || 0;
if (depth > MAX_DEPTH) return [text];
const allMatches = [];
const push = (m, extra) => allMatches.push({ index: m.index, length: m[0].length, ...extra });
for (const m of text.matchAll(ESCAPE_RE)) push(m, { type: 'escaped', body: m[1] });
for (const m of text.matchAll(WIKILINK_RE)) push(m, { type: 'wikilink', target: linkTarget(m[1]), label: (m[2] || linkTarget(m[1])).trim() });
for (const m of text.matchAll(CODE_BLOCK_RE)) push(m, { type: 'code-block', body: codeBlockBody(m[1]) });
for (const m of text.matchAll(RULE_RE)) push(m, { type: 'rule' });
for (const m of text.matchAll(HEADER_CLOSED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
for (const m of text.matchAll(HEADER_SPACED_RE)) push(m, { type: 'header', level: headerLevel(m[1]), body: m[2] });
for (const m of text.matchAll(QUOTE_RE)) push(m, { type: 'quote', body: m[1] });
for (const m of text.matchAll(BULLET_RE)) push(m, { type: 'bullet', level: indentLevel(m[1]), body: m[2] });
for (const m of text.matchAll(NUMBER_RE)) push(m, { type: 'number', level: indentLevel(m[1]), marker: `${m[2]}${m[3]}`, body: m[4] });
for (const m of text.matchAll(BLOB_IMAGE_RE)) push(m, { type: 'blob-image', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_VIDEO_RE)) push(m, { type: 'blob-video', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_AUDIO_RE)) push(m, { type: 'blob-audio', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_PDF_RE)) push(m, { type: 'blob-pdf', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_TORRENT_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
for (const m of text.matchAll(BLOB_FILE_RE)) push(m, { type: 'blob-file', name: m[1], blob: m[2] });
for (const m of text.matchAll(MD_MENTION_RE)) push(m, { type: 'md-mention', name: m[1], feedId: m[2] });
for (const m of text.matchAll(RAW_MENTION_RE)) push(m, { type: 'raw-mention', feedId: m[1] });
for (const m of text.matchAll(BARE_MENTION_RE)) {
const name = bareMentionName(m[2]);
if (name) allMatches.push({ index: m.index + m[1].length, length: name.length + 1, type: 'bare-mention', name });
}
for (const m of text.matchAll(MSG_REF_RE)) push(m, { type: 'msg-ref', id: m[0] });
for (const m of text.matchAll(MD_LINK_RE)) push(m, { type: 'md-link', label: m[1], href: m[2] });
for (const m of text.matchAll(BOLD_RE)) push(m, { type: 'bold', body: m[1] });
for (const m of text.matchAll(UNDERLINE_RE)) push(m, { type: 'underline', body: m[1] });
for (const m of text.matchAll(STRIKE_RE)) push(m, { type: 'strike', body: m[1] });
for (const m of text.matchAll(ITALIC_RE)) push(m, { type: 'italic', body: m[1] });
for (const m of text.matchAll(INLINE_CODE_RE)) push(m, { type: 'inline-code', body: m[1] });
for (const m of text.matchAll(URL_RE)) {
const trimmed = m[0].replace(URL_TAIL_RE, '');
if (trimmed) allMatches.push({ index: m.index, length: trimmed.length, type: 'url', text: trimmed });
}
for (const m of text.matchAll(EMAIL_RE)) push(m, { type: 'email', text: m[0] });
for (const m of text.matchAll(HASHTAG_RE)) push(m, { type: 'hashtag', tag: m[0].slice(1) });
allMatches.sort((x, y) => x.index - y.index);
const filtered = [];
let lastEnd = 0;
for (const m of allMatches) {
if (m.index < lastEnd) continue;
filtered.push(m);
lastEnd = m.index + m.length;
}
const blobHref = (id) => String(id).startsWith('/blob/sealed/') ? String(id) : `${opts.blobPrefix || '/blob/'}${encodeURIComponent(id)}`;
const linksOn = opts.links !== false;
const internalOn = opts.internalLinks !== false;
const plain = (value) => opts.plainUrlClass ? span({ class: opts.plainUrlClass }, value) : value;
const inner = (body) => renderStyledText(body, { ...opts, depth: depth + 1 });
const result = [];
let cursor = 0;
for (const m of filtered) {
if (cursor < m.index) result.push(text.slice(cursor, m.index));
if (m.type === 'escaped') {
result.push(m.body);
} else if (m.type === 'msg-ref') {
result.push(internalOn ? a({ href: `/thread/${encodeURIComponent(m.id)}`, class: 'styled-link' }, m.id.slice(0, 10) + '...') : m.id);
} else if (m.type === 'wikilink') {
result.push(typeof opts.wikiLink === 'function'
? opts.wikiLink(m.target, slugify(m.target), m.label)
: internalOn
? a({ href: `/wiki/${encodeURIComponent(slugify(m.target))}`, class: 'wiki-link' }, m.label)
: m.label);
} else if (m.type === 'code-block') {
result.push(span({ class: 'rt-code-block' }, m.body));
} else if (m.type === 'rule') {
result.push(span({ class: 'rt-rule' }));
} else if (m.type === 'header') {
result.push(span({ class: `rt-header rt-header-${m.level}` }, ...inner(m.body)));
} else if (m.type === 'quote') {
result.push(span({ class: 'rt-quote' }, ...inner(m.body)));
} else if (m.type === 'bullet') {
result.push(span({ class: `rt-item rt-item-${m.level}` }, ...inner(m.body)));
} else if (m.type === 'number') {
result.push(span({ class: `rt-item rt-item-number rt-item-${m.level}` }, `${m.marker} `, ...inner(m.body)));
} else if (m.type === 'bold') {
result.push(strong(...inner(m.body)));
} else if (m.type === 'underline') {
result.push(u(...inner(m.body)));
} else if (m.type === 'strike') {
result.push(strike(...inner(m.body)));
} else if (m.type === 'italic') {
result.push(em(...inner(m.body)));
} else if (m.type === 'inline-code') {
result.push(code({ class: 'rt-code' }, m.body));
} else if (m.type === 'blob-image') {
const imageSrc = blobHref(m.blob);
if (opts.zoomImages) {
zoomSeq = (zoomSeq + 1) % 1000000;
const zoomId = `rtzoom-${zoomSeq}-${String(m.blob).replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
result.push(a({ href: `#${zoomId}`, id: `${zoomId}-src`, class: 'zoom-link' }, img({ src: imageSrc, alt: m.name || '', class: 'post-image' })));
result.push(span({ id: zoomId, class: 'lightbox' },
a({ href: `${imageSrc}${imageSrc.includes('?') ? '&' : '?'}download=1`, class: 'lightbox-download', title: getI18n().fileShareDownload || 'Download' }, '\u2913'),
a({ href: `#${zoomId}-src`, class: 'lightbox-close' }, '\u00d7'),
img({ src: imageSrc, alt: m.name || '', class: 'lightbox-image' })
));
} else {
result.push(img({ src: imageSrc, alt: m.name || '', class: 'post-image' }));
}
} else if (m.type === 'blob-video') {
result.push(video({ controls: true, class: 'post-video', src: blobHref(m.blob) }));
} else if (m.type === 'blob-audio') {
result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) }));
} else if (m.type === 'blob-pdf') {
const i18n = getI18n();
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || i18n.pdfFallbackLabel || 'PDF'));
} else if (m.type === 'blob-file') {
result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || m.blob.slice(1, 9)));
} else if (m.type === 'md-mention') {
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
} else if (m.type === 'raw-mention') {
result.push(internalOn ? a({ href: `/author/${encodeURIComponent('@' + m.feedId)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...') : '@' + m.feedId.slice(0, 8) + '...');
} else if (m.type === 'bare-mention') {
const feedId = internalOn && mentionResolver ? mentionResolver(m.name) : null;
result.push(feedId ? a({ href: `/author/${encodeURIComponent(feedId)}`, class: 'mention' }, '@' + m.name) : '@' + m.name);
} else if (m.type === 'md-link') {
const external = /^https?:\/\//.test(m.href) && !SELF_HOST_RE.test(m.href);
result.push(external
? (linksOn ? a({ href: m.href, class: 'styled-link', target: '_blank', rel: 'noopener noreferrer' }, ...inner(m.label)) : plain(m.label))
: (internalOn ? a({ href: m.href, class: 'styled-link' }, ...inner(m.label)) : m.label));
} else if (m.type === 'url') {
const schemeMatch = m.text.match(/^(https?):\/\//i);
const href = schemeMatch ? `${schemeMatch[1].toLowerCase()}://${m.text.slice(schemeMatch[0].length)}` : `https://${m.text}`;
const internalTarget = internalTargetOf(href);
if (!linksOn) result.push(plain(m.text));
else if (internalTarget) {
result.push(internalOn
? a({ href: internalTarget.href, class: 'internal-link' },
span({ class: 'internal-link-kind' }, internalTarget.kind.toUpperCase()),
internalTarget.id ? span({ class: 'internal-link-id' }, shortInternalId(internalTarget.id)) : null
)
: plain(internalTarget.href));
}
else if (SELF_HOST_RE.test(href)) result.push(a({ href }, m.text));
else result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
} else if (m.type === 'email') {
result.push(linksOn ? a({ href: `mailto:${m.text}` }, m.text) : plain(m.text));
} else if (m.type === 'hashtag') {
const tagHref = typeof opts.hashtagHref === 'function' ? opts.hashtagHref(m.tag) : (internalOn ? `/search?query=%23${encodeURIComponent(m.tag)}` : null);
result.push(tagHref ? a({ href: tagHref, class: 'tag-link tag-inline' }, `#${m.tag}`) : `#${m.tag}`);
}
cursor = m.index + m.length;
}
if (cursor < text.length) result.push(text.slice(cursor));
return result;
}
const safeExternalHref = (url) => {
const value = String(url || '').trim();
const lower = value.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:')) return value;
return '';
};
const escapeHtml = (value) => String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function renderStyledHtml(text, opts = {}) {
if (text === null || text === undefined || text === '') return '';
return renderStyledText(String(text), opts)
.map(node => (node && typeof node === 'object' && node.outerHTML) ? node.outerHTML : escapeHtml(node))
.join('');
}
function plainText(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/```[\s\S]*?```/g, '')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/^[ \t]*---[ \t]*$/gm, '')
.replace(/^[ \t]*#{1,6}[ \t]*/gm, '')
.replace(/[ \t]*#{1,6}[ \t]*$/gm, '')
.replace(/^[ \t]*- /gm, '')
.replace(/^[ \t]*\d{1,3}[.)] /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!?\[[^\]\n]{0,160}\]\(\s*&[^)\s\n]{1,120}\.sha256\s*\)/g, '')
.replace(/\[(?:video|audio|pdf):[^\]\n]{0,160}\]\([^)\n]{0,200}\)/g, '')
.replace(/\[\[([^\]|\n]{1,120})(?:\|([^\]\n]{1,120}))?\]\]/g, (m, target, label) => label || target)
.replace(/!?\[([^\]\n]{1,160})\]\(([^)\s\n]{0,200})\)/g, (m, label, href) => href ? `${label} (${href})` : label)
.replace(/\\([\\*_~`#>\-\[\]!])/g, '$1')
.trim();
}
function renderTextPreview(text, maxLength = 220) {
const preview = plainText(text).replace(/\n+/g, ' ').trim();
return preview.length > maxLength ? preview.slice(0, maxLength) + '...' : preview;
}
const FORMAT_MARKS = [
{ mark: '**bold**', style: 'bold' },
{ mark: '*italic*', style: 'italic' },
{ mark: '__underline__', style: 'underline' },
{ mark: '~~strike~~', style: 'strike' },
{ mark: '`code`', style: 'code' },
{ mark: '### title ###', style: 'header' },
{ mark: '- list', style: 'item' },
{ mark: '> quote', style: 'quote' },
{ mark: '[[wiki]]', style: 'wiki' }
];
function renderFormatBar() {
const i18n = getI18n();
const marks = [];
FORMAT_MARKS.forEach((m, i) => {
if (i) marks.push(span({ class: 'rt-sep' }, '|'));
marks.push(span({ class: `rt-mark rt-mark-${m.style}` }, m.mark));
});
return div({ class: 'rt-toolbar' },
span({ class: 'rt-toolbar-label' }, `${i18n.formatBarLabel || 'Format'}:`),
...marks
);
}
function richTextarea(attrs, ...children) {
return [renderFormatBar(), textarea(attrs, ...children)];
}
module.exports = { renderStyledText, renderStyledHtml, renderTextPreview, plainText, escapeHtml, safeExternalHref, renderFormatBar, richTextarea, setMentionResolver, INTERNAL_PATHS };

View file

@ -1,139 +0,0 @@
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderTextPreview(text, maxLength = 220) {
if (!text) return ''
let preview = String(text)
preview = preview
.replace(/```[\s\S]*?```/g, '')
.replace(/^>.*$/gm, '')
.replace(/^#{1,6}\s*/gm, '')
.replace(/^- /gm, '')
.replace(/^\d+\. /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[.*?\]\(.*?\)/g, '')
.replace(/\n+/g, ' ')
.trim()
if (preview.length > maxLength) {
preview = preview.slice(0, maxLength) + '...'
}
return preview
}
function renderTextWithStyles(text) {
if (!text) return ''
const i18n = getI18n()
let html = String(text)
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
.replace(/^---$/gim, '<hr/>')
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
html = html
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
.replace(/`([^`]+)`/gim, '<code>$1</code>')
html = html
.replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
)
.replace(/\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></video>`
)
.replace(/\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></audio>`
)
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
)
html = html
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
)
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
)
const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
html = html
.replace(/#(\w+)/g, (_, tag) =>
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
)
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
)
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
)
const lines = html.split('\n')
let result = ''
let inUL = false
let inOL = false
for (let line of lines) {
if (/^- /.test(line)) {
if (!inUL) {
result += '<ul>'
inUL = true
}
result += `<li>${line.replace(/^- /, '')}</li>`
continue
}
if (/^\d+\. /.test(line)) {
if (!inOL) {
result += '<ol>'
inOL = true
}
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
continue
}
if (inUL) {
result += '</ul>'
inUL = false
}
if (inOL) {
result += '</ol>'
inOL = false
}
result += line + '<br>'
}
if (inUL) result += '</ul>'
if (inOL) result += '</ol>'
return result
}
module.exports = { renderTextWithStyles, renderTextPreview }

View file

@ -1,92 +0,0 @@
const { a, img, video, audio } = require("../server/node_modules/hyperaxe");
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderUrl(text) {
if (typeof text !== 'string') return [text];
const blobImageRegex = /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobVideoRegex = /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobAudioRegex = /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobPdfRegex = /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const mdMentionRegex = /\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
const rawMentionRegex = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
const allMatches = [];
for (const m of text.matchAll(blobImageRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-image', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobVideoRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-video', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobAudioRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-audio', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobPdfRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-pdf', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(mdMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'md-mention', name: m[1], feedId: m[2] });
}
for (const m of text.matchAll(rawMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'raw-mention', feedId: m[1] });
}
for (const m of text.matchAll(urlRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'url', text: m[0] });
}
for (const m of text.matchAll(emailRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'email', text: m[0] });
}
allMatches.sort((a, b) => a.index - b.index);
const filtered = [];
let lastEnd = 0;
for (const m of allMatches) {
if (m.index < lastEnd) continue;
filtered.push(m);
lastEnd = m.index + m.length;
}
const result = [];
let cursor = 0;
for (const m of filtered) {
if (cursor < m.index) {
result.push(text.slice(cursor, m.index));
}
if (m.type === 'blob-image') {
result.push(img({ src: `/blob/${encodeURIComponent(m.blob)}`, alt: m.name || '', class: 'post-image' }));
} else if (m.type === 'blob-video') {
result.push(video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-audio') {
result.push(audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-pdf') {
const i18n = getI18n();
const label = m.name || i18n.pdfFallbackLabel || 'PDF';
result.push(a({ href: `/blob/${encodeURIComponent(m.blob)}`, class: 'post-pdf', target: '_blank' }, label));
} else if (m.type === 'md-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.name));
} else if (m.type === 'raw-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...'));
} else if (m.type === 'url') {
const href = m.text.startsWith('http') ? m.text : `https://${m.text}`;
result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
} else if (m.type === 'email') {
result.push(a({ href: `mailto:${m.text}` }, m.text));
}
cursor = m.index + m.length;
}
if (cursor < text.length) {
result.push(text.slice(cursor));
}
return result;
}
module.exports = { renderUrl };

View file

@ -0,0 +1,99 @@
const MSG_KEY_RE = /^%[A-Za-z0-9+/]{43}=\.sha256$/;
const isMsgKey = (value) => typeof value === 'string' && MSG_KEY_RE.test(value);
const pickMsgKeys = (value) => {
const list = Array.isArray(value) ? value : (value == null ? [] : [value]);
return Array.from(new Set(list.filter(isMsgKey)));
};
const isLocalPath = (value) => {
if (typeof value !== 'string' || !value.startsWith('/')) return false;
if (value.startsWith('//') || value.startsWith('/\\')) return false;
if (/[\r\n\0]/.test(value)) return false;
return true;
};
const SHARED_RETURN_PAGES = ['/activity', '/agenda', '/search', '/trending', '/favorites', '/opinions', '/mentions', '/melody', '/author', '/inhabitant', '/tribe'];
const isSharedReturnPage = (rt) => SHARED_RETURN_PAGES.some(p => rt === p || (rt.startsWith(p) && /^[?#/]/.test(rt.slice(p.length))));
const safeReturnTo = (ctx, fallback, allowedPrefixes) => {
const rt = ctx?.request?.body?.returnTo || ctx?.query?.returnTo;
if (!isLocalPath(rt)) return fallback;
if (isSharedReturnPage(rt)) return rt;
return Array.isArray(allowedPrefixes) && allowedPrefixes.some(p => rt.startsWith(p)) ? rt : fallback;
};
const refererPath = (referer, host) => {
if (!referer) return null;
try {
const u = new URL(referer);
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== host) return null;
const target = u.pathname + u.search + u.hash;
return isLocalPath(target) ? target : null;
} catch (_) {
return null;
}
};
const safeRefererRedirect = (ctx, fallback = '/') => {
const target = refererPath(ctx.request.header.referer, ctx.host);
ctx.redirect(target || fallback);
};
const isClearnetPath = (request) => {
const url = String((request && request.url) || '');
return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
};
const isTrustedRequest = (request, validHosts) => {
if (isClearnetPath(request)) return request.method === 'GET';
if (!Array.isArray(validHosts) || !validHosts.includes(request.hostname)) return false;
if (request.method === 'GET') return true;
const origin = request.header && request.header.origin;
const source = origin && origin !== 'null' ? origin : (request.header && request.header.referer);
if (source == null) return false;
try {
const u = new URL(source);
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || !validHosts.includes(u.hostname)) return false;
if (u.host !== String(request.host || '')) return false;
if (u.pathname.startsWith('/blob/')) return false;
} catch (_) {
return false;
}
return true;
};
const publicModeGuard = ({ isPublic, onBlocked }) => async (ctx, next) => {
if (isPublic && ctx.method !== 'GET') { onBlocked(ctx); return; }
await next();
};
const buildCsp = (isClearnet, { frames = false } = {}) => (isClearnet
? [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"media-src 'self' blob:",
"connect-src 'self'",
"frame-src 'none'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
]
: [
"default-src 'self'",
"script-src 'self' http://localhost:3000/js",
"style-src 'self'",
"img-src 'self'",
"media-src 'self' blob:",
"worker-src 'self' blob:",
frames ? "frame-src 'self'" : "frame-src 'none'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
]).join('; ');
module.exports = { isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };

View file

@ -1,14 +1,32 @@
"use strict";
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const SAFE_SRC = /^\/(?:blob|image)\/[^\s"'<>]+$/i;
const SAFE_HREF = /^(?:https?:\/\/|\/(?!\/)|#|mailto:)/i;
const SAFE_CLASS = /^(?:rt-[a-z0-9-]+|styled-link|mention|tag-link|tag-inline|zoom-link|pm-quote|wiki-link(?:-missing)?|internal-link(?:-id|-kind)?|lightbox(?:-[a-z]+)?|post-(?:audio|image|pdf|video))$/;
let purifier = null;
const getPurify = () => {
if (purifier) return purifier;
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const purify = DOMPurify(new JSDOM('').window);
purify.addHook('uponSanitizeAttribute', (node, data) => {
const name = String(data.attrName || '').toLowerCase();
const value = String(data.attrValue || '').trim();
if (name === 'src' && !SAFE_SRC.test(value)) data.keepAttr = false;
if (name === 'href' && !SAFE_HREF.test(value)) data.keepAttr = false;
if (name === 'class') {
const kept = value.split(/\s+/).filter(c => SAFE_CLASS.test(c));
if (kept.length) data.attrValue = kept.join(' '); else data.keepAttr = false;
}
});
purifier = purify;
return purifier;
};
const dropControlChars = (html) => String(html).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
const stripDangerousTags = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
return dropControlChars(getPurify().sanitize(input, {
ALLOWED_TAGS: [
'p', 'br',
'b', 'strong', 'i', 'em', 'u',
@ -19,13 +37,12 @@ const stripDangerousTags = (input) => {
ALLOWED_ATTR: [],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
FORBID_ATTR: ['style']
});
}));
};
const sanitizeHtml = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
return dropControlChars(getPurify().sanitize(input, {
ALLOWED_TAGS: [
'p', 'br', 'hr',
'b', 'strong', 'i', 'em', 'u', 's', 'del',
@ -39,7 +56,7 @@ const sanitizeHtml = (input) => {
ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
});
}));
};
module.exports = { stripDangerousTags, sanitizeHtml };

View file

@ -1,228 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildSmartContractPdf({ transfer, block, viewerId }) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 24;
const bodyBottom = footerH + 10;
const lineH = 14;
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const t = transfer || {};
const b = block || {};
const fmt = v => (v === undefined || v === null) ? '' : String(v);
const fmtAmount = () => {
const cat = String(t.category || 'ECONOMIC').toUpperCase();
const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
};
const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
const required = t.from === t.to ? 1 : 2;
const confirmedCount = confirmedBy.length;
const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
const sections = [];
sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'OASIS IDs' });
sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) });
sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'TERMS' });
sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
sections.push({ kind: 'kv', label: 'Required', value: String(required) });
sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
if (confirmedBy.length) {
for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
if (b && b.id) {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
} else {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'METADATA' });
sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
const lines = [];
for (const s of sections) {
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push(s);
}
}
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
const pages = [];
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
if (!pages.length) pages.push([]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`);
const prefix = 'Issued to: ';
const prefixW = prefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
for (const ln of pg) {
if (ln.kind === 'title') {
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'subtitle') {
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'section') {
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
} else if (ln.kind === 'kv') {
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildSmartContractPdf };

View file

@ -1,12 +1,50 @@
const fetch = require('../server/node_modules/node-fetch');
const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
const { join } = require('path');
const localpackage = join(__dirname, '../server/package.json');
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json'; // Official SNH-Oasis
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json';
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json';
let printed = false;
const repoRoot = join(__dirname, '..', '..');
const updateFlagFile = join(__dirname, '../server/.update_required');
const git = (args, cwd = repoRoot) => new Promise((resolve) => {
require('child_process').execFile('git', args, { cwd, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (err, out) => resolve(err ? '' : String(out || '').trim()));
});
const commitOf = (line) => {
const [hash, date, ...rest] = String(line || '').split('\t');
return hash ? { hash, date: date || '', subject: rest.join('\t') } : null;
};
const cleanOrigin = (url) => String(url || '').replace(/\/\/[^@\/]*@/, '//').replace(/\.git$/, '');
async function collectDetails(root = repoRoot) {
if (!existsSync(join(root, '.git'))) return null;
const installed = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s'], root));
await git(['fetch', '--quiet'], root);
const upstream = (await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], root)) || 'origin/master';
const available = commitOf(await git(['log', '-1', '--format=%h%x09%cI%x09%s', upstream], root));
const commits = (await git(['log', '--format=%h%x09%cI%x09%s', '-n', '10', `HEAD..${upstream}`], root)).split('\n').map(commitOf).filter(Boolean);
const count = Number(await git(['rev-list', '--count', `HEAD..${upstream}`], root)) || commits.length;
const stat = await git(['diff', '--shortstat', 'HEAD', upstream], root);
const num = (re) => { const m = stat.match(re); return m ? Number(m[1]) : 0; };
return {
installed, available, commits, count,
files: num(/(\d+) files? changed/), insertions: num(/(\d+) insertions?/), deletions: num(/(\d+) deletions?/),
origin: cleanOrigin(await git(['remote', 'get-url', 'origin'], root))
};
}
exports.collectDetails = collectDetails;
exports.readUpdateInfo = () => {
if (!existsSync(updateFlagFile)) return null;
try {
const info = JSON.parse(readFileSync(updateFlagFile, 'utf8'));
return info && info.required ? info : { required: true };
} catch (_) {
return { required: true };
}
};
async function extractVersionFromText(text) {
try {
@ -33,11 +71,22 @@ async function diffVersion(body, callback) {
const updateFlagPath = join(__dirname, "../server/.update_required");
if (remoteVersion !== localVersion) {
writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
const alreadyKnown = existsSync(updateFlagPath);
writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion }));
collectDetails().then((details) => {
if (details && existsSync(updateFlagPath)) writeFileSync(updateFlagPath, JSON.stringify({ required: true, version: remoteVersion, installedVersion: localVersion, details }));
}).catch(() => {});
if (!alreadyKnown) {
try {
const { notify, i18nNow } = require('./desktopNotify');
const i18n = i18nNow();
notify(i18n.notifyUpdateLabel, `${i18n.updateBannerText} (${remoteVersion})`);
} catch (_) {}
}
callback("required");
} else {
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
callback(""); // no updates required
callback("");
}
} catch (error) {
console.error("Error comparing versions:", error.message);
@ -49,15 +98,8 @@ async function checkMirror(callback) {
try {
const response = await fetch(remoteUrl2, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://raw.githubusercontent.com',
'Origin': 'https://raw.githubusercontent.com'
}
headers: { 'Accept': 'application/json, text/plain, */*' },
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
@ -72,20 +114,154 @@ async function checkMirror(callback) {
}
}
const networkAllowed = () => {
if (process.argv.includes('--offline') || process.env.OASIS_NETWORK_PAUSED === '1') return false;
try { if (require('../server/ssb_config').offline === true) return false; } catch (_) {}
try { if (require('../configs/config-manager.js').getConfig().networkPaused === true) return false; } catch (_) {}
return true;
};
const MOBILE_RELEASE_SOURCES = [
{ url: 'https://code.03c8.net/KrakensLab/oasis/releases', type: 'html' },
{ url: 'https://api.github.com/repos/epsylon/oasis/releases?per_page=20', type: 'json' }
];
const MOBILE_APK_RE = /^oasis-v(\d+\.\d+\.\d+)\.apk$/i;
const MOBILE_APK_HTML_RE = /oasis-v(\d+\.\d+\.\d+)\.apk/i;
const ECOIN_RELEASES_API = 'https://api.github.com/repos/epsylon/ecoin/releases?per_page=20';
const ECOIN_RELEASES_PAGE = 'https://github.com/epsylon/ecoin/releases';
const ECOIN_APK_RE = /^ecoin-wallet-v(\d+(?:\.\d+)*)\.apk$/i;
const MOBILE_FETCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
};
let ecoinWalletApk = '';
const versionParts = (v) => String(v || '').split('.').map(n => parseInt(n, 10) || 0);
const isNewerVersion = (remote, local) => {
const a = versionParts(remote), b = versionParts(local);
const len = Math.max(a.length, b.length, 3);
for (let i = 0; i < len; i++) {
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
}
return false;
};
const localMobileVersion = () => {
const fromEnv = String(process.env.OASIS_ANDROID_VERSION || '').split('-')[0].trim();
if (fromEnv) return fromEnv;
try {
return JSON.parse(readFileSync(localpackage, 'utf8')).version || '';
} catch (error) {
return '';
}
};
const pickReleaseFromJson = (releases) => {
for (const rel of Array.isArray(releases) ? releases : []) {
if (!rel || rel.draft) continue;
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
const match = MOBILE_APK_RE.exec(String((asset && asset.name) || ''));
if (match) {
return {
version: match[1],
page: String(rel.html_url || ''),
apk: String(asset.browser_download_url || '')
};
}
}
}
return null;
};
const pickReleaseFromHtml = (html, pageUrl) => {
const match = MOBILE_APK_HTML_RE.exec(String(html || ''));
return match ? { version: match[1], page: pageUrl, apk: '' } : null;
};
const fetchMobileRelease = async (source) => {
const isHtml = source.type === 'html';
const response = await fetch(source.url, {
method: 'GET',
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': isHtml ? 'text/html,application/xhtml+xml' : 'application/json, text/plain, */*' }
});
if (!response.ok) throw new Error(`Request failed with status ${response.status}`);
return isHtml
? pickReleaseFromHtml(await response.text(), source.url)
: pickReleaseFromJson(await response.json());
};
const refreshEcoinWalletApk = async () => {
try {
const response = await fetch(ECOIN_RELEASES_API, {
method: 'GET',
headers: { ...MOBILE_FETCH_HEADERS, 'Accept': 'application/json, text/plain, */*' }
});
if (!response.ok) return;
const releases = await response.json();
let best = null;
for (const rel of Array.isArray(releases) ? releases : []) {
if (!rel || rel.draft) continue;
for (const asset of Array.isArray(rel.assets) ? rel.assets : []) {
const match = ECOIN_APK_RE.exec(String((asset && asset.name) || ''));
if (!match || !asset.browser_download_url) continue;
if (!best || isNewerVersion(match[1], best.version)) best = { version: match[1], apk: String(asset.browser_download_url) };
}
}
if (best) ecoinWalletApk = best.apk;
} catch (error) {}
};
exports.ecoinWalletDownloadUrl = () => {
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return ECOIN_RELEASES_PAGE;
if (!ecoinWalletApk) refreshEcoinWalletApk();
return ecoinWalletApk || ECOIN_RELEASES_PAGE;
};
exports.getMobileRemoteVersion = async () => {
await refreshEcoinWalletApk();
if (String(process.env.OASIS_ANDROID_INSTALLER || '') === 'com.android.vending') return;
const localVersion = localMobileVersion();
if (!localVersion) return;
const updateFlagPath = join(__dirname, '../server/.update_required');
const found = [];
for (const source of MOBILE_RELEASE_SOURCES) {
try {
const candidate = await fetchMobileRelease(source);
if (candidate) found.push(candidate);
} catch (error) {}
}
if (!found.length) return;
const top = found.reduce((best, r) => (isNewerVersion(r.version, best.version) ? r : best), found[0]);
const sameVersion = found.filter(r => r.version === top.version);
const withApk = sameVersion.find(r => r.apk);
const release = { version: top.version, page: sameVersion[0].page, apk: withApk ? withApk.apk : '' };
if (isNewerVersion(release.version, localVersion)) {
try {
writeFileSync(updateFlagPath, JSON.stringify({
required: true,
version: release.version,
installedVersion: localVersion,
page: release.page || release.apk,
apk: release.apk
}));
} catch (error) {}
console.log(`\noasis@version: Oasis ${release.version} is available at ${release.apk || release.page}\n`);
} else if (existsSync(updateFlagPath)) {
try { unlinkSync(updateFlagPath); } catch (error) {}
}
};
exports.getRemoteVersion = async () => {
if (existsSync('../../.git')) {
if (process.env.OASIS_MOBILE === '1') return exports.getMobileRemoteVersion();
if (!networkAllowed()) return;
if (existsSync(join(__dirname, '..', '..', '.git'))) {
try {
const response = await fetch(remoteUrl, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://code.03c8.net',
'Origin': 'https://code.03c8.net'
}
headers: { 'Accept': 'application/json, text/plain, */*' },
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {

View file

@ -1,116 +1,119 @@
const buildVoteTally = (messages) => {
const nodes = new Map();
const ballots = [];
const INSTITUTIONAL_VOTE_TAG = /^(gov|govMethod|courtsCase|courtsMethod):/;
for (const m of messages) {
const msOf = (v) => {
const t = Date.parse(v);
return Number.isFinite(t) ? t : null;
};
const isLater = (a, b) => a.ts > b.ts || (a.ts === b.ts && a.seq > b.seq);
const legacyChoice = (kid, parent, options) => {
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters : [];
const pVoters = Array.isArray(parent.content.voters) ? parent.content.voters : [];
if (!kVoters.includes(kid.author) || pVoters.includes(kid.author)) return null;
const kVotes = kid.content.votes || {};
const pVotes = parent.content.votes || {};
let choice = null;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(pVotes[o]) || 0);
if (d === 0) continue;
if (d !== 1 || choice !== null) return null;
choice = o;
}
return choice;
};
const buildVoteResults = (messages) => {
const nodes = new Map();
const ballotsByTarget = new Map();
for (const m of messages || []) {
const v = m && m.value;
const c = v && v.content;
if (!c) continue;
if (c.type === 'votesVote') { if (c.target) ballots.push({ target: c.target, author: v.author, choice: c.choice }); continue; }
const ts = Number(v.timestamp || m.timestamp || 0) || 0;
const seq = Number(v.sequence || 0) || 0;
if (c.type === 'votesVote') {
if (typeof c.target !== 'string' || !v.author) continue;
if (!ballotsByTarget.has(c.target)) ballotsByTarget.set(c.target, []);
ballotsByTarget.get(c.target).push({ author: v.author, choice: c.choice, ts, seq });
continue;
}
if (c.type !== 'votes') continue;
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts, seq });
}
const prev = new Map();
for (const [k, n] of nodes) {
const t = n.content.replaces;
if (typeof t === 'string' && nodes.has(t)) prev.set(k, t);
}
const childrenOf = new Map();
for (const [k, p] of prev) {
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p).push(k);
if (typeof t === 'string' && t !== k && nodes.has(t)) prev.set(k, t);
}
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
const groups = new Map();
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
const tallyByKey = new Map();
const results = new Map();
for (const [root, keys] of groups.entries()) {
const rootNode = nodes.get(root);
if (!rootNode) continue;
const options = Array.isArray(rootNode.content.options) ? rootNode.content.options : [];
let votesMap = Object.assign({}, rootNode.content.votes || {});
let voters = Array.isArray(rootNode.content.voters) ? rootNode.content.voters.slice() : [];
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
let cur = root;
let g = 0;
let creatorChoice = null;
let creatorCounted = false;
while (g++ < 100000) {
const kids = (childrenOf.get(cur) || []).map(k => nodes.get(k)).filter(Boolean).sort((a, b) => a.ts - b.ts);
if (!kids.length) break;
let advanced = false;
for (const kid of kids) {
const kVotes = Object.assign({}, kid.content.votes || {});
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters.slice() : [];
for (const o of options) if (!(o in kVotes)) kVotes[o] = 0;
if (kid.author === rootNode.author) {
if (!creatorCounted && kVoters.includes(rootNode.author) && !voters.includes(rootNode.author)) {
creatorCounted = true;
let inc = null;
let incCount = 0;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d >= 1) { inc = o; incCount++; }
}
if (incCount === 1) creatorChoice = inc;
}
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
}
if (voters.includes(kid.author)) continue;
if (kVoters.length !== voters.length + 1) continue;
const added = kVoters.filter(x => !voters.includes(x));
if (added.length !== 1 || added[0] !== kid.author) continue;
let deltas = 0;
let deltaOk = true;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d === 0) continue;
if (d === 1) { deltas++; continue; }
deltaOk = false;
break;
}
if (!deltaOk || deltas !== 1) continue;
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
const creator = rootNode.author;
const tags = Array.isArray(rootNode.content.tags) ? rootNode.content.tags : [];
const frozen = tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t)));
let current = rootNode;
if (!frozen) {
for (const k of keys) {
const n = nodes.get(k);
if (n.author === creator && isLater(n, current)) current = n;
}
if (!advanced) break;
}
const options = Array.isArray(current.content.options) ? current.content.options.filter(o => typeof o === 'string') : [];
const deadline = typeof current.content.deadline === 'string' ? current.content.deadline : null;
const opensAt = rootNode.ts;
const closesAt = deadline ? msOf(deadline) : null;
const cast = [];
for (const k of keys) {
for (const b of ballotsByTarget.get(k) || []) cast.push(b);
const n = nodes.get(k);
if (n.author === creator || !prev.has(k)) continue;
const choice = legacyChoice(n, nodes.get(prev.get(k)), options);
if (choice !== null) cast.push({ author: n.author, choice, ts: n.ts, seq: n.seq });
}
if (voters.includes(rootNode.author)) {
voters = voters.filter(x => x !== rootNode.author);
if (creatorChoice && (Number(votesMap[creatorChoice]) || 0) > 0) votesMap[creatorChoice] = votesMap[creatorChoice] - 1;
}
const voterSet = new Set(voters);
let totalVotes = voters.length;
const chainKeys = new Set(keys);
for (const b of ballots) {
if (!chainKeys.has(b.target)) continue;
if (b.author === rootNode.author) continue;
if (!b.author || voterSet.has(b.author)) continue;
const latest = new Map();
for (const b of cast) {
if (!b.author || b.author === creator) continue;
if (!options.includes(b.choice)) continue;
voterSet.add(b.author);
voters.push(b.author);
votesMap[b.choice] = (votesMap[b.choice] || 0) + 1;
totalVotes += 1;
if (b.ts < opensAt) continue;
if (closesAt !== null && b.ts > closesAt) continue;
const known = latest.get(b.author);
if (!known || isLater(b, known)) latest.set(b.author, b);
}
const tally = { votes: votesMap, voters, totalVotes };
for (const k of keys) tallyByKey.set(k, tally);
const votes = {};
for (const o of options) votes[o] = 0;
const voters = [];
for (const b of [...latest.values()].sort((a, b) => a.ts - b.ts || a.seq - b.seq)) {
votes[b.choice] = (votes[b.choice] || 0) + 1;
voters.push(b.author);
}
const result = { votes, voters, totalVotes: voters.length, creator, rootId: root, deadline, options };
for (const k of keys) results.set(k, result);
}
return results;
};
const buildVoteTally = (messages) => {
const shared = new Map();
const tallyByKey = new Map();
for (const [k, r] of buildVoteResults(messages)) {
if (!shared.has(r)) shared.set(r, { votes: r.votes, voters: r.voters, totalVotes: r.totalVotes });
tallyByKey.set(k, shared.get(r));
}
return tallyByKey;
};
module.exports = { buildVoteTally };
module.exports = { buildVoteTally, buildVoteResults };

View file

@ -1,17 +0,0 @@
const fs = require("fs");
const path = require("path");
const STORAGE_DIR = path.join(__dirname, "..", "configs");
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
function ensure() {
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
}
function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
module.exports = { getAddress, setAddress };

Binary file not shown.

View file

@ -0,0 +1,93 @@
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13l3-8h12l3 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M3 13h5l1 3h6l1-3h5"/></svg>

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

View file

@ -0,0 +1,507 @@
/* Lanzador, barras, tarjetas e identidades con el tema Acampada (rama movil).
*
* OasisMobile.css (cargado antes que themes/Acampada.css) esta pensado para fondo
* oscuro; esta hoja va detras del tema, solo con Acampada, y cambia lo que alli
* es amarillo sobre negro por negro sobre blanco con el cian neon de
* acampada26s.net. Las variables --a26-* las define themes/Acampada.css. */
body { background: var(--a26-bg) !important; }
/* ---------- barra de arriba ---------- */
.oasis-mobile-topbar {
background: var(--a26-bg) !important;
border: 0 !important;
border-bottom: var(--a26-bd) !important;
box-shadow: none !important;
}
.oasis-mobile-topbar .omt-menus { background: transparent !important; border: 0 !important; box-shadow: none !important; gap: 10px; }
.oasis-mobile-topbar .omt-menus .oasis-menu-btn {
font-family: var(--a26-tit) !important;
font-weight: 400 !important;
text-transform: uppercase;
font-size: 20px !important;
letter-spacing: .02em;
min-height: 44px !important;
padding: 4px 16px !important;
background: var(--a26-bg) !important;
color: var(--a26-fg) !important;
border: var(--a26-bd) !important;
border-radius: var(--a26-radius) !important;
box-shadow: 0 4px 0 var(--a26-fg) !important;
}
.oasis-mobile-topbar .omt-menus .oasis-menu-btn span { color: var(--a26-fg) !important; }
.oasis-mobile-topbar .omt-menus .oasis-menu-btn.active { background: var(--a26-neon) !important; }
.oasis-mobile-topbar .omt-menus .oasis-menu-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--a26-fg) !important; }
.oasis-mobile-topbar .omt-logo img, .oasis-mobile-topbar .omt-avatar img {
border: var(--a26-bd) !important;
border-radius: 50%;
}
/* la cabecera de escritorio de upstream no pinta nada en el movil */
.header { background: transparent !important; border: 0 !important; box-shadow: none !important; padding: 0 !important; }
.header .top-bar-left, .header .top-bar-right { display: none !important; }
/* ---------- lanzador de modulos (views/hive_views.js) ---------- */
.launcher, .ln-group, .ln-grid { background: transparent !important; border: 0 !important; box-shadow: none !important; }
.header .launcher { margin: 18px auto 10px !important; }
.ln-group + .ln-group { margin-top: 22px !important; }
.ln-title {
font-family: var(--a26-tit) !important;
font-weight: 400 !important;
font-size: 17px !important;
letter-spacing: .03em !important;
color: var(--a26-fg) !important;
border-bottom: var(--a26-bd) !important;
padding: 0 0 4px !important;
margin: 0 2px 14px !important;
}
.ln-grid { gap: 16px 10px !important; }
.ln-ico {
width: 58px !important; height: 58px !important;
background: var(--a26-bg) !important;
color: var(--a26-fg) !important;
border: var(--a26-bd) !important;
border-radius: var(--a26-radius) !important;
box-shadow: 0 4px 0 var(--a26-fg) !important;
font-size: 24px !important;
transition: transform .12s ease-out, box-shadow .12s ease-out;
}
.ln-app:active .ln-ico, .ln-app:focus-visible .ln-ico {
transform: translateY(3px);
box-shadow: 0 1px 0 var(--a26-fg) !important;
background: var(--a26-neon) !important;
border-color: var(--a26-fg) !important;
}
.ln-app:hover, .ln-app:focus-visible { background: transparent !important; }
.ln-lbl { color: var(--a26-fg) !important; font-size: 12px !important; font-weight: 600 !important; }
/* ---------- barra de abajo ---------- */
.oasis-bottombar {
background: var(--a26-bg) !important;
border: 0 !important;
border-top: var(--a26-bd) !important;
box-shadow: none !important;
}
.oasis-bottombar { padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)) !important; gap: 2px !important; }
.oasis-bottombar .bb-item { gap: 2px !important; }
.oasis-bottombar .bb-ico { color: var(--a26-fg) !important; font-size: 21px !important; }
.oasis-bottombar .bb-lbl {
color: var(--a26-fg) !important;
font-size: 10.5px !important;
font-weight: 600;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.oasis-bottombar a.bb-item.current .bb-ico { background: var(--a26-neon) !important; color: var(--a26-fg) !important; }
.oasis-bottombar a.bb-item.current .bb-lbl { color: var(--a26-fg) !important; }
.oasis-bottombar .bb-badge { background: var(--a26-neon) !important; color: var(--a26-fg) !important; border: 2px solid var(--a26-fg) !important; }
/* Nodos e Invitaciones: juntos y estrechos {
background: var(--a26-bg) !important;
border: var(--a26-bd-fino) !important;
border-radius: var(--a26-radius) !important;
padding: 2px !important;
gap: 0 !important;
}
/* ---------- personalizar la barra ---------- */
.bb-manage-btn, .bb-pick-btn {
background: var(--a26-bg) !important;
color: var(--a26-fg) !important;
border: var(--a26-bd) !important;
border-radius: var(--a26-radius) !important;
box-shadow: 0 4px 0 var(--a26-fg) !important;
font-family: var(--a26-txt) !important;
text-transform: none;
}
.bb-manage-ico, .bb-manage-x, .bb-pick-ico { color: var(--a26-fg) !important; }
.bb-manage-lbl, .bb-pick-lbl { color: var(--a26-fg) !important; font-weight: 600; }
.bb-manage h3, .bb-manage-add h3 { color: var(--a26-fg) !important; }
.bb-manage-empty, .bb-manage-full { color: var(--a26-muted) !important; }
/* ---------- menu desplegable de upstream (si se abre) ---------- */
.oasis-nav-header { background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; }
.oasis-nav-list li a, .oasis-nav-list li a .emoji { color: var(--a26-fg) !important; }
.oasis-nav-list li a.current { background: var(--a26-neon) !important; }
/* ---------- foro y chat: los colores de hive.css pensados para fondo negro ---------- */
.forum-comment.level-1, .forum-comment.level-2, .forum-comment.level-3, .forum-comment.level-4 { border-left: 3px solid var(--a26-fg) !important; }
.forum-comment.highlighted-reply { background: #e5fcff !important; border-left: 5px solid var(--a26-neon) !important; }
.chat-message-form .filter-btn::before, .forum-send-btn::before { color: var(--a26-fg) !important; }
/* ---------- lo que mobile.css de upstream pinta en amarillo con !important ---------- */
/* mobile.css va detras del tema, asi que estas reglas tienen que ir aqui */
.create-button, button.create-button, a.create-button {
background-color: var(--a26-fg) !important;
border-color: var(--a26-fg) !important;
color: var(--a26-bg) !important;
}
.filter-btn.active { background-color: var(--a26-neon) !important; border-color: var(--a26-fg) !important; color: var(--a26-fg) !important; }
.peers-conn-actions button, .filter-box__button { background-color: var(--a26-bg) !important; color: var(--a26-fg) !important; }
.oasis-id-copy { background: var(--a26-soft) !important; }
.gov-overview tbody tr { border: var(--a26-bd-fino) !important; }
button, input[type="submit"], input[type="button"], .filter-btn, .create-button { min-height: 48px !important; font-size: 18px !important; }
/* aviso de bienvenida: el boton de empezar en negro y la X sin caja */
.welcome-banner .welcome-banner-start {
background: var(--a26-fg) !important;
color: var(--a26-bg) !important;
box-shadow: 0 4px 0 var(--a26-bg) !important;
border: var(--a26-bd) !important;
}
.welcome-banner .welcome-banner-close-btn {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
color: var(--a26-fg) !important;
font-size: 22px !important;
opacity: 1 !important;
}
/* pie: gris y subrayado, sin amarillos */
.oasis-footer .oasis-footer-center a, .oasis-footer .oasis-footer-center span,
.oasis-footer .oasis-footer-center a span { color: var(--a26-muted) !important; }
/* la columna de contenido sin marco: el marco lo llevan las tarjetas */
.main-column { border: 0 !important; box-shadow: none !important; border-radius: 0 !important; }
/* la sombra de Personal/Social no se corta contra la raya de la barra */
.oasis-mobile-topbar { padding-bottom: 12px !important; }
/* ---------- asistente de bienvenida: botones grandes de un toque ---------- */
.welcome-step { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; }
.welcome-banner .welcome-banner-start { font-size: 20px !important; min-height: 48px !important; padding: 8px 22px !important; }
/* mobile.css pinta en naranja los botones sin clase y todos los .filter-btn */
button:not([class]), input[type="submit"]:not([class]), input[type="button"]:not([class]),
.filter-btn, a.filter-btn, .conn-actions button, .peers-conn-actions button, .filter-box__button {
background-color: var(--a26-bg) !important;
color: var(--a26-fg) !important;
border: var(--a26-bd) !important;
border-radius: var(--a26-radius) !important;
font-weight: 400 !important;
font-size: 18px !important;
height: auto !important;
min-height: 48px !important;
}
.filter-btn.active, a.filter-btn.active { background-color: var(--a26-neon) !important; }
/* pantallas estrechas (360px): Nodos e Invitaciones solo con icono, para que
los cuatro atajos no se corten */
@media (max-width: 380px) {
.oasis-bottombar .bb-pair .bb-lbl { display: none !important; }
.oasis-bottombar .bb-pair .bb-ico { font-size: 22px !important; }
.oasis-bottombar .bb-pair .bb-item { padding: 2px 6px !important; }
.oasis-bottombar .bb-lbl { font-size: 10px !important; }
.welcome-banner .update-banner-icon { display: none !important; }
}
/* ---------- identidades (views/identities_view.js): una tarjeta por identidad ---------- */
.identities .identity-list { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.identities .identity-row { display: flex; flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 14px; }
.identities .identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; border: 0; background: transparent; padding: 0; margin: 0; box-shadow: none; }
.identities .identity-feed { font-family: monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.identities .identity-warn { font-size: 12px; }
.identities .identity-row form { margin: 0; }
.identities .identity-row button { width: 100%; margin: 0; }
.identities .identity-active { text-align: center; font-weight: bold; padding: 10px 12px; border-radius: var(--a26-radius); }
.identities .identity-create { display: flex; flex-direction: column; gap: 10px; margin: 0 0 16px; }
.identities .identity-create input { width: 100%; box-sizing: border-box; margin: 0; }
.identities .identity-create button { width: 100%; margin: 0; }
.identities .identity-actions { display: flex; flex-direction: column; gap: 12px; align-items: stretch; border: 0; background: transparent; padding: 0; }
.identities .identity-actions form { margin: 0; }
.identities .identity-actions button { width: 100%; }
.identities .identity-legacy { text-align: center; text-decoration: underline; }
.identities .identity-row { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; background: var(--a26-bg) !important; }
.identities .identity-name { font-family: var(--a26-tit) !important; font-weight: 400 !important; text-transform: uppercase; font-size: 22px !important; }
.identities .identity-feed { color: var(--a26-muted) !important; }
.identities .identity-active { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; color: var(--a26-fg) !important; }
.identities .identity-warn { color: #c4003b !important; }
/* las barras de filtros de cada modulo: upstream las encoge (.75em) y les pone
borde amarillo con selectores compuestos; aqui, del mismo tamaño que el resto */
.tribe-mode-buttons .create-button, .ui-toolbar--filters .create-button, .mode-buttons .create-button,
.mode-buttons-row .create-button, .filters .create-button, .filter-group .create-button,
.activity-sub-filter .create-button, .activity-filter-grid .create-button {
border: var(--a26-bd) !important;
border-color: var(--a26-fg) !important;
background: var(--a26-fg) !important;
color: var(--a26-bg) !important;
font-size: 18px !important;
padding: 8px 16px !important;
min-height: 48px !important;
border-radius: var(--a26-radius) !important;
line-height: 1.2 !important;
}
.tribe-mode-buttons .filter-btn, .ui-toolbar--filters .filter-btn, .mode-buttons .filter-btn,
.mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .filters .filter-btn,
.filter-group .filter-btn, .activity-sub-filter .filter-btn, .activity-filter-grid .filter-btn {
border: var(--a26-bd) !important;
font-size: 16px !important;
padding: 6px 14px !important;
min-height: 44px !important;
border-radius: var(--a26-radius) !important;
line-height: 1.2 !important;
}
/* ---------- lo que tiene que seguir siendo pequeño ---------- */
/* los iconos de accion de cada tarjeta (borrar, fijar, enlace...): en fila y redondos */
.content-actions {
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
justify-content: flex-end !important; align-items: center !important; gap: 6px !important;
}
.main-column .content-actions form, .content-actions .content-action-form { display: inline-flex !important; margin: 0 !important; width: auto !important; }
.btn-singleview, button.btn-singleview, a.btn-singleview {
width: 34px !important; height: 34px !important; min-width: 34px !important; min-height: 34px !important;
padding: 0 !important; margin: 0 !important;
display: inline-flex !important; align-items: center !important; justify-content: center !important;
font-family: var(--a26-txt) !important; font-size: 15px !important; text-transform: none !important;
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: var(--a26-bd-fino) !important; border-radius: 50% !important; box-shadow: none !important;
}
.btn-singleview.btn-delete { color: #c4003b !important; border-color: #c4003b !important; }
/* barras de filtros: chips en fila que se reparten el ancho, no un boton por linea */
.mode-buttons, .mode-buttons-row, .mode-buttons-cols, .tribe-mode-buttons, .ui-toolbar--filters, .filters {
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
justify-content: center !important; gap: 8px !important; margin: 0 0 14px !important;
}
.mode-buttons form, .mode-buttons-row form, .mode-buttons-cols form, .tribe-mode-buttons form, .filters > form:not(.filter-box) {
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
justify-content: center !important; gap: 8px !important; width: auto !important; max-width: 100% !important; margin: 0 !important;
}
.mode-buttons .filter-btn, .mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .tribe-mode-buttons .filter-btn,
.ui-toolbar--filters .filter-btn, .filters .filter-btn, .activity-filter-grid .filter-btn, .activity-sub-filter .filter-btn {
width: auto !important; flex: 0 0 auto !important;
min-height: 38px !important; height: auto !important;
padding: 4px 12px !important; margin: 0 !important;
font-size: 15px !important; box-shadow: 0 3px 0 var(--a26-fg) !important;
border: var(--a26-bd-fino) !important;
}
.mode-buttons .create-button, .mode-buttons-row .create-button, .tribe-mode-buttons .create-button, .filters .create-button {
flex: 1 1 100% !important; width: 100% !important; margin-top: 4px !important;
}
/* chips de la actividad (activity-filter-*) */
.activity-filter-chips button, .activity-filter-grid button, .activity-sub-filter button, .activity-filter-col button {
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important;
}
.activity-filter-chips button.active, .activity-filter-grid button.active, .activity-sub-filter button.active, .activity-filter-col button.active {
background: var(--a26-neon) !important;
}
/* fechas y autores al pie de cada tarjeta: texto, no botones */
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link {
font-family: var(--a26-txt) !important; text-transform: none !important; letter-spacing: 0 !important;
font-size: 13px !important; box-shadow: none !important; border: 0 !important;
background: transparent !important; padding: 0 !important; min-height: 0 !important;
}
a.user-link { font-weight: 700 !important; text-decoration: underline !important; }
.card-footer { display: flex !important; flex-wrap: wrap; gap: 10px; align-items: center; color: var(--a26-muted) !important; }
/* buscador de cada modulo */
.filter-box { display: flex !important; gap: 8px !important; align-items: stretch !important; }
.filter-box__input::placeholder, input::placeholder, textarea::placeholder { color: #777 !important; }
.filter-box__button { font-size: 15px !important; padding: 4px 14px !important; box-shadow: none !important; }
/* el pie de cada pagina y el id del perfil, en gris */
.main-column [class*="feed-id"], .profile .feed-id, code { color: var(--a26-muted) !important; }
/* una sola caja por tarjeta: lo de dentro (cabecera, titulo, texto) sin marco */
.main-column section, .main-column [class*="-card-"], .main-column .card-section, .main-column .card-header,
.main-column .card-meta-row, .main-column .shop-title-row, .main-column [class*="-grid"],
.main-column .feed-container, .main-column [class$="-card"] [class$="-card"] {
border: 0 !important; box-shadow: none !important; background: transparent !important; border-radius: 0 !important;
}
.main-column [class$="-card"], .main-column [class*="-card "] {
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
background: var(--a26-bg) !important; padding: 12px !important; margin: 0 0 14px !important;
}
.main-column .card-header { padding: 0 0 8px !important; border-bottom: var(--a26-bd-fino) !important; margin-bottom: 8px !important; }
.main-column [class$="-card"] .card-footer { border-top: var(--a26-bd-fino) !important; padding-top: 8px !important; margin-top: 8px !important; }
.main-column hr { border: 0 !important; border-top: var(--a26-bd-fino) !important; }
/* titulos de las tarjetas: enlaces en negro */
.main-column h1 a, .main-column h2 a, .main-column h3 a, .main-column [class*="title"] a {
color: var(--a26-fg) !important; text-decoration: none !important;
}
.main-column [class*="-card"] .tribe-card-title, .main-column [class*="-card"] h2 { font-size: 22px !important; margin: 4px 0 8px !important; }
/* la etiqueta del tipo (IMAGENES, FOROS...) y los chips de estado */
.pm-exposition-chip, .card-chips-row > span {
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
}
.pm-exposition-chip *, .card-chips-row > span * { color: var(--a26-bg) !important; }
.pm-exposition-text { font-family: var(--a26-tit) !important; letter-spacing: .04em; font-size: 13px !important; }
.content-actions .btn-singleview.btn-content, .content-actions a.btn-content { border-color: var(--a26-fg) !important; color: var(--a26-fg) !important; }
/* opiniones y votos */
.opinions-summary, .vote-btn, .score-btn {
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: var(--a26-bd-fino) !important; border-radius: 10px !important; box-shadow: none !important;
font-family: var(--a26-txt) !important; text-transform: none !important; font-size: 14px !important;
}
.post-image { border: var(--a26-bd-fino) !important; border-radius: 10px; }
/* tablas de detalle (fecha, estado, lugar...): filas claras, la etiqueta en gris */
.main-column table, .main-column table tr, .main-column table td, .main-column table th, .main-column table tbody {
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border-color: var(--a26-fg) !important;
}
.main-column table { border: var(--a26-bd-fino) !important; border-radius: 10px !important; }
.main-column table tr + tr td { border-top: 1px solid #ccc !important; }
.main-column table td:first-child, .main-column table th, .main-column [class*="-info-label"] {
color: var(--a26-muted) !important; font-weight: 700 !important; font-size: 13px !important;
text-transform: uppercase; letter-spacing: .03em;
}
/* perfil: los datos del habitante como lineas claras, sin cajas negras */
.profile-sensors-box, .profile-sensors-box > span, .karma-line, .inhabitant-last-activity, .gpg-line,
.inhabitant-stats-box, .inhabitant-stat, .profile-side-relationship .status {
background: transparent !important; color: var(--a26-fg) !important; border: 0 !important; box-shadow: none !important;
}
.profile-sensors-box { display: flex !important; flex-direction: column !important; gap: 4px !important; align-items: center !important; }
.profile-sensors-box strong, .profile-sensors-box a, .device-mobile { color: var(--a26-fg) !important; }
.inhabitant-stat { border: var(--a26-bd-fino) !important; border-radius: 10px !important; padding: 4px 10px !important; }
.profile-side-relationship .status { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 10px !important; }
.main-column [class*="feed-id"], .main-column a[href^="/author/"]:not(.user-link) { color: var(--a26-muted) !important; }
.profile img.avatar, .inhabitant-photo { border: var(--a26-bd) !important; }
/* etiquetas (#asamblea) y la tasa ECO: chips claros */
.card-tags { background: transparent !important; border: 0 !important; padding: 0 !important; display: flex !important; flex-wrap: wrap; gap: 6px; }
.card-tags .tag-link, a.tag-link {
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 8px !important;
text-decoration: none !important; font-weight: 700;
}
.eco-tax-chip, [class*="eco-tax-chip-"] {
background: var(--a26-soft) !important; color: var(--a26-muted) !important;
border: 1px solid #bbb !important; border-radius: 8px !important;
}
/* los filtros de la actividad son enlaces .activity-chip */
.activity-chip {
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: var(--a26-bd-fino) !important; border-radius: 10px !important;
padding: 4px 10px !important; font-weight: 700 !important; text-decoration: none !important;
}
.activity-chip.active { background: var(--a26-neon) !important; }
.activity-chip:hover { color: var(--a26-fg) !important; border-color: var(--a26-fg) !important; }
/* ---------- fichas de filtro de 1.3.3 (OasisMobile.css las pinta en amarillo y gris) ---------- */
/* mismo selector que alli: gana por ir detras */
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) {
justify-content: center !important;
gap: 8px !important;
padding: 4px 0 !important;
}
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
font-family: var(--a26-tit) !important;
font-weight: 400 !important;
font-size: 15px !important;
letter-spacing: .02em !important;
padding: 4px 12px !important;
min-height: 38px !important;
border: var(--a26-bd-fino) !important;
border-radius: var(--a26-radius) !important;
background: var(--a26-bg) !important;
color: var(--a26-fg) !important;
box-shadow: 0 3px 0 var(--a26-fg) !important;
}
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn.active:not(.create-button) {
background: var(--a26-neon) !important;
border-color: var(--a26-fg) !important;
color: var(--a26-fg) !important;
}
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .create-button { min-height: 48px !important; }
/* acciones de la tarjeta: iconos SVG en negro dentro del circulo */
.main-column .content-actions .btn-singleview { width: 36px !important; height: 36px !important; }
.btn-singleview .btn-icon { width: 18px !important; height: 18px !important; }
.btn-singleview.btn-pin-on { background: var(--a26-neon) !important; }
/* hilos del foro (lista tipo archivo de 1.3.3): tarjeta negra sin la raya naranja */
.main-column .mailing-archive-item {
border: var(--a26-bd-fino) !important;
border-radius: var(--a26-radius) !important;
background: var(--a26-bg) !important;
padding: 12px !important;
margin: 0 0 14px !important;
}
.main-column .mailing-archive-item a { color: var(--a26-fg) !important; }
.main-column a.forum-category {
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
font-family: var(--a26-tit) !important; text-decoration: none !important; letter-spacing: .03em;
}
/* restos de naranja de style.css y mobile.css */
.oasis-id-copy, input.oasis-id-copy, input.oasis-id-copy[readonly] {
background: var(--a26-soft) !important; border: var(--a26-bd-fino) !important; color: var(--a26-muted) !important;
}
.oasis-menu-btn.oasis-menu-open, .oasis-menu-btn.oasis-menu-close {
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important; color: var(--a26-fg) !important;
}
img.inhabitant-photo-details { border: var(--a26-bd) !important; }
.welcome-action { color: var(--a26-fg) !important; }
label.welcome-ux-option {
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important;
border-radius: var(--a26-radius) !important; box-shadow: 0 4px 0 var(--a26-fg) !important;
}
label.welcome-ux-option:has(input:checked) { background: var(--a26-neon) !important; }
/* chat: burbujas con marco negro y las reacciones como chips pequeños
(el estilo general de botones de Acampada las hacia de 48px a ancho completo) */
.chat-bubble-row .chat-message {
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: var(--a26-bd-fino) !important; border-radius: 14px 14px 14px 4px !important;
}
.chat-bubble-row-self .chat-message { background: #e5fcff !important; border-radius: 14px 14px 4px 14px !important; }
.chat-bubble-sender, .chat-bubble-sender-owner { color: var(--a26-fg) !important; }
.chat-bubble-time { color: var(--a26-muted) !important; }
.main-column .chat-msg-actions { display: flex !important; flex-wrap: wrap !important; gap: 4px !important; }
.main-column .chat-msg-actions form, .main-column form.chat-react-form { display: inline-flex !important; width: auto !important; margin: 0 !important; }
.main-column button.chat-react-btn, button.chat-react-btn {
width: auto !important; min-height: 0 !important; height: auto !important;
padding: 1px 8px !important; margin: 0 !important;
font-size: 13px !important; line-height: 1.4 !important;
font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif !important;
text-transform: none !important; letter-spacing: 0 !important;
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: 1px solid var(--a26-fg) !important; border-radius: 10px !important; box-shadow: none !important;
}
/* el 📌 va en la esquina: el texto deja su hueco (el padding general de la burbuja lo pisaba) */
.chat-bubble-row .chat-message.chat-has-pin-toggle { position: relative !important; padding-right: 44px !important; }
.chat-day-chip { background: var(--a26-fg) !important; color: var(--a26-bg) !important; border: 0 !important; }
.chat-reply-cancel,
.chat-message-meta .chat-reply-btn { color: var(--a26-fg) !important; }
/* ---------- skins en Ajustes ---------- */
.skin-card {
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
box-shadow: 0 5px 0 var(--a26-fg) !important;
}
.skin-card .skin-shot { border: var(--a26-bd-fino); }
.skin-card .skin-name {
font-family: var(--a26-tit) !important; font-weight: 400 !important;
text-transform: uppercase; letter-spacing: .02em; font-size: 21px !important;
}
.skin-card.is-current { background: var(--a26-neon) !important; color: var(--a26-fg) !important; border: var(--a26-bd) !important; }
.skin-card:active { transform: translateY(4px); box-shadow: 0 1px 0 var(--a26-fg) !important; background: var(--a26-neon) !important; }
/* ---------- letra y botones mas grandes, para leer sin gafas ---------- */
body { font-size: 18px; }
.ln-ico { width: 64px !important; height: 64px !important; font-size: 28px !important; }
.ln-lbl { font-size: 13px !important; font-weight: 700 !important; white-space: normal !important; overflow-wrap: normal !important; word-break: normal !important; line-height: 1.15 !important; letter-spacing: -.02em; overflow: visible !important; text-overflow: clip !important; max-width: calc(100% + 10px) !important; }
.ln-title { font-size: 20px !important; }
.ln-grid { gap: 20px 8px !important; }
.oasis-bottombar .bb-ico { font-size: 25px !important; }
.oasis-bottombar .bb-lbl { font-size: 12.5px !important; font-weight: 700 !important; }
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
font-size: 18px !important; min-height: 46px !important; padding: 6px 16px !important;
}
.activity-chip { font-size: 16px !important; padding: 6px 12px !important; }
.main-column .content-actions .btn-singleview { width: 44px !important; height: 44px !important; min-width: 44px !important; }
.btn-singleview .btn-icon { width: 22px !important; height: 22px !important; }
.main-column button.chat-react-btn, button.chat-react-btn { font-size: 17px !important; padding: 3px 10px !important; }
.oasis-mobile-topbar .omt-menus .oasis-menu-btn { font-size: 22px !important; min-height: 48px !important; }
.tags-header p { font-size: 16px !important; }
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link { font-size: 15px !important; }
input, textarea, select { font-size: 18px !important; min-height: 50px; }

View file

@ -89,7 +89,6 @@ code {
}
.search-input,
.feed-search-input,
.activity-search-input {
width: 100% !important;
max-width: 100% !important;
@ -262,7 +261,7 @@ h3 { font-size: 1em !important; }
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
justify-content: center !important;
justify-content: flex-start !important;
align-items: center !important;
width: 100% !important;
max-width: 100% !important;
@ -289,8 +288,8 @@ h3 { font-size: 1em !important; }
}
.inhabitant-action .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
padding: 2px 9px !important;
font-size: .75em !important;
white-space: nowrap !important;
}
.profile-sensors-box {
@ -316,8 +315,8 @@ h3 { font-size: 1em !important; }
.activity-filter-col .filter-btn,
.activity-sub-filter .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
padding: 2px 9px !important;
font-size: .75em !important;
white-space: nowrap !important;
}
@ -451,10 +450,6 @@ h3 { font-size: 1em !important; }
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-media-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-overview-grid {
grid-template-columns: 1fr !important;
}
@ -467,24 +462,11 @@ h3 { font-size: 1em !important; }
height: 180px !important;
}
.tribe-votation-option {
flex-direction: column !important;
align-items: flex-start !important;
}
.tribe-content-header {
flex-direction: column !important;
gap: 8px !important;
}
.tribe-content-filters {
flex-wrap: wrap !important;
}
.tribe-media-filters {
flex-wrap: wrap !important;
}
.forum-card {
flex-direction: column !important;
}
@ -510,28 +492,28 @@ h3 { font-size: 1em !important; }
}
.forum-comment {
margin-left: 0 !important;
padding-left: 8px !important;
margin-left: 0;
padding-left: 8px;
}
.comment-body-row {
flex-direction: column !important;
flex-direction: column;
}
.comment-vote-col,
.comment-text-col {
width: 100% !important;
width: 100%;
}
.forum-score-box,
.forum-score-form {
flex-direction: row !important;
justify-content: center !important;
flex-direction: row;
justify-content: center;
}
.new-message-form textarea,
.comment-textarea {
width: 100% !important;
width: 100%;
}
[style*="grid-template-columns: repeat(6"] {
@ -627,6 +609,7 @@ a.filter-btn,
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
flex: 0 0 auto !important;
height: 42px !important;
font-size: 16px !important;
box-sizing: border-box !important;
@ -673,7 +656,7 @@ a.filter-btn,
padding: 9px 16px !important;
}
.filters .ui-toolbar--filters {
justify-content: center !important;
justify-content: flex-start !important;
align-items: center !important;
gap: 6px !important;
}
@ -729,6 +712,15 @@ a.filter-btn,
max-width: 100% !important;
overflow-wrap: anywhere !important;
}
.card-field .card-value.user-link,
.card-field .card-value .user-link {
display: inline-block !important;
vertical-align: bottom !important;
max-width: 100% !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.market-grid,
.shop-products-grid,
@ -818,14 +810,14 @@ a.filter-btn,
margin-left: 0 !important;
}
.oasis-id-copy {
input.oasis-id-copy[readonly] {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
font-family: monospace !important;
font-size: 12px !important;
font-size: clamp(8px, 2.5vw, 12px) !important;
text-align: center !important;
padding: 8px !important;
padding: 8px 6px !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
background: rgba(255, 165, 0, 0.08) !important;
@ -850,6 +842,8 @@ a.create-button {
.peers-list .block-info-table,
.peers-technical-block .block-info-table,
.viewer-hidden-block .block-info-table,
.parliament-rows-table,
.gov-overview {
display: block !important;
width: 100% !important;
@ -860,17 +854,23 @@ a.create-button {
}
.peers-list .block-info-table tbody,
.peers-technical-block .block-info-table tbody,
.viewer-hidden-block .block-info-table tbody,
.parliament-rows-table tbody,
.gov-overview tbody {
display: block !important;
width: 100% !important;
}
.gov-overview thead { display: none !important; }
.gov-overview thead,
.parliament-rows-table thead { display: none !important; }
.peers-list .block-info-table tr:first-child,
.peers-technical-block .block-info-table tr:first-child {
.peers-technical-block .block-info-table tr:first-child,
.viewer-hidden-block .block-info-table tr:first-child {
display: none !important;
}
.peers-list .block-info-table tr,
.peers-technical-block .block-info-table tr,
.viewer-hidden-block .block-info-table tr,
.parliament-rows-table tbody tr,
.gov-overview tbody tr {
display: block !important;
width: 100% !important;
@ -883,6 +883,8 @@ a.create-button {
}
.peers-list .block-info-table td,
.peers-technical-block .block-info-table td,
.viewer-hidden-block .block-info-table td,
.parliament-rows-table tbody td,
.gov-overview tbody td {
display: flex !important;
justify-content: space-between !important;
@ -898,6 +900,8 @@ a.create-button {
}
.peers-list .block-info-table td::before,
.peers-technical-block .block-info-table td::before,
.viewer-hidden-block .block-info-table td::before,
.parliament-rows-table tbody td::before,
.gov-overview tbody td::before {
content: attr(data-label);
flex: 0 0 auto !important;
@ -910,7 +914,606 @@ a.create-button {
white-space: nowrap !important;
word-break: normal !important;
}
.peers-technical-block .block-info-table td[data-label=""] { justify-content: center !important; }
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
.peers-technical-block .block-info-table td[data-label=""],
.viewer-hidden-block .block-info-table td[data-label=""] { justify-content: center !important; }
.peers-technical-block .block-info-table td[data-label=""]::before,
.viewer-hidden-block .block-info-table td[data-label=""]::before { content: none !important; }
.peers-list .block-info-table td .peer-key,
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
.phone-history td.phone-dir,
.phone-contact .phone-dir,
.phone-dir {
display: none !important;
}
.phone-contact {
gap: 6px !important;
padding: 8px !important;
}
.phone-contact .user-link {
flex: 1 1 100% !important;
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.phone-contact .date-link {
flex: 0 1 auto !important;
font-size: 12px !important;
}
.phone-history,
.phone-history tbody {
display: block !important;
width: 100% !important;
}
.phone-history tr:first-child {
display: none !important;
}
.phone-history tr {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
border: 1px solid #444 !important;
border-radius: 10px !important;
margin: 0 0 8px 0 !important;
padding: 4px 10px !important;
}
.phone-history td {
display: flex !important;
justify-content: space-between !important;
align-items: baseline !important;
gap: 10px !important;
width: 100% !important;
border: 0 !important;
padding: 3px 0 !important;
font-size: 12px !important;
text-align: right !important;
}
.phone-history td::before {
content: attr(data-label);
flex: 0 0 auto !important;
font-weight: 700 !important;
text-transform: uppercase;
font-size: 10px !important;
letter-spacing: .03em;
opacity: .7 !important;
text-align: left !important;
white-space: nowrap !important;
}
.phone-history td.phone-row-actions {
justify-content: flex-end !important;
padding: 0 !important;
margin-top: -6px !important;
}
.phone-history .phone-row-delete .btn-singleview,
.phone-latest-row .phone-row-delete .btn-singleview {
width: 32px !important;
height: 32px !important;
min-width: 0 !important;
min-height: 0 !important;
line-height: 1 !important;
padding: 0 !important;
font-size: 14px !important;
}
.phone-history td.phone-row-actions::before {
content: none !important;
}
.phone-latest-row .phone-row-delete {
margin-left: auto !important;
}
.phone-history td .user-link {
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.peers-list .block-info-table td .ubi-tick-ok,
.peers-list .block-info-table td .peer-tor-no,
.peers-technical-block .block-info-table td .ubi-tick-ok,
.peers-technical-block .block-info-table td .peer-tor-no {
padding: 0 !important;
line-height: 1.2 !important;
}
.conn-actions,
.peers-conn-actions {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
gap: 8px !important;
width: 100% !important;
}
.conn-actions form,
.peers-conn-actions form {
flex: 1 1 45% !important;
min-width: 130px !important;
max-width: 100% !important;
margin: 0 !important;
display: block !important;
}
.conn-actions form button,
.peers-conn-actions form button {
width: 100% !important;
min-width: 0 !important;
}
.relationship-status .relationship-actions { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; }
.relationship-status .relationship-actions form,
.relationship-status .relationship-actions button { width: 100% !important; }
.profile-sensors-box { align-items: center !important; text-align: center !important; }
.profile-sensors-box > * { margin-left: auto !important; margin-right: auto !important; justify-content: center !important; }
.profile-layout,
.profile-layout .tribe-side,
.profile-layout .tribe-main {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
}
.profile-side-description,
.profile-side-description p,
.trending-card .card-section,
.feed-text {
word-break: normal !important;
overflow-wrap: break-word !important;
}
.invite-qr-card { display: flex !important; justify-content: center !important; border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; margin: 0 auto !important; }
.invite-qr-img { display: block; margin: 0 auto; }
.stats-kpi { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.stats-kpi-label,
.stats-kpi-value { width: 100% !important; text-align: center !important; }
.main-column audio,
.main-column video { width: 100% !important; max-width: 100% !important; }
.main-column section:has(.chat-messages-list) { border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; }
.tribe-details:has(.chat-messages-list) { display: block !important; border: none !important; background: transparent !important; padding: 6px !important; margin: 0 !important; box-shadow: none !important; }
.chat-full-width { border: none !important; background: transparent !important; padding: 0 !important; }
.chat-messages-list { padding: 0 !important; }
.chat-message { max-width: 92% !important; }
.chat-message.chat-poll { max-width: 97% !important; }
.feed-workspace {
display: block !important;
}
.feed-side {
display: none !important;
}
.tribe-info-table,
.tribe-info-table tbody,
.tribe-info-table tr,
.tribe-info-table td {
display: block !important;
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
text-align: left !important;
overflow: visible !important;
}
.tribe-info-table td .user-link {
display: inline-block !important;
max-width: 100% !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.school-lesson-summary {
flex-wrap: wrap !important;
}
.school-lesson-summary-title {
flex: 1 1 100% !important;
min-width: 0 !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
}
.school-lesson-summary-meta {
flex: 1 1 100% !important;
white-space: normal !important;
}
.feed-votes-summary {
flex-wrap: wrap !important;
max-width: 100% !important;
min-width: 0 !important;
}
.feed-votes-summary .vote-category {
white-space: nowrap !important;
flex: 0 0 auto !important;
}
.tribe-open-invite-code {
font-size: .85em !important;
}
.school-progress-row {
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
}
.school-progress-issue {
width: 100% !important;
}
.school-progress-issue input[type="text"] {
max-width: 100% !important;
flex: 1 1 100% !important;
}
.subscription-box-block {
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 8px !important;
}
.subscription-box-block .subscription-form { margin: 0 !important; }
.profile-module-section,
.profile-module-section .trending-card,
.profile-module-section .card-body,
.profile-module-section .card-section {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
}
.card-section-action {
display: flex !important;
justify-content: center !important;
width: 100% !important;
}
.card-section-action .keep-reading-link {
text-align: center !important;
}
.card-field.image-container,.card-field.video-container,.card-field.audio-container { justify-content: center !important; }
.card-field.media-left { justify-content: flex-start !important; }
.card-footer .date-link,
.card-footer .user-link { padding: 0 10px !important; font-size: 12px !important; }
.card-meta-row .opinions-summary,
.card-meta-row .comments-summary-link { padding: 0 10px !important; font-size: 12px !important; }
.parliament-rows-table tbody td[data-label=""] { justify-content: center !important; }
.parliament-rows-table tbody td[data-label=""]::before { content: none !important; }
.parliament-rows-table tbody td .parliament-avatar-cell {
display: flex !important;
align-items: center !important;
gap: 8px !important;
justify-content: flex-end !important;
}
.parliament-rows-table tbody td .parliament-leader-avatar { width: 32px !important; height: 32px !important; }
.parliament-rows-table tbody td .parliament-method-img { max-height: 28px !important; width: auto !important; }
.parliament-rows-table tbody td form { margin: 0 !important; }
.hemi-legend {
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
justify-content: flex-start !important;
}
.hemi-legend-item {
width: 100% !important;
flex-wrap: nowrap !important;
justify-content: flex-start !important;
}
.hemi-legend-count { margin-left: auto !important; }
.hemi-modes { flex-wrap: wrap !important; }
.room-item .mailing-archive-head{flex-wrap:wrap}
.oasis-nav-side-toggle {
position: absolute !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
pointer-events: none !important;
}
.oasis-nav-side-toggle ~ .header {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 8px 6px !important;
padding: 8px 8px 4px !important;
min-height: 60px !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 900 !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .header + * ,
.oasis-nav-side-toggle ~ .header ~ .update-banner:first-of-type {
margin-top: 0 !important;
}
body:has(.oasis-nav-side-toggle) {
padding-top: 64px !important;
}
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header {
position: absolute !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left,
.oasis-nav-side-toggle ~ .header .top-bar-right,
.oasis-nav-side-toggle ~ .header .top-bar-mid {
display: contents !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon {
order: 1 !important;
flex: 0 0 auto !important;
margin: 0 !important;
padding: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left a.logo-icon img.logo-icon {
width: 44px !important;
height: 44px !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .header .oasis-mobile-menus {
order: 2 !important;
display: flex !important;
flex: 1 1 auto !important;
flex-wrap: nowrap !important;
justify-content: flex-end !important;
align-items: center !important;
gap: 5px !important;
margin: 0 !important;
padding: 0 !important;
}
.oasis-nav-side-toggle ~ .header .oasis-menu-slot {
display: inline-flex !important;
}
.oasis-nav-side-toggle ~ .header .oasis-menu-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
min-height: 44px !important;
padding: 6px 9px !important;
white-space: nowrap !important;
cursor: pointer !important;
}
.oasis-nav-side-toggle ~ .header .oasis-menu-slot .oasis-menu-close {
display: none !important;
}
#nav-left-toggle:checked ~ .header .oasis-menu-slot-left .oasis-menu-open,
#nav-right-toggle:checked ~ .header .oasis-menu-slot-right .oasis-menu-open {
display: none !important;
}
#nav-left-toggle:checked ~ .header .oasis-menu-slot-left .oasis-menu-close,
#nav-right-toggle:checked ~ .header .oasis-menu-slot-right .oasis-menu-close {
display: inline-flex !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
order: 3 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
order: 4 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav,
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
flex: 1 1 calc(50% - 4px) !important;
width: auto !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul,
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 4px !important;
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul li,
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul li {
margin: 0 !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav ul li a,
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul li a {
position: relative !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
min-height: 50px !important;
padding: 0 !important;
font-size: 0 !important;
text-decoration: none !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .header nav ul li a .nav-text {
display: none !important;
}
.oasis-nav-side-toggle ~ .header nav ul li a .emoji {
font-size: 1.6rem !important;
line-height: 1 !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .header nav ul li a .inbox-badge {
position: absolute !important;
top: 3px !important;
right: 3px !important;
margin: 0 !important;
font-size: 0.66rem !important;
}
.oasis-nav-side-toggle ~ .main-content {
display: flex !important;
flex-direction: column !important;
}
.oasis-nav-side-toggle ~ .main-content .main-column {
order: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .sidebar-left,
.oasis-nav-side-toggle ~ .main-content .sidebar-right {
display: none !important;
}
#nav-left-toggle:checked ~ .main-content .sidebar-left,
#nav-right-toggle:checked ~ .main-content .sidebar-right {
display: block !important;
order: -1 !important;
width: 100% !important;
max-width: 100% !important;
padding: 0 4px !important;
margin: 0 0 8px 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-header {
margin: 0 0 6px 0 !important;
padding: 12px !important;
}
.oasis-nav-side-toggle ~ .main-content .sidebar-left .oasis-nav-list,
.oasis-nav-side-toggle ~ .main-content .sidebar-right .oasis-nav-list {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 8px !important;
margin: 0 !important;
padding: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li {
margin: 0 !important;
padding: 0 !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 74px !important;
height: 100% !important;
padding: 8px 4px !important;
margin: 0 !important;
text-align: center !important;
font-size: 0.72rem !important;
opacity: 1 !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a .emoji {
width: auto !important;
font-size: 1.5rem !important;
line-height: 1 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-list li a .nav-text {
line-height: 1.15 !important;
overflow-wrap: anywhere !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav,
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
position: fixed !important;
bottom: 0 !important;
height: 66px !important;
width: 50% !important;
flex: none !important;
z-index: 900 !important;
margin: 0 !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
left: 0 !important;
right: auto !important;
padding: 6px 2px 6px 6px !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
right: 0 !important;
left: auto !important;
padding: 6px 6px 6px 2px !important;
}
.oasis-nav-side-toggle ~ .oasis-footer {
padding-bottom: 84px !important;
}
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header .top-bar-left nav,
body:has(:is(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select):focus) .oasis-nav-side-toggle ~ .header .top-bar-right nav {
display: none !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-left nav {
width: calc(300% / 7) !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav {
width: calc(400% / 7) !important;
}
.oasis-nav-side-toggle ~ .header .top-bar-right nav ul {
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
}
.oasis-nav-side-toggle ~ .main-content .sidebar-left nav > ul,
.oasis-nav-side-toggle ~ .main-content .sidebar-right nav > ul {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
grid-auto-flow: row dense !important;
gap: 8px !important;
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group {
display: contents !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 74px !important;
margin: 0 !important;
padding: 8px 4px !important;
text-align: center !important;
font-size: 0.68rem !important;
box-sizing: border-box !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .emoji {
width: auto !important;
font-size: 1.5rem !important;
line-height: 1 !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .nav-text {
flex: 0 0 auto !important;
line-height: 1.15 !important;
overflow-wrap: anywhere !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-header .oasis-nav-arrow {
display: none !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-group > .oasis-nav-list {
grid-column: 1 / -1 !important;
max-height: none !important;
overflow: visible !important;
}
.oasis-nav-side-toggle ~ .main-content .oasis-nav-toggle:not(:checked) + .oasis-nav-header + .oasis-nav-list {
display: none !important;
}
.oasis-nav-side-toggle ~ .header .oasis-phone-state {
order: 1 !important;
flex: 1 1 0 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
align-self: center !important;
margin: 0 !important;
padding: 0 !important;
min-width: 0 !important;
}
.oasis-nav-side-toggle ~ .header:has(.oasis-phone-state) .oasis-mobile-menus {
flex: 0 1 auto !important;
}
.oasis-nav-side-toggle ~ .header .oasis-phone-state-btn {
appearance: none !important;
-webkit-appearance: none !important;
font: inherit !important;
display: inline-flex !important;
align-items: center !important;
gap: 3px !important;
min-height: 0 !important;
justify-content: center !important;
width: 38px !important;
height: 38px !important;
padding: 0 !important;
font-size: 8.5px !important;
line-height: 1.2 !important;
white-space: nowrap !important;
border-radius: 999px !important;
cursor: pointer !important;
}
.oasis-nav-side-toggle ~ .header .oasis-phone-state-btn .pm-exposition-icon {
font-size: 22px !important;
line-height: 1 !important;
margin: 0 !important;
}

Some files were not shown because too many files have changed in this diff Show more