Compare commits

..

No commits in common. "PRUEBAS" and "v1.3.3-76" have entirely different histories.

254 changed files with 8678 additions and 17570 deletions

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

@ -0,0 +1,53 @@
# Construye la APK y la deja publicada como borrador en las releases.
#
# 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: 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
./scripts/release.sh --release --publish
- name: Borrar el almacen de claves
if: always()
run: rm -f /tmp/oasis.jks

6
.gitignore vendored
View file

@ -1,7 +1 @@
node_modules/ 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/
src/server/.update_required

View file

@ -1,385 +0,0 @@
# 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`.
## La estrategia: una capa encima
Lo del dev entra tal cual y lo nuestro va encima, en ficheros nuestros: hojas de
estilo, vistas y un router propio que va delante del suyo. Se eligió así frente a
las otras salidas:
- Un fork que copie sus vistas y siga por libre se queda viejo en semanas (39
versiones en siete semanas).
- Una app propia que hable con su backend obliga a rehacer toda la interfaz, y Oasis
no tiene API: devuelve HTML.
- Reescribir su HTML o sus respuestas al vuelo se rompe sin avisar con cualquier
cambio suyo. Se probó y se quitó el 20/10/2026.
Las reglas de la capa:
1. Lo del dev no se cambia: ni su código ni lo que contesta (HTML, redirecciones,
cabeceras). Nuestro código no mira sus respuestas.
2. El aspecto, con CSS en nuestras hojas.
3. Otro HTML en una pantalla suya: función en `mobile_ui.js` y una línea en su vista.
4. Una pantalla o una ruta nueva: en `fork_routes.js`; lo que no es nuestro pasa al
dev con `next()`.
5. Si una pantalla del dev ya hace algo (editar el perfil, escribir, guardar los
módulos), se enlaza a ella o se envía a su ruta y se acepta a dónde vuelve. No se
copia su lógica.
6. Se pueden usar sus funciones (`getConfig`, `saveConfig`, leer de sus modelos).
Reescribirlas, no.
7. Si algo no se puede hacer con estas reglas, se le pide al dev.
Dónde va cada cosa:
| Quiero... | Va en |
|---|---|
| Cambiar colores, tamaños, márgenes | `styles/mobile_ux.css` (piezas) o `styles/activista.css` (lanzador y barras) |
| Cambiar los colores de serie o las paletas | Variables `--a26-*` de `themes/Activista.css`; las paletas, en `mobile_settings.js` |
| La barra de unirse a un pub | `views/mobile_pubbar.js` |
| Otro HTML en una pantalla del dev | Función en `views/mobile_ui.js` + una línea en su vista |
| Una pantalla nueva | Vista propia en `views/mobile_*.js` + ruta en `fork_routes.js` |
| Algo en Ajustes del móvil | `views/mobile_settings.js` |
| Un texto nuevo | `translations/i18n_fork.js`, en los 13 idiomas |
| Un icono | `views/mobile_icons.js` |
| Un workflow (perfil) o la barra de abajo de un perfil | `models/fork_workflows.js` (`FORK_WORKFLOWS` y `BARS`) |
| Algo de la APK | `android/scripts/sobre_oficial.sh` o `android/icono/` |
| Algo que la capa no deja | Pedírselo al dev |
Cómo seguir modificando, paso a paso:
1. Arrancar en local (ver «Probar en local») y localizar en el inspector la clase o
la pantalla.
2. Decidir con la tabla dónde va. Si la respuesta es un fichero del dev, parar y
releer las reglas.
3. Hacer el cambio.
4. Pasar «Antes de hacer commit».
5. Commit en castellano y subir la rama `PRUEBAS` a forgejo.
6. Montar la APK con `android/scripts/sobre_oficial.sh` y probarla en el emulador
`Oasis_Pruebas`.
7. Si entra un fichero o un mecanismo nuevo, apuntarlo en esta guía en el mismo commit.
## 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. Desde la 1.3.5 es `script-src 'none'`: tampoco valen
ficheros `.js` propios. Lo que haga falta, con enlaces, formularios y CSS.
- 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`, `welcome_view.js` y `activity_view.js` (el botón Filtrar, que
pliega las fichas de tipo de actividad). 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`). La abre el ancla `#oasis-menu`
y la pinta `:target`: abrirla deja una entrada en el historial, así que el gesto atrás
la cierra sin cambiar de pantalla. La X, tocar fuera o volver a tocar Menú van a
`#menu-cerrado`, un ancla que no existe, para que la página no salte arriba. La barra tiene cuatro atajos más Menú. Cada perfil trae los suyos (`BARS` en
`fork_workflows.js`) y quien la cambia desde «Tu barra» la cambia solo para ese perfil
(`bottomBarBySkin` en la config; sin perfil reconocido, `bottomBarPins`). 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/Activista.css` y `styles/activista.css`: el skin
Activista, el único del móvil (Acampada y el OasisMobile del dev se quitaron del
selector el 20/10/2026). Lo más simple posible: letra del sistema, bordes finos y
sin sombras. El skin quita lo que sobra; los colores los pone cada cual.
- `src/views/mobile_pubbar.js`: la barra fija de unirse a un pub (y de restaurar una
copia), encima de la barra de abajo. Solo sale si hay algo que contar y se pone al
día al cambiar de pantalla (sin JS); el estado sale de
`backupModel.restoreStatus()`, que `backend.js` le pasa a `fork_routes.js` en la
línea que lo monta.
- `src/views/mobile_ui.js`: lo que se enchufa en vistas del dev.
- `src/views/mobile_settings.js`: los seis accesos de arriba de Ajustes (identidad,
perfil, portada, colores, comunidad y saludo) y los colores. Los colores se guardan
en la config (`mobileColors`) y salen en `/mobile-colors.css`, la última hoja; son
las variables `--a26-*` del skin Activista. La paleta de serie, Estándar, son los
colores del dev (negro y oro de su OasisMobile); las demás llevan nombre propio
(Plaza, Madrugada, Huerto, Violeta, Hoguera, Fanzine, Pancarta).
- `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`. También
deja abiertos los comentarios de las fichas con la opción del dev
(`comments_view.setCommentsOpen`), salvo en el parlamento.
- `src/models/fork_workflows.js`: nuestros workflows, que en el móvil hacen de skins:
cada uno enciende unos módulos y trae su barra de abajo. Activista (acción directa,
6 módulos) es el de serie, después Acampada (16) y los del dev; en Ajustes y en la
bienvenida salen de menos a más módulos, con el número y los iconos de su barra.
Ojo: `backend.js` apaga a la fuerza en el móvil `ai`, `aiNav`, `dev`, `games`, `logs`,
`maps` y `pixelia`. Un perfil que encienda alguno de esos no se reconoce nunca como
puesto (le pasa a Movimiento, el `activists` del dev, y a Por defecto).
- `src/client/assets/translations/i18n_fork.js`: nuestros textos. Las claves
`bbShort_<modulo>` son el nombre corto de la barra de abajo cuando el del dev no cabe
en una de las cinco pestañas (medido a 412 y 360 px en los 13 idiomas); sin clave se
usa el nombre entero, que queda siempre como nombre accesible. Si un perfil nuevo
mete otro módulo en la barra, medirlo.
- Los avisos del dev (versión nueva y sugerencia, `.apk-banners .update-banner`) van
fijos abajo, encima de la barra, como la del pub: `mobile_ux.css`, sin tocar su HTML.
Se esconden con el teclado, con el menú y con la barra del pub; en el WebView viejo
se quedan arriba.
- `src/client/assets/images/ux-*.svg`: nuestras imágenes.
- `src/client/assets/images/mobile-*.svg`: iconos de línea que `mobile_ux.css` pinta
con `mask` en sitios del dev que llevan emoji o un carácter (avisos de arriba, fichas
de visibilidad, Compartir, estado del teléfono, responder y enviar en chats). Se
esconde el carácter con `font-size: 0` y el icono toma el color del texto. Mismo
trazo para todos: 24x24, línea de 2, puntas redondas. Los del menú y la barra de
abajo están en `views/mobile_icons.js` y no se tocan desde aquí. Nada de emoji ni de
logotipos (tampoco el robot de las sugerencias del dev).
- `android/scripts/sobre_oficial.sh`: monta la APK sobre la oficial del dev. Es lo
único de Android que hay en la rama; el proyecto Gradle propio se quitó.
- `docs/botones/descargar.svg`: el botón de descarga de arriba del README. Enlaza a la
APK de la versión publicada `apk-<versión>` en gitea (forgejo no la admite: su nginx
da 413 con ficheros de 60 MB). Con APK nueva, se publica otra versión y se cambian el
número del botón y el enlace.
- `android/icono/`: el icono adaptativo (la isla del logo de Oasis sobre su fondo).
`sobre_oficial.sh` lo copia encima del de la APK oficial, que solo trae un PNG
cuadrado y el lanzador lo encoge dentro de un círculo blanco.
## Cómo hacer cada cosa
Desde la 1.3.5 el dev monta en el móvil una cabecera por módulo (`apkModbar` en
`main_views.js`): se lleva a su barra el título, la primera caja de fichas, el buscador y
los contadores, y pone los avisos en `.apk-banners`. Su CSS está en `OasisMobile.css`, que
con el skin Activista no se carga, así que su estilo va en `mobile_ux.css` (bloque
«cabecera de modulo de la 1.3.5»). Hasta tres fichas, en rejilla de tres columnas. Con
cuatro o más van en una fila con una flecha a cada lado (bloque «filas de fichas que no
caben»): un carrusel de CSS puro con `::scroll-button` y anclas (`anchor-name`,
`anchor-scope`), sin JS y sin tocar su HTML. Lo mismo para las fichas de Gobierno de la
tribu. Un WebView anterior al 135 no lo entiende y deja la rejilla. Otra fila de fichas
que no quepa: añadir su selector a ese bloque. Filtrar de Actividad es una casilla con su etiqueta dentro de la propia
caja de fichas, para que viaje con ella a la cabecera.
Los colores de serie (paleta Estándar) son los de la APK oficial, medidos con el inspector
sobre su backend: si el dev los cambia, se vuelven a medir y se ponen en `PALETTES`
(`mobile_settings.js`). Los botones son los nuestros.
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 las `--a26-*` del skin. Si la regla no gana,
es que `mobile.css` o `Activista.css` mandan con `!important` y selectores largos:
hay que subir la especificidad y poner `!important` también. Ojo con una trampa:
`Activista.css` quita fondo, borde y sombra a todos los `div`, así que para ponérselo a uno hace
falta `.main-column div.la-clase`.
Huecos de las barras del sistema: nada de `env(safe-area-inset-*)`. El contenedor de
la app del dev ya se aparta de la barra de estado y de la de gestos, y si se suma en
el CSS el hueco sale dos veces. Se usan `var(--inset-top)` y `var(--inset-bottom)`,
que valen 0 y están en `mobile_base.css`.
Botones de solo icono: debajo llevan su nombre en letra de 12 px, sacado de su `title` con
`attr(title)` en CSS (en el móvil no hay hover). El texto es el del dev, ya traducido. Si
un botón nuevo del dev no se entiende y no trae `title`, se le pide al dev.
Zonas táctiles: 44 px como mínimo para botones, fichas y casillas (con su etiqueta);
24 px para los enlaces que van dentro de una línea de texto (nombres, etiquetas).
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: no se hace (regla 1). Antes había un `wrapUpstream`
que miraba sus redirecciones para el aviso de sección apagada, `?saved=1` al guardar
módulos y la vuelta a Ajustes tras guardar el perfil; se quitó el 20/10/2026 porque
cualquier cambio suyo lo rompía sin avisar. Ahora Ajustes enlaza a `/profile/edit` y
`/feed/create`, y guardar los módulos vuelve a donde decide el dev.
Textos. En `i18n_fork.js` y en los 13 idiomas (en, es, ca, gl, fr, eu, de, it, pt, zh,
ar, hi, ru). Un idioma que falte en `i18n_fork.js` coge el bloque en inglés entero y pisa
las claves que el dev sí tenía traducidas (pasó con catalán y gallego hasta el 22/10). 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 13 idiomas, y la pantalla mirada al menos en uno largo
(de o ru) y uno de derecha a izquierda (ar): sin scroll de lado ni textos cortados.
- 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`.
6. Con REA se mira otra vez cómo extrae y cómo usa la WebView su APK
(`copyNodeProjectIfNeeded`, `configureWebView` y el manifiesto). Si cambia,
`sobre_oficial.sh` puede tener que cambiar. Los comandos, en
`docs/ARQUITECTURA_APK.md`.
## 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, la APK de depuración deja inspeccionar la WebView desde
`chrome://inspect`. El `Pixel_9_API_36` de Android Studio está casi lleno; para
instalar la APK va mejor `Oasis_Pruebas` (12 GB, puerto 5556).
## La APK y la extracción del backend
La app del dev lleva nuestro código dentro de `assets/nodejs-project.zip` y al abrirse
lo descomprime en su carpeta de datos. Solo lo vuelve a descomprimir si cambia
`versionName.versionCode` (lo guarda en `nodejs-project/.version`). Si no cambia,
arranca con el código de la vez anterior aunque la APK sea nueva, y parece que se han
perdido los cambios (el CSS, por ejemplo). Por eso `sobre_oficial.sh` pone en el
`versionName` una huella del código (`1.3.3-hacklab.<huella>`): cada APK distinta se
descomprime de nuevo. La config de cada cual (`src/configs`) se guarda aparte y se
conserva.
Cómo está hecha la APK del dev por dentro (sacado con REA): `docs/ARQUITECTURA_APK.md`.
## 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 15 ficheros suyos, 516
líneas (20/10/2026, contra el `nodejs-project` de la APK 1.3.3). 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 (skin Activista, perfil Activismo, barra de abajo).
- `views/main_views.js` (64), `settings_view.js` (33), `welcome_view.js` (20),
`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.

244
README.md
View file

@ -1,11 +1,5 @@
# OASIS_MOBILE — rama de desarrollo experimental # OASIS_MOBILE — rama de desarrollo experimental
<p align="center">
<a href="https://gitea.laenre.net/hacklab/OASIS_MOBILE/releases/download/apk-1.3.7/oasis-1.3.7.apk"><img src="docs/botones/descargar.svg" alt="Descargar la APK 1.3.7" width="240"></a>
</p>
Android 7 o superior, 64 bits. Se instala junto al Oasis oficial sin pisarlo.
> ## ⚠ ESTO NO ES OASIS OFICIAL > ## ⚠ ESTO NO ES OASIS OFICIAL
> >
> Esta es una **rama de desarrollo experimental** mantenida por el hacklab, **no > Esta es una **rama de desarrollo experimental** mantenida por el hacklab, **no
@ -19,59 +13,25 @@ Android 7 o superior, 64 bits. Se instala junto al Oasis oficial sin pisarlo.
> >
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.** > **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í Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` ·
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.5 · Rama: `PRUEBAS` ·
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) · [Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX) [Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX)
![Oasis Mobile: actividad, menú con los perfiles Mínimo y Activista, y una tribu](docs/capturas/00-portada.png) ![Oasis Mobile: lanzador, Karvan, filtros y barra de atajos](docs/capturas/00-portada.png)
**[⬇ 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 ### Novedades de esta versión
- Al día con Oasis 1.3.5: cabecera por módulo, buscador con más opciones, avisos de - **Al día con Oasis 1.3.3**: karma en el perfil, salas con directo, fotos selladas en
versión y sugerencias, y su CSP sin JavaScript en la página. chats privados y tribus, visibilidad de menciones y las traducciones nuevas.
- Arranca con el perfil Mínimo: chats, teléfono, eventos, emergencias, invitaciones y - **Arranca en modo activistas**: chats, Karvan, pads, foro, imágenes, vídeos, wiki y
copia de seguridad. Encima van Acampada (16 módulos), Activista (24) y los del dev, blogs. Todo cabe en una pantalla; el resto se activa en *Ajustes → Workflows*.
de menos a más, cada uno con su barra de abajo. Se cambia en *Ajustes → Perfil*. - **Filtros en fichas**: en chats, feed, foros o pads los filtros ya no son botones
- Un solo skin, Activista: letra del sistema, bordes finos y sin sombras. Los colores gigantes que tapan el contenido, sino fichas pequeñas en fila.
de serie son los de la APK oficial (negro y oro) y se cambian en *Ajustes → Colores*. - **Tarjetas más limpias**: borrar, fijar, enlazar y abrir van en una fila de iconos.
- Menú sin JavaScript: la hoja se abre con un ancla y el gesto atrás la cierra sin - **Karvan**: tus mensajes salen a la derecha también al volver a entrar en la sala.
cambiar de pantalla.
- Tribus: las pestañas se quedan a la vista al cambiar de sección, todas en el mismo
estilo, y los botones de la columna (unirse, invitar, suscribirse, etiquetas) de
44 px.
- Responder en los chats es un botón con su nombre, no una flecha de 27 px junto a la
hora; Enviar ocupa el ancho. Los comentarios salen abiertos, con la caja a la vista.
- Iconos de línea propios en vez de los emoji del dev en los avisos, las fichas de
visibilidad (pública, mutuos, cifrada, licencia, tribu) y Compartir.
- Publicar lleva al primer sitio de escribir que esté encendido (blog, feed, foro o
wiki); antes, sin blogs, acababa en Módulos.
### Lo que ya traía la rama
- 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.
- 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.
--- ---
@ -79,13 +39,15 @@ Versión base: Oasis 1.3.5 · Rama: `PRUEBAS` ·
| | Oasis oficial | Esta rama | | | Oasis oficial | Esta rama |
|---|---|---| |---|---|---|
| Llamadas y salas | Teléfono y Salas, con micrófono y cámara | Los mismos, tal cual | | **Salas efímeras** | — | Karvan: mensajes que solo viven en memoria y se autodestruyen |
| Interfaz de móvil | El menú de escritorio en una pantalla pequeña | Skin Activista, actividad al abrir, hoja de Menú con iconos, barra de cuatro atajos | | **Videollamadas** | — | Audio y vídeo por WebRTC, sin servidores de terceros |
| Primer arranque | Casi sesenta módulos encendidos | Perfil Mínimo (6 módulos) y una escalera de perfiles hasta los del dev | | **Interfaz de móvil** | El menú de escritorio en una pantalla pequeña | Lanzador de módulos, filtros en fichas y barra de cuatro atajos |
| Votaciones | Seis opciones fijas en una tabla | Sí/No, semáforo o termómetro, con barras | | **Primer arranque** | Casi sesenta módulos encendidos | Modo activistas: lo justo para organizarse |
| Varias identidades | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave | | **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`) | | **Respuesta citada** | — | Responder a un mensaje concreto dentro de un chat |
| Metadatos de las fotos | Se quitan solo si está `sharp`; en Android nunca | Se quitan siempre: GPS, marca, modelo, número de serie | | **Envoltorio Android** | APK sin permisos de cámara ni micrófono | Proyecto Gradle propio: permisos, subida de ficheros, servicio que sobrevive |
| **Metadatos de las fotos** | Se quitan solo si está `sharp`; en Android **nunca** | Se quitan siempre: GPS, marca, modelo, número de serie |
| **Servidor STUN** | Uno de Google, por defecto | Ninguno. TURN propio si lo configuras |
Los nombres de las categorías, los módulos y el resto de la aplicación son los de Los nombres de las categorías, los módulos y el resto de la aplicación son los de
epsylon, sin tocar. epsylon, sin tocar.
@ -94,161 +56,133 @@ epsylon, sin tocar.
## Qué es Oasis, en cuatro dibujos ## Qué es Oasis, en cuatro dibujos
Una red social sin servidor: publicas en tu propio equipo y los demás se traen una 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. copia. No hay cuenta, no hay contraseña y nadie puede borrarte.
![Qué es Oasis](docs/esquemas/01-red.png) ![Qué es Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/01-red.png)
Los mensajes viajan de vecino en vecino, así que llegan a gente con la que nunca te 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. has conectado. Por eso funciona incluso sin internet, entre dos móviles en la misma wifi.
![Cómo llega un mensaje](docs/esquemas/02-gossip.png) ![Cómo llega un mensaje](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/02-gossip.png)
Un pub es solo un nodo que no se apaga nunca. No es un servidor con tus datos: no 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. guarda tu identidad, no decide qué ves y no puede borrarte.
![Qué es un pub](docs/esquemas/04-pub.png) ![Qué es un pub](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/04-pub.png)
Todo lo que publicas se encadena y va firmado con tu clave. Nadie puede colar un 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. mensaje en tu nombre ni cambiar lo que dijiste.
![Tu registro](docs/esquemas/14-registro.png) ![Tu registro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/14-registro.png)
> La explicación completa —los saltos, las tribus, el LARP, los módulos— está en > 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). > **[Entender Oasis en diez minutos](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender)**.
--- ---
## Llamadas y salas ## Karvan: salas que se borran solas
El Teléfono (llamadas de uno a uno, con buzón de voz) y las Salas (hasta cincuenta ![Cómo funciona Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/11-karvan.png)
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.
Ese puente está en la app Android del dev, que no tiene el código publicado. Por eso la Lo que se dice dentro **no toca el disco**, no entra en tu registro y no se replica a
APK de esta rama se monta sobre la oficial: se queda con su app tal cual y solo cambia nadie. Dos relojes corren a la vez —inactividad y tiempo absoluto— y la sala muere con
lo que corre dentro (el backend con nuestra capa) y el nombre del paquete. Probado en el que llegue antes.
el emulador: se entra en una sala y el micrófono graba.
En la barra de arriba, junto al avatar, está el botón de *No molestar* del Teléfono. <p align="center">
<img src="docs/capturas/07-karvan-salas.png" width="270">
<img src="docs/capturas/03-karvan.png" width="270">
</p>
---
## Videollamadas sin terceros
![Cómo funciona una llamada](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/12-llamadas.png)
El servidor solo sirve para que os encontréis: **la voz y el vídeo van directos** entre
los participantes, cifrados de extremo a extremo. Sin STUN de Google.
<p align="center">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/12-permiso-camara.png" width="270">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/15-llamada-controles.png" width="270">
</p>
Esto **no es posible con la APK oficial**: no declara los permisos de cámara y su
navegador interno los deniega. Por eso esta rama trae su propio envoltorio Android.
--- ---
## La interfaz ## La interfaz
![La interfaz por dentro](docs/capturas/esquema-interfaz.png)
<p align="center"> <p align="center">
<img src="docs/capturas/01-actividad.png" width="200"> <img src="docs/capturas/01-portada.png" width="240">
<img src="docs/capturas/02-menu.png" width="200"> <img src="docs/capturas/04-foros.png" width="240">
<img src="docs/capturas/03-menu-activista.png" width="200"> <img src="docs/capturas/06-barra.png" width="240">
</p>
<p align="center">
<img src="docs/capturas/04-tribu.png" width="200">
<img src="docs/capturas/05-blog.png" width="200">
<img src="docs/capturas/06-ajustes.png" width="200">
</p> </p>
Lo que sale al instalarla: la actividad al abrir y la hoja de Menú con el perfil Casi sesenta módulos no caben en la pantalla de un teléfono: la portada los ordena por
Mínimo. Después, con el perfil Activista: el menú completo, una tribu, un blog y secciones en una rejilla de iconos, como el cajón de apps del móvil. Abajo
Ajustes. Capturas a 412 px, en español, con los colores de serie. queda una barra de cuatro atajos que eliges tú. Dentro de cada módulo los filtros van en fichas pequeñas,
para que el contenido se vea sin tener que bajar.
Casi sesenta módulos no caben en la pantalla de un teléfono. Al abrir la app se ve la Solo aparecen los módulos activos. En **Ajustes → Workflows** se eligen de golpe: el de
actividad, lo que ha pasado en la red. Abajo hay cuatro atajos que eliges tú y una **activistas** deja lo justo para organizarse (chats, Karvan, pads, foro, imágenes,
quinta pestaña fija, Menú, que abre desde abajo una hoja con todos los módulos por vídeos, wiki y blogs). En el móvil un workflow cambia los módulos, no el aspecto.
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).
Solo aparecen los módulos activos. En *Ajustes → Perfil* se eligen de golpe, de
menos a más: Mínimo, Acampada, Activista y los del dev. Un perfil cambia los módulos y
la barra de abajo, no el aspecto: los colores van en *Ajustes → Colores*.
---
## Cómo está hecha la rama
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).
--- ---
## Empezar desde cero ## Empezar desde cero
![De cero a estar dentro](docs/esquemas/05-empezar.png) ![De cero a estar dentro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/05-empezar.png)
| | | | | |
|---|---| |---|---|
| [oasis-project.pub](https://oasis-project.pub) | Directorio de pubs, con invitaciones y estado de cada uno | | **[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 | | **[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 | | **[wiki.solarnethub.com](https://wiki.solarnethub.com/socialnet/overview)** | La documentación oficial, módulo por módulo |
--- ---
## Instalar ## Instalar
La APK no está en ninguna tienda: se descarga de las *releases* de este repositorio 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 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 oficial** sin pisarlo —el identificador es `net.laenre.oasis`—, así que puedes tener
los dos y comparar. los dos y comparar.
Necesita unos 350 MB libres. El primer arranque tarda entre 15 y 60 segundos porque Necesita unos **350 MB libres**. El primer arranque tarda entre 15 y 60 segundos porque
descomprime el servidor. descomprime el servidor.
> Verifica la huella de la firma antes de instalar una APK bajada de un servidor > **Verifica la huella de la firma** antes de instalar una APK bajada de un servidor
> propio: `apksigner verify --print-certs` > propio: `apksigner verify --print-certs`
### Compilar ### Compilar
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.
```sh ```sh
cd android cd android
./scripts/sobre_oficial.sh # baja la oficial, le pone el backend de la rama, la firma # la primera vez hace falta la APK oficial de la MISMA versión (ahora la 1.3.3):
# para publicar, con la clave del hacklab: # de ella salen las librerías nativas y el node_modules podado del móvil
OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh OASIS_APK_BASE=/ruta/oasis-v1.3.3.apk ./scripts/release.sh # compila, verifica y calcula la huella
``` ```
Sale en `android/build/sobre-oficial/oasis-<versión>-hacklab.apk`. Sin clave se firma ![Qué hay dentro de la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/16-arquitectura-movil.png)
con la de depuración y queda depurable, para poder inspeccionarla desde
`chrome://inspect`.
Qué hay dentro de la APK:
| Pieza | De quién | Qué hace |
|---|---|---|
| La app Android (ventana, servicio, notificaciones, puente `.audio_bridge`) | Del dev, tal cual | Abre un WebView en `127.0.0.1:3000`, mantiene vivo el servidor y da micrófono y cámara |
| `node_modules` | Del dev, de su APK oficial | Las dependencias, podadas para el móvil |
| El servidor (`assets/nodejs-project.zip`) | Oasis 1.3.5 con nuestra capa | Se descomprime al primer arranque y cada vez que cambia la versión |
| El paquete | `net.laenre.oasis` | Para convivir con la oficial (`com.solarnethub.oasis`) |
Detalle, sacado de la APK oficial: [docs/ARQUITECTURA_APK.md](docs/ARQUITECTURA_APK.md).
--- ---
## Documentación ## Documentación
Para desarrollar, en el propio repositorio:
| | |
|---|---|
| [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/ARQUITECTURA_APK.md](docs/ARQUITECTURA_APK.md) | Cómo está hecha la APK del dev por dentro |
| [docs/PENDIENTE.md](docs/PENDIENTE.md) | Lo que queda por hacer y lo que hay que pedir al dev |
Todo lo de arriba, en detalle, está en la Todo lo de arriba, en detalle, está en la
[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki): **[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki)**:
| | | | | |
|---|---| |---|---|
| [Entender Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender) | La red, el gossip, los saltos, los pubs, las tribus y el LARP | | [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 | | [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 | | [Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Karvan) | Salas efímeras, paso a paso |
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Cómo se llama y cómo montar un TURN |
| [La interfaz](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Interfaz) | Botón por botón | | [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 | | [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 | | [Compilar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Compilar) | Y cómo se publica una versión |
@ -260,12 +194,12 @@ Todo lo de arriba, en detalle, está en la
## El proyecto original ## El proyecto original
Esta rama existe gracias a y por encima de el trabajo de epsylon. El código, la 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. arquitectura, los módulos y la red son suyos.
- Código: https://github.com/epsylon/oasis - **Código:** https://github.com/epsylon/oasis
- Proyecto: https://solarnethub.com - **Proyecto:** https://solarnethub.com
- Wiki: https://wiki.solarnethub.com/socialnet/overview - **Wiki:** https://wiki.solarnethub.com/socialnet/overview
## Licencia ## Licencia

8
android/.gitignore vendored
View file

@ -1,3 +1,7 @@
# lo que monta y baja scripts/sobre_oficial.sh # artefactos y binarios grandes: los prepara scripts/prepare.sh
app/src/main/jniLibs/
app/src/main/assets/nodejs-project.zip
.gradle/
build/ build/
.tools/ app/build/
local.properties

View file

@ -1,21 +1,90 @@
# APK # Wrapper Android propio
La APK se monta sobre la oficial del dev con `scripts/sobre_oficial.sh`: se queda con Proyecto Gradle para construir la APK de la rama sin depender del APK oficial como
su app Android tal cual (ventana, servicio, notificaciones y el puente nativo de audio y molde.
cámara, `.audio_bridge`), le cambia el backend por el de la rama y le pone paquete
propio, `net.laenre.oasis`, para convivir con la oficial. ## Por que existe
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
videollamadas de Karvan 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
```sh ```sh
./scripts/sobre_oficial.sh # baja la oficial de la versión y la monta # 1. dependencias del backend (una vez)
OASIS_VERSION=x.y.z ./scripts/sobre_oficial.sh # otra versión del dev (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
``` ```
Hace falta Java 17 o más y las build-tools de Android (zipalign y apksigner); apktool Para firmar la release, por entorno (la keystore nunca al repositorio):
se baja solo. Sin clave se firma con la de depuración. Para repartirla, con la clave
del hacklab por entorno (nunca al repositorio): `OASIS_KEYSTORE`, `OASIS_KEYSTORE_PASS`
y `OASIS_KEY_ALIAS`.
`icono/` es el icono adaptativo; `sobre_oficial.sh` lo copia encima del de la oficial. ```sh
export OASIS_KEYSTORE=/ruta/oasis-alfa-key.jks
export OASIS_KEYSTORE_PASS=...
export OASIS_KEY_ALIAS=oasis
./gradlew assembleRelease
```
Hasta el 19/10/2026 hubo aquí un proyecto Gradle propio. Se quitó: no tenía el puente ## Requisitos
de audio del dev y en él no había llamadas. Está en la historia de git.
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,61 @@
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
// el pipeline los pasa por entorno; a mano valen los de aqui
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.3"
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 {
// libnode.so son 49 MB: sin comprimir se carga por mmap y arranca mas rapido
useLegacyPackaging = false
}
}
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,57 @@
<?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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Camara y microfono para las videollamadas de Karvan.
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 pulsa Llamar. 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">
<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,243 @@
package net.laenre.oasis
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.PermissionRequest
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 androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
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(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())
}
}
}
}
waitAndLoad()
}
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:#121212;color:#FFB400;font-family:sans-serif;display:flex;
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
div{padding:24px}p{opacity:.7;font-size:14px;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,117 @@
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.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
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
startForeground(NOTIF_ID, buildNotification())
if (started) return
started = true
Thread({ runNode() }, "node-main").start()
}
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,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Oasis" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">#121212</item>
<item name="android:statusBarColor">#121212</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

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- fondo del icono adaptativo: el marron casi negro del logo de Oasis -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1A1300"/>
</shape>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- el logo de Oasis (la isla), el mismo que la app oficial; el fondo llena la forma
que ponga el lanzador (circulo, gota...) en vez de dejar un cuadrado dentro -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

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)"

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

@ -0,0 +1,151 @@
#!/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
#
# 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
for arg in "$@"; do
case "$arg" in
--release) MODE=release ;;
--publish) PUBLISH=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"
[ -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"
if [ "$MODE" = release ]; then
"$BT/apksigner" verify --print-certs "$APK" > /tmp/oasis-signer.txt 2>&1 \
|| 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);
process.stdout.write(JSON.stringify({ tag_name: tag, name, body, draft: true, prerelease: true }));
' "$TAG" "Oasis Mobile $TAG" "$NOTES")" | 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)"
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-$TAG.apk" \
-H "$AUTH" -F "attachment=@$APK" >/dev/null \
|| die "la release se ha creado pero no se ha podido subir la APK"
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
say "revisala y quita el borrador a mano; no se publica sola a proposito"

View file

@ -1,143 +0,0 @@
#!/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"
# icono: el logo de la oficial, pero adaptativo. La oficial solo trae el PNG cuadrado
# y en Android 8+ el lanzador lo encoge dentro de un circulo blanco; con la capa de
# fondo y la de delante (android/icono) la isla llena la forma del lanzador
say "icono adaptativo"
RES="$HERE/icono"
mkdir -p "$DEC/res/mipmap-anydpi-v26" "$DEC/res/drawable"
cp "$RES/mipmap-anydpi-v26/ic_launcher.xml" "$DEC/res/mipmap-anydpi-v26/"
cp "$RES/drawable/ic_launcher_background.xml" "$DEC/res/drawable/"
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
cp "$RES/mipmap-$d/ic_launcher_foreground.png" "$DEC/res/mipmap-$d/"
done
# 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 ))"
# la app oficial solo vuelve a extraer nodejs-project.zip si cambia versionName.versionCode;
# con el mismo commit (o cambios sin commitear) se quedaba con el backend anterior. La
# huella del codigo en el nombre hace que cada APK distinta se extraiga de nuevo
HUELLA="$(cd "$REPO" && find src main.js package.json -type f -not -path '*/node_modules/*' -print0 | sort -z | xargs -0 sha1sum | sha1sum | cut -c1-8)"
NAME="$VERSION-hacklab.$HUELLA"
sed -i -e "s/^ versionCode: .*/ versionCode: $CODE/" -e "s/^ versionName: .*/ versionName: $NAME/" "$DEC/apktool.yml"
say "version $NAME (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")

View file

@ -1,159 +0,0 @@
# Cómo está hecha la APK de Oasis
Análisis de la APK oficial de epsylon (`oasis-v1.3.3.apk`, sha256 `4e8d60d4…5550d`)
con REA 6.7.0 y JADX headless 0.7.1, el 20/10/2026. Lo que se cita sale del código
decompilado de `com.solarnethub.oasis.OasisActivity`; las salidas completas están
en `OASIS_APK/ANALISIS_REA/` (fuera del repo).
## Las piezas
La app es una envoltura Android pequeña (cuatro clases propias) alrededor de un
servidor Node.js que corre dentro del móvil, con nodejs-mobile.
```
APK
├── classes.dex OasisActivity pantalla: WebView, extracción, arranque de node
│ OasisService servicio en primer plano (sigue vivo con la app cerrada)
│ OasisAudioBridge, OasisStreamDecoder micro, altavoz y cámara del Teléfono
├── lib/*/libnode.so node para el móvil
└── assets/nodejs-project.zip
└── nodejs-project/ main.js, package.json, src/ (backend, vistas, CSS, node_modules)
```
La interfaz no es nativa: es HTML que sirve node en `http://127.0.0.1:3000` y que la
WebView enseña. Todo lo que vemos (vistas, CSS, textos) va dentro del zip.
## El arranque
`onCreate`:
1. `isExtractionNeeded()`: mira si hay que descomprimir.
2. `configureWebView()` y la pantalla de carga.
3. En otro hilo: `copyNodeProjectIfNeeded()` y después `startNode()`, que lanza
`node nodejs-project/main.js` y el servicio en primer plano.
4. Cuando node responde en `127.0.0.1:3000`, la WebView carga la página.
## Cuándo se descomprime el zip
```java
// copyNodeProjectIfNeeded()
File file2 = new File(file, ".version");
String appVersion = getAppVersion(); // versionName + "." + versionCode
String strTrim = file2.exists() ? readFile(file2).trim() : null;
if (file.exists() && appVersion.equals(strTrim)) {
Log.i("OASIS", "Node project already up to date (v" + appVersion + ")");
return;
}
...
stashLegacyState(file3, file4); // copia src/configs a .configs-stash
deleteRecursive(file);
extractZipAsset(ASSET_ZIP, ...);
restoreLegacyState(file4, file3); // vuelve a poner los configs de antes
writeFile(file2, appVersion);
```
Solo se descomprime si cambia `versionName.versionCode`. Una APK nueva con el mismo
nombre y número de versión arranca con el código de la vez anterior, aunque dentro
lleve otro. Eso es lo que pasaba con nuestras APK de prueba: el `versionCode` solo
subía con cada commit, y al instalar una APK hecha sobre el mismo commit (o con
cambios sin commitear) se veía el CSS y las vistas de antes. Desde el 20/10/2026
`sobre_oficial.sh` pone una huella del código en el `versionName`.
Al descomprimir se conservan los ficheros de `src/configs` (la config de cada cual:
tema, idioma, workflow, colores). Consecuencia: si cambiamos la config de serie en
`configs/oasis-config.json`, solo la ve quien instala de cero; quien actualiza se
queda con la suya.
## La WebView no guarda caché
```java
// configureWebView()
settings.setCacheMode(2); // LOAD_NO_CACHE
this.webView.clearCache(true);
```
Cada página y cada hoja de estilo se piden de nuevo al node del móvil. Si después de
instalar se ve CSS viejo, no es la caché: es que node está sirviendo el código viejo
(ver el punto anterior).
La WebView solo navega dentro de `127.0.0.1:3000` y `localhost:3000`. Los enlaces
a una `.apk` los descarga ella misma y el resto de enlaces http(s) los abre en otra
app (el navegador). Hay rutas que intercepta para el Teléfono (`/phone/speaker`,
`/phone/accept-speaker`).
## El backend por dentro
`rea analyze-javascript-application` sobre el `nodejs-project` oficial, sin
`node_modules` (463 ficheros JavaScript). Del grafo de imports, contando solo
ficheros del dev:
- `backend/backend.js` lo cose todo: hace 896 de los imports. Rutas, middleware y
la llamada a cada modelo y cada vista pasan por él.
- Las capas van `backend → views` (568 imports), `backend ↔ models` (218 y 223) y
`models → configs` (180). Las vistas casi no importan nada del backend (3).
- Lo más importado, y por tanto lo que más rompe si cambia: `views/main_views.js`
(217), `models/typed_log.js` (194), `configs/config-manager.js` (152),
`views/clearnet_view.js` (116), `backend/dedupe.js` (112).
Cuadra con la lista de lo que no se toca de la guía: un cambio nuestro en
`backend.js` o en `main_views.js` tropieza con casi todo lo demás en cada versión.
Por eso lo nuestro va en `fork_routes.js`, `mobile_ui.js` y las hojas propias, que
el dev no importa.
## Barras del sistema y botón atrás
`onCreate` pone un `OnApplyWindowInsetsListener` que aparta el contenedor de la
WebView de la barra de estado, del recorte de la cámara y de la barra de gestos
(`setPadding` con esos insets), salvo en pantalla completa. Pero devuelve los insets
sin consumir, y la WebView los sigue anunciando como `safe-area-inset-*`: si el CSS
los suma, el hueco sale doble (65 px de más arriba y 24 abajo en un Pixel 9 con
Android 16). Por eso nuestras hojas usan `--inset-top` y `--inset-bottom` a 0.
El botón atrás pasa por `goBackInWebView()`, que hace `webView.goBack()` o, si no
puede, carga la página anterior de su propia lista (`visitedUrls`). Con el gesto de
atrás se llama una vez. Con la tecla (móviles con tres botones) se llama dos veces,
por `onKeyDown` y por `registerPredictiveBack`, y se retrocede dos pasos: con el menú
abierto, en vez de cerrarlo se va a la pantalla anterior. Es de su código Android;
hay que pedírselo al dev.
## Lo que hace nuestra APK encima
`android/scripts/sobre_oficial.sh` coge esta APK tal cual y cambia:
- `assets/nodejs-project.zip`: nuestro `src/`, `main.js` y `package.json`, con el
`node_modules` del dev (el podado para el móvil).
- El paquete, a `net.laenre.oasis`, para que conviva con la oficial.
- El icono, por el adaptativo de `android/icono/`.
- `versionName`, a `<versión>-hacklab.<huella del código>`.
La parte Android (Java, puente de audio, servicio) es la del dev sin tocar.
## Repetir el análisis
```sh
npm install --global --prefix ~/.local rea-agents@6.7.0
# JADX headless 0.7.1, comprobando la huella que fija REA
curl -fL -o jadx-headless-mcp-0.7.1-all.jar \
https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar
echo '6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75 jadx-headless-mcp-0.7.1-all.jar' | sha256sum -c -
export REA_JADX_MCP_JAR=$PWD/jadx-headless-mcp-0.7.1-all.jar JAVA_HOME=/opt/android-studio/jbr
rea inspect-android-package oasis-v1.3.3.apk
rea inspect-android-class oasis-v1.3.3.apk com.solarnethub.oasis.OasisActivity
rea inspect-android-method oasis-v1.3.3.apk com.solarnethub.oasis.OasisActivity copyNodeProjectIfNeeded
# el backend: sacar nodejs-project del zip y quitarle src/server/node_modules
# (con ellas, 16.500 ficheros, se corta por tiempo)
rea --json analyze-javascript-application /ruta/absoluta/nodejs-project-sin-node_modules
```
Con cada versión nueva del dev conviene mirar otra vez `copyNodeProjectIfNeeded`,
`configureWebView` y el manifiesto: si cambia la forma de extraer o la caché,
`sobre_oficial.sh` puede tener que cambiar también.

View file

@ -1,108 +0,0 @@
# 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 un ancla y CSS (`:target`), sin JS: la CSP de la 1.3.5 no deja ninguno.
Abrir la hoja deja una entrada en el historial y atrás la cierra sin cambiar de
página. Al volver atrás desde un módulo abierto desde la hoja, la hoja sale abierta:
se vuelve a donde se estaba.
## 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.

View file

@ -1,163 +0,0 @@
# 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 | Quitado el 20/10: no se miran las respuestas del dev |
| `backend.js`: ruta `POST /modules/enable` y `?saved=1` tras guardar | Quitado el 20/10: se guarda con la ruta del dev y vuelve a donde dice el |
| `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 |

View file

@ -1,139 +0,0 @@
# Pendiente
Lo que queda por hacer en la rama del móvil. Apuntado el 20/10/2026, con la APK
`oasis-1.3.3-activista` (commit b84a885) recién hecha para probar en el móvil.
## Integrar la 1.3.4 (hecho: integrada la 1.3.5 el 22/10)
- El dev ha publicado la 1.3.4 y el aviso de versión nueva sale en todas las
pantallas. Integrarla con los pasos de la guía antes de tocar el CSS de abajo, por si
cambian clases.
- Al integrar, rehacer `docs/FRONTERA_UPSTREAM.md` (ver "Antes de publicar").
## Arreglar en la capa (pruebas del 20/10): hecho el 24/10
Todo lo de esta lista está en la capa o ya no pasa con la 1.3.5: teléfono legible en
perfil y habitantes (y el de otra persona en una fila, no apilado), letra de 12 px o más
en melody, parlamento y banca, filtros y botones de 44 px, Parlamento y Gobierno de la
tribu sin sangrados ni huecos, imagen del método de gobierno a 160 px, campos de 44 px,
ficha del pad (texto de cada autora en negro sobre su color, "Por autora" y versiones de
44 px), y la primera ficha de Blogs y Wiki ya no empieza al 70 % de la pantalla.
## Probar en el móvil: hecho en el emulador el 24/10
APK `1.3.5-hacklab` en `Oasis_Pruebas`. Lógica 40/40 (Ajustes, los seis accesos,
Colores y que se conserva al reabrir, menú y atrás), usabilidad con las 8 paletas,
perfiles (los 10), tribus 396/396 y contenido. Unirse a un pub de verdad (SNH La
Plaza) de principio a fin con la barra del pub: "Descargando" y después "Guardando en
tu móvil: N % · M mensajes", siempre encima de la barra de abajo.
Lo que se vio y es del dev (backend):
- Mientras baja la red del pub la app no contesta: más de un minuto sin cargar una
pantalla, aunque la barra dice "Puedes seguir usando la app mientras tanto".
- Mientras guarda, cada pantalla tarda de 3 a 10 segundos.
Queda: instalarla en un móvil de verdad y repasarla a mano.
## Pedirle al dev (epsylon)
- **Botón atrás con tres botones.** `OasisActivity` atiende la tecla dos veces
(`onKeyDown` y `registerPredictiveBack`) y retrocede dos pasos; con el menú abierto
se va a la pantalla anterior en vez de cerrarlo. Con el gesto va bien. Detalle en
`docs/ARQUITECTURA_APK.md`.
- **Aviso al fallar unirse a un pub.** Si `acceptInvite` falla por lo que sea (sin red,
pub caído), dice "código no válido o ya usado". Habría que distinguir los casos.
- **Colores en variables CSS** (`--bg`, `--fg`, `--accent`) en sus hojas. Con eso
sobrarían muchos `!important` nuestros.
- **Fichas de Actividad en mayúsculas**: las escribe así en el HTML
(`toUpperCase`); con CSS se podría elegir.
## Blindar la capa
- Comprobador automático de las clases del dev que usa nuestro CSS (unas 424): avisar
en cada versión nueva de las que desaparecen.
- Capturas antes y después de cada integración, comparadas solas.
## Antes de publicar
- La APK va firmada con la clave de depuración. Para repartirla hace falta una clave
propia (`OASIS_KEYSTORE` en `android/scripts/sobre_oficial.sh`).
- `docs/FRONTERA_UPSTREAM.md` es un informe del 19/10 y habla aún de Acampada y
`hive_acampada.css`: rehacer la medición con la siguiente versión del dev.
## Repo
- `package.json` llama en el `postinstall` a `scripts/patch-node-modules.js`, que no
existe. La APK no lo nota (saca `node_modules` de la oficial), pero `npm install`
falla.
- El `origin` del clon es gitea y va por detrás; se sube a forgejo. Cambiarlo si gitea
ya no se usa.
## Tras los perfiles por escalera (22/10)
- `backend.js` apaga en el móvil `ai`, `aiNav`, `dev`, `games`, `logs`, `maps` y
`pixelia`. Movimiento (`activists`) y Por defecto encienden alguno, así que en el
móvil nunca salen como puestos y usan `bottomBarPins`. Pedir al dev que sus perfiles
de móvil no los incluyan, o quitarlos de la cuenta en `fork_workflows.js`. La prueba
de perfiles lo sigue marcando con `dev`, que no tiene entrada en el menú.
- Hecho: las sub-fichas de un módulo filtrado van en una fila, y EMPEZAR abre la
bienvenida como botón grande (el nombre va después, en la propia bienvenida).
## Tras las pruebas de tribus y mensajería (23/10)
Prueba con dos identidades de usar y tirar, B ve desde fuera la tribu de
A, se une, recorre las 24 pestañas, escribe en el feed y el foro, y comenta un blog.
Pasa entero salvo lo de abajo.
Arreglado en la capa: la hoja del menú sin JS (`:target`), iconos propios en vez de
emoji en avisos, fichas de visibilidad y Compartir, avisos de arriba en una línea,
responder y enviar en los chats, buscador con "más opciones" de la 1.3.5 en
`form.filter-box`, botones de voto del foro, etiquetas de la tribu, hora de un
comentario, ficha PÚBLICO y etiquetas del menú que se salían.
Pedirle al dev:
- **Dejar una tribu no pide confirmación.** El botón DEJAR va directo a
`/tribes/leave/:id`. Lo mismo con la ✕ de borrar un comentario. Él ya tiene
`confirmPage` (lo usa para unirse con invitación abierta).
- **Enlace a un hilo que no existe.** En Actividad, la ficha de un comentario a un
evento lleva a `/thread/<id>` y sale "No se ha encontrado".
- **Emoji metidos en el texto**: "👥: 1 · 💬 3" en foros, listas y actividad, y
"🔔 SUSCRIBIRSE". Con un `span` alrededor del emoji, como ya hace en las fichas de
visibilidad, se podrían cambiar por iconos desde CSS.
- **Etiquetas de la barra de abajo**: "Votaciones" no cabe con cinco pestañas a 412 px
y sale "Votacion…".
## Vuelta del 24/10
Hecho:
- Batería entera sobre la APK nueva: idiomas (13 idiomas, 71 pantallas, 320/360/480
px, 0 problemas), lógica, usabilidad con las 8 paletas, perfiles, tribus y
contenido. Y repaso a ojo de las 66 pantallas del menú con capturas.
- Filas de fichas que no caben: con cuatro o más, una fila con flechas a los lados
(carrusel de CSS, sin JS). También las fichas de Gobierno de la tribu. Ver la guía.
- Avisos de abajo: se esconden con el menú, con la barra del pub y con el teclado
(antes se quedaban encima del teclado). EMPEZAR ya no baja con ellos: se queda arriba.
En el WebView viejo (Android 9 con el 69 y Android 11 con el 83) van arriba y las
fichas se quedan en rejilla.
- Cajas de "no hay nada" una dentro de otra (Agenda, Calendarios, Encuestas), Pads
con la tarjeta metida 40 px, el borde doble de OASIS/CLEARNET en la wiki y
"GOBERNANDO" partido en L.A.R.P.
Queda:
- En Android 9, "INTRODUCIR INVITACIÓN" de la tribu sale de 36 px.
- Las etiquetas cortas de la barra (`bbShort_*`): repasarlas con alguien de cada idioma.
- Botones de icono sin nombre del dev (la ✕ de EMPEZAR y otros): sin `title`, la prueba
de idiomas los marca en todas las pantallas.

View file

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="52" viewBox="0 0 240 52" role="img" aria-label="Descargar APK 1.3.7">
<rect x="1" y="1" width="238" height="50" rx="12" fill="#121212" stroke="#ffd700" stroke-width="2"/>
<g fill="none" stroke="#ffd700" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" transform="translate(18 14)">
<path d="M12 3v12M7 10l5 5 5-5M4 20h16"/>
</g>
<text x="52" y="32" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="16" font-weight="700" fill="#ffd700">Descargar APK 1.3.7</text>
</svg>

Before

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

BIN
docs/capturas/03-karvan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/capturas/04-foros.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

BIN
docs/capturas/05-feed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

40
main.js
View file

@ -26,8 +26,6 @@ try {
const dns = require('dns'); const dns = require('dns');
const net = require('net'); const net = require('net');
const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4']; 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 { dns.setServers(servers); } catch (e) {}
try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {} try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {}
@ -50,43 +48,11 @@ try {
? callback(null, [direct]) ? callback(null, [direct])
: callback(null, direct.address, direct.family)); : callback(null, direct.address, direct.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; const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
resolver.call(dns, hostname, (err, addrs) => { resolver.call(dns, hostname, (err, addrs) => {
pubDone = true; if (err || !addrs || !addrs.length) return callback(err || new Error('ENOTFOUND ' + hostname));
if (err || !addrs || !addrs.length) { pubErr = err || new Error('ENOTFOUND ' + hostname); return bothFailed(); } if (options.all) return callback(null, addrs.map((a) => ({ address: a, family })));
finish(null, options.all ? addrs.map((x) => ({ address: x, family })) : addrs[0], family); return callback(null, 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();
}); });
}; };

View file

@ -1,6 +1,6 @@
{ {
"name": "@krakenslab/oasis", "name": "@krakenslab/oasis",
"version": "1.3.5", "version": "1.3.3",
"description": "Oasis - Social Networking Utopia", "description": "Oasis - Social Networking Utopia",
"repository": { "repository": {
"type": "git", "type": "git",

File diff suppressed because it is too large Load diff

View file

@ -85,12 +85,11 @@ const stripJpeg = (buf) => {
const out = [buf.subarray(0, 2)]; const out = [buf.subarray(0, 2)];
let orientation = 0; let orientation = 0;
let i = 2; let i = 2;
let scan = false;
while (i + 4 <= buf.length) { while (i + 4 <= buf.length) {
if (buf[i] !== 0xFF) return null; // no es un JPEG sano: no tocarlo if (buf[i] !== 0xFF) return null; // no es un JPEG sano: no tocarlo
const marker = buf[i + 1]; const marker = buf[i + 1];
if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; } 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 if (marker === 0xDA) { out.push(buf.subarray(i)); break; } // datos comprimidos: hasta el final
const len = buf.readUInt16BE(i + 2); const len = buf.readUInt16BE(i + 2);
if (len < 2 || i + 2 + len > buf.length) return null; if (len < 2 || i + 2 + len > buf.length) return null;
const seg = buf.subarray(i + 4, i + 2 + len); const seg = buf.subarray(i + 4, i + 2 + len);
@ -98,8 +97,6 @@ const stripJpeg = (buf) => {
if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len)); if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len));
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) { if (orientation > 1) {
const exif = exifOnlyOrientation(orientation); const exif = exifOnlyOrientation(orientation);
const head = Buffer.alloc(4); const head = Buffer.alloc(4);
@ -153,40 +150,14 @@ const stripWebp = (buf) => {
return Buffer.concat([head, body]); return Buffer.concat([head, body]);
}; };
// Formatos que pueden llevar EXIF (GPS incluido) y que aqui no se saben limpiar: /** Limpia segun el formato; si algo no cuadra, devuelve la imagen tal cual. */
// 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) => { const stripMetadataPure = (buffer) => {
if (isUncleanable(buffer)) throw unsafe(); try {
if (!looksCleanable(buffer)) return buffer; const out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer);
let out = null; return out && out.length ? out : buffer;
try { out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer); } catch (_) { out = null; } } catch {
if (!out || !out.length) throw unsafe(); return buffer;
return out; }
}; };
const stripImageMetadata = async (buffer) => { const stripImageMetadata = async (buffer) => {
@ -230,141 +201,7 @@ const stripPdfMetadata = (buffer) => {
} }
}; };
const STRIP_CHUNK = 64 * 1024;
const STRIP_IN_MEMORY_MAX = 256 * 1024 * 1024;
const FFMPEG_STRIP_MS = 120000;
const MP4_LIKE = /mp4|quicktime|m4a|m4v|3gpp/;
const MP4_METADATA_BOXES = new Set(['udta', 'meta']);
const MP4_WALKED_BOXES = new Set(['moov', 'trak']);
const XMP_UUID = Buffer.from('be7acfcb97a942e89c71999491e3afac', 'hex');
const WAV_METADATA_CHUNKS = new Set(['LIST', 'id3 ', 'ID3 ', 'bext', 'iXML', '_PMX', 'XMP ']);
const FLAC_METADATA_BLOCKS = new Set([4, 6]);
const FLAC_PADDING = 1;
const ID3V1_NO_GENRE = 255;
const FFMPEG_FORMATS = { 'video/webm': 'webm', 'audio/webm': 'webm', 'video/x-matroska': 'matroska', 'audio/x-matroska': 'matroska', 'video/ogg': 'ogg', 'audio/ogg': 'ogg', 'audio/opus': 'ogg', 'video/x-msvideo': 'avi' };
const readAt = async (fh, pos, len) => {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, pos);
return buf.subarray(0, bytesRead);
};
const zeroAt = async (fh, pos, len) => {
const zeros = Buffer.alloc(Math.min(len, STRIP_CHUNK));
for (let done = 0; done < len; done += zeros.length) await fh.write(zeros, 0, Math.min(zeros.length, len - done), pos + done);
};
const stripMp4 = async (fh, start, end, depth = 0) => {
let pos = start;
while (pos + 8 <= end) {
const head = await readAt(fh, pos, 16);
if (head.length < 8) return;
let size = head.readUInt32BE(0);
let header = 8;
const type = head.toString('latin1', 4, 8);
if (size === 1) { if (head.length < 16) return; size = Number(head.readBigUInt64BE(8)); header = 16; }
else if (size === 0) size = end - pos;
if (size < header || pos + size > end) return;
const isXmp = type === 'uuid' && (await readAt(fh, pos + header, XMP_UUID.length)).equals(XMP_UUID);
if (MP4_METADATA_BOXES.has(type) || isXmp) {
await fh.write(Buffer.from('free', 'latin1'), 0, 4, pos + 4);
await zeroAt(fh, pos + header, size - header);
}
else if (MP4_WALKED_BOXES.has(type) && depth < MP4_WALKED_BOXES.size) await stripMp4(fh, pos + header, pos + size, depth + 1);
pos += size;
}
};
const stripMp3 = async (fh, size) => {
const head = await readAt(fh, 0, 10);
if (head.length === 10 && head.toString('latin1', 0, 3) === 'ID3') {
const tagSize = ((head[6] & 0x7f) << 21) | ((head[7] & 0x7f) << 14) | ((head[8] & 0x7f) << 7) | (head[9] & 0x7f);
const footer = head[5] & 0x10 ? 10 : 0;
await fh.write(Buffer.from([0]), 0, 1, 5);
await zeroAt(fh, 10, Math.min(tagSize + footer, size - 10));
}
if (size >= 128 && (await readAt(fh, size - 128, 3)).toString('latin1') === 'TAG') {
await zeroAt(fh, size - 125, 125);
await fh.write(Buffer.from([ID3V1_NO_GENRE]), 0, 1, size - 1);
}
};
const stripFlac = async (fh, size) => {
if ((await readAt(fh, 0, 4)).toString('latin1') !== 'fLaC') return;
for (let pos = 4; pos + 4 <= size;) {
const head = await readAt(fh, pos, 4);
if (head.length < 4) return;
const last = head[0] & 0x80;
const len = head.readUIntBE(1, 3);
if (FLAC_METADATA_BLOCKS.has(head[0] & 0x7f)) {
await fh.write(Buffer.from([last | FLAC_PADDING]), 0, 1, pos);
await zeroAt(fh, pos + 4, len);
}
if (last) return;
pos += 4 + len;
}
};
const stripWav = async (fh, size) => {
const head = await readAt(fh, 0, 12);
if (head.length < 12 || head.toString('latin1', 0, 4) !== 'RIFF' || head.toString('latin1', 8, 12) !== 'WAVE') return;
for (let pos = 12; pos + 8 <= size;) {
const chunk = await readAt(fh, pos, 8);
if (chunk.length < 8) return;
const len = chunk.readUInt32LE(4);
if (WAV_METADATA_CHUNKS.has(chunk.toString('latin1', 0, 4))) {
await fh.write(Buffer.from('JUNK', 'latin1'), 0, 4, pos);
await zeroAt(fh, pos + 8, Math.min(len, size - pos - 8));
}
pos += 8 + len + (len & 1);
}
};
const stripWithFfmpeg = (filepath, format) => new Promise((resolve) => {
const out = `${filepath}.clean`;
const args = ['-v', 'error', '-y', '-i', filepath, '-map', '0', '-map_metadata', '-1', '-c', 'copy', '-f', format, out];
require('child_process').execFile(process.env.OASIS_FFMPEG || 'ffmpeg', args, { timeout: FFMPEG_STRIP_MS }, async (err) => {
try {
if (err) await promisesFs.unlink(out).catch(() => {});
else await promisesFs.rename(out, filepath);
} catch (_) {}
resolve();
});
});
const stripFileMetadata = async (filepath, mime) => {
const m = String(mime || '');
try {
const { size } = await promisesFs.stat(filepath);
if ((m.startsWith('image/') && m !== 'image/gif') || m === 'application/pdf') {
if (size > STRIP_IN_MEMORY_MAX) return;
const data = await promisesFs.readFile(filepath);
const clean = m === 'application/pdf' ? stripPdfMetadata(data) : await stripImageMetadata(data);
if (clean !== data) await promisesFs.writeFile(filepath, clean);
return;
}
if (!m.startsWith('audio/') && !m.startsWith('video/')) return;
if (FFMPEG_FORMATS[m]) { await stripWithFfmpeg(filepath, FFMPEG_FORMATS[m]); return; }
const fh = await promisesFs.open(filepath, 'r+');
try {
if (MP4_LIKE.test(m)) await stripMp4(fh, 0, size);
else if (m === 'audio/mpeg') await stripMp3(fh, size);
else if (m === 'audio/flac' || m === 'audio/x-flac') await stripFlac(fh, size);
else if (/wav|wave/.test(m)) await stripWav(fh, size);
} finally { await fh.close(); }
} catch (_) {}
};
const MAX_BLOB_SIZE = 75 * 1024 * 1024; const MAX_BLOB_SIZE = 75 * 1024 * 1024;
const KIND_MIME = { image: /^image\//, audio: /^audio\//, video: /^video\//, document: /^application\/pdf$/, torrent: /^application\/x-bittorrent$/ };
const looksLikeTorrent = (data) => data.length > 0 && data[0] === 0x64 && data.subarray(0, 4096).includes(Buffer.from('4:info'));
class WrongFileTypeError extends Error {
constructor(fileName, mime) {
super(`Wrong file type: ${fileName} (${mime})`);
this.name = 'WrongFileTypeError';
}
}
class FileTooLargeError extends Error { class FileTooLargeError extends Error {
constructor(fileName, fileSize) { constructor(fileName, fileSize) {
@ -387,7 +224,7 @@ const oggMime = (buffer) => {
const sealedRef = (sealed, mime, name) => `/blob/sealed/${encodeURIComponent(sealed.manifestBlobId)}?k=${encodeURIComponent(sealed.key)}&t=${encodeURIComponent(mime)}&n=${encodeURIComponent(name)}`; 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, kind = null) { const storeUploadedFile = async function (blobUpload, seal = null) {
if (!blobUpload || !blobUpload.filepath) return null; if (!blobUpload || !blobUpload.filepath) return null;
let data = await promisesFs.readFile(blobUpload.filepath); let data = await promisesFs.readFile(blobUpload.filepath);
@ -428,16 +265,11 @@ const storeUploadedFile = async function (blobUpload, seal = null, kind = null)
if (!blob.mime) { if (!blob.mime) {
blob.mime = 'application/octet-stream'; blob.mime = 'application/octet-stream';
} }
if (blob.mime === 'application/octet-stream' && looksLikeTorrent(data)) blob.mime = 'application/x-bittorrent';
if (kind && KIND_MIME[kind] && !KIND_MIME[kind].test(blob.mime)) throw new WrongFileTypeError(blob.name, blob.mime);
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') { if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
data = await stripImageMetadata(data); data = await stripImageMetadata(data);
} else if (blob.mime === 'application/pdf') { } else if (blob.mime === 'application/pdf') {
data = stripPdfMetadata(data); data = stripPdfMetadata(data);
} else if (blob.mime.startsWith('audio/') || blob.mime.startsWith('video/')) {
await stripFileMetadata(blobUpload.filepath, blob.mime);
data = await promisesFs.readFile(blobUpload.filepath);
} }
if (typeof seal === 'function') { if (typeof seal === 'function') {
@ -463,11 +295,11 @@ const storeUploadedFile = async function (blobUpload, seal = null, kind = null)
return `\n[${blob.name}](${blob.id})`; return `\n[${blob.name}](${blob.id})`;
}; };
const handleBlobUpload = async function (ctx, fileFieldName, seal = null, kind = null) { const handleBlobUpload = async function (ctx, fileFieldName, seal = null) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null; if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
const entry = ctx.request.files[fileFieldName]; const entry = ctx.request.files[fileFieldName];
const first = Array.isArray(entry) ? entry[0] : entry; const first = Array.isArray(entry) ? entry[0] : entry;
return storeUploadedFile(first, seal, kind); return storeUploadedFile(first, seal);
}; };
const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) { const handleBlobUploads = async function (ctx, fileFieldName, max = 8, seal = null) {
@ -646,5 +478,5 @@ const serveBlob = async function (ctx) {
sendBlobBuffer(ctx, buffer); sendBlobBuffer(ctx, buffer);
}; };
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, WrongFileTypeError, stripImageMetadata, stripFileMetadata }; module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, sendBlobBuffer, contentDisposition, oggMime, FileTooLargeError, stripImageMetadata };

View file

@ -94,6 +94,15 @@ const assertKind = (kind) => {
return k; return k;
}; };
const lastArg = (args, n) => args[args.length - n];
const kindFromArgs = (args) => {
const k = String(lastArg(args, 1) || "").trim();
return assertKind(k);
};
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
exports.getFavoriteSet = async (kind) => { exports.getFavoriteSet = async (kind) => {
const k = assertKind(kind); const k = assertKind(kind);
const data = await readAll(); const data = await readAll();
@ -133,4 +142,15 @@ exports.removeFavorite = async (kind, id) =>
await writeAll(data); await writeAll(data);
}); });
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
exports.addToFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.addFavorite(kind, id);
};
exports.removeFromFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.removeFavorite(kind, id);
};
exports.storePath = storePath; exports.storePath = storePath;

View file

@ -1,137 +0,0 @@
"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
// - las rutas propias: tema, colores y Modulos en el movil, barra de abajo, identidades
//
// No se toca lo que contesta el dev (ni sus redirecciones ni su HTML): si hace falta
// otra cosa, ruta propia aqui o enlace a la pantalla del dev. Ver GUIA_DE_DESARROLLO.md.
//
// 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 { mobileModulesView } = require("../views/mobile_modules_view");
require("../models/fork_workflows").register();
const isMobile = () => process.env.OASIS_MOBILE === '1';
module.exports = ({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage, about, backupModel }) => {
const pubBar = require('../views/mobile_pubbar');
pubBar.setSource(backupModel);
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');
})
// colores del movil (mobile_settings.js): se guardan en la config
.post('/settings/colors', koaBody(), async (ctx, next) => {
if (!isMobile()) return next();
const body = ctx.request.body || {};
const cfg = getConfig();
cfg.mobileColors = require('../views/mobile_settings').colorsFromBody(body);
saveConfig(cfg);
const back = String(body.returnTo || '');
ctx.redirect(/^\/(settings|welcome)(#[a-z-]+)?$/.test(back) ? back : '/settings#colors');
})
// barra de unirse a un pub (mobile_pubbar.js): cerrar el aviso de terminado
.post('/mobile/pub-status/dismiss', koaBody(), async (ctx, next) => {
if (!isMobile()) return next();
pubBar.dismiss();
const back = String(ctx.get('referer') || '').replace(/^https?:\/\/[^/]+/, '');
ctx.redirect(/^\/[\w\-/?=&%.#]*$/.test(back) ? back : '/activity');
})
.get('/mobile-colors.css', async (ctx, next) => {
if (!isMobile()) return next();
ctx.type = 'text/css';
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
ctx.body = require('../views/mobile_settings').colorsCss(getConfig());
})
// 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();
})
.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 || [];
// cada skin tiene su barra (fork_workflows.js): se cambia la del skin puesto
const bars = require("../models/fork_workflows");
let pins = bars.barFor(cfg).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 = [];
}
bars.saveBar(cfg, 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, forkRouter.routes()]);
};

View file

@ -0,0 +1,237 @@
"use strict";
// Rutas del modulo Karvan (salas efimeras en RAM, mensajes temporales y
// senalizacion WebRTC), mas las dos pantallas que la rama anade a los ajustes.
//
// Upstream pone todas sus rutas en backend.js: 625 en una sola cadena fluida.
// Estas viven aparte porque ese fichero se reescribe entero 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. Se montan en un router propio insertado
// antes del de upstream; lo que no casa cae a next() y sigue su curso.
//
// El nombre sigue a la familia del modulo: karvan_model.js, karvan_view.js,
// karvan.js, karvan.css.
//
// Las dependencias se inyectan en lugar de reconstruirse: cooler abre la conexion
// al sbot y duplicarla abriria un segundo cliente muxrpc.
const koaRouter = require("../server/node_modules/@koa/router");
const { koaBody } = require("../server/node_modules/koa-body");
const { stripDangerousTags } = require("./sanitizeHtml");
const { buildIceServers } = require("./turnCredentials");
const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC)
const { karvanView, karvanRoomView } = require("../views/karvan_view");
module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId,
getConfig, saveConfig, isLoopbackRequest, sendErrorPage }) => {
// FORK_IA_UX Fase 2 (cross-device): relay de MENSAJES de Karvan por privados SSB. Funciona vía un pub
// (el privado se replica al otro móvil); los SIGNAL de WebRTC NO se relayan (serían demasiados/lentos por el log).
// No toca el arranque del SSB. Ingesta por stream EN VIVO (no polling) → eficiente.
const karvanSeenRelay = new Set(); // mids ya inyectados (evita duplicados/bucles)
let karvanRelaySub = false; // ¿suscriptor del log en vivo montado?
async function karvanPublishRelay(roomId, mid, from, text) {
try {
const feeds = karvanModel.getRemoteFeeds(roomId);
if (!feeds.length) return;
const ssbClient = await cooler.open();
const recps = [...new Set([ssbClient.id, ...feeds])].slice(0, 8);
await new Promise((res, rej) => ssbClient.private.publish(
{ type: 'karvan-relay', roomId, mid: String(mid || '').slice(0, 60), from: String(from || '?').slice(0, 80), text: String(text || '').slice(0, 2000), private: true },
recps, (e, m) => e ? rej(e) : res(m)));
} catch (e) {}
}
async function karvanStartRelay() {
if (karvanRelaySub) return;
karvanRelaySub = true;
try {
const ssbClient = await cooler.open();
const me = ssbClient.id;
pull(
ssbClient.createLogStream({ old: false, live: true }),
pull.drain((m) => {
try {
if (!m || !m.value) return;
let dec; try { dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.timestamp }); } catch (_) { return; }
const c = dec && dec.value && dec.value.content;
if (!c || c.type !== 'karvan-relay' || !c.roomId || !c.mid) return;
if (dec.value.author === me) return; // no reinyectar lo propio (evita bucle A→B→A)
if (karvanSeenRelay.has(c.mid)) return;
if (!karvanModel.getRoom(c.roomId)) return; // solo si tenemos esa sala localmente
karvanSeenRelay.add(c.mid);
if (karvanSeenRelay.size > 5000) karvanSeenRelay.clear();
karvanModel.postMessage(c.roomId, { from: c.from, text: c.text, mid: c.mid });
} catch (e) {}
})
);
} catch (e) { karvanRelaySub = false; }
}
// ¿quién me invitó a esta sala? (para registrar su feed en el espejo local) — reutiliza el lector de privados
async function karvanInviteAuthor(roomId) {
try {
const msgs = await pmModel.listAllPrivate();
for (const m of (msgs || [])) {
const c = m && m.value && m.value.content;
if (c && c.subject === 'KARVAN_INVITE' && typeof c.text === 'string' && c.text.indexOf('/karvan/' + roomId) !== -1) return m.value.author;
}
} catch (e) {}
return null;
}
const forkRouter = new koaRouter();
forkRouter
// Configuracion ICE para las llamadas. Solo loopback: la credencial TURN va
// firmada con el secreto del nodo y no debe salir de la maquina.
.get('/karvan/ice', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = { error: 'forbidden' }; return; }
let who = ''; try { who = getViewerId() || ''; } catch (e) {}
let rtc = null; try { rtc = (getConfig() || {}).rtc || null; } catch (e) {}
ctx.body = buildIceServers(rtc, who);
})
// ===== FORK_IA_UX: módulo KARVAN (mensajes temporales RAM + WebRTC data-channel) =====
.get('/karvan', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
ctx.body = karvanView(karvanModel.listRooms(), { selfId });
})
.post('/karvan/create', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const room = karvanModel.createRoom({
title: stripDangerousTags(String(b.title || '').trim()).slice(0, 80),
ttlMinutes: b.ttl,
});
ctx.redirect('/karvan/' + room.id);
})
.get('/karvan/:id', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
let room = karvanModel.getRoom(ctx.params.id);
// unirse por enlace/invitación crea un espejo local, pero SOLO en una navegación real (documento):
// un <img>/subrecurso de una web maliciosa NO debe poder crear/expulsar salas por GET (CSRF). El navegador
// controla sec-fetch-dest/accept, así que son señales fiables.
let justAdopted = false;
if (!room) {
const isNav = ctx.get('sec-fetch-dest') === 'document' || (ctx.get('accept') || '').includes('text/html');
if (isNav) { room = karvanModel.adoptRoom({ id: ctx.params.id }); justAdopted = !!room; }
}
if (!room) { ctx.redirect('/karvan'); return; }
if (justAdopted) { // al unirte por enlace, registra el feed de quien te invitó → relay cross-device
const author = await karvanInviteAuthor(ctx.params.id);
if (author) { karvanModel.addRemoteFeed(ctx.params.id, author); karvanStartRelay(); }
}
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msgs = karvanModel.listMessages(room.id, 0) || [];
ctx.body = karvanRoomView(room, msgs, { selfId, invite: ctx.query.invite || '' });
})
.post('/karvan/:id/invite', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const id = ctx.params.id;
const room = karvanModel.getRoom(id);
if (!room) { ctx.redirect('/karvan'); return; }
const to = String((ctx.request.body || {}).to || '').trim();
if (!/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(to)) { ctx.redirect('/karvan/' + id + '?invite=bad'); return; }
try {
// reutiliza el patrón de invitación por mensaje privado de Oasis (como el módulo industry):
// le llega al INBOX del contacto con un enlace a la sala. Sin código SSB nuevo.
await pmModel.sendMessage([to], 'KARVAN_INVITE',
'You have been invited to an ephemeral Karvan room' + (room.title ? ' ("' + room.title + '")' : '') + ' -> /karvan/' + id);
karvanModel.addRemoteFeed(id, to); karvanStartRelay(); // relay cross-device hacia el invitado
ctx.redirect('/karvan/' + id + '?invite=ok');
} catch (e) { ctx.redirect('/karvan/' + id + '?invite=fail'); }
})
.post('/karvan/:id/msg', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msg = karvanModel.postMessage(ctx.params.id, {
from: String(b.from || selfId || '?').slice(0, 80),
text: stripDangerousTags(String(b.text || '')).slice(0, 2000),
mid: b.mid,
});
if (!msg) { ctx.status = 404; ctx.body = { error: 'no room' }; return; }
karvanPublishRelay(ctx.params.id, msg.mid || (msg.from + '-' + msg.ts + '-' + msg.seq), msg.from, msg.text); // → feeds remotos (si hay)
const wantsJson = (ctx.get('accept') || '').indexOf('application/json') !== -1
|| (ctx.get('content-type') || '').indexOf('application/json') !== -1;
if (wantsJson) ctx.body = { ok: true, seq: msg.seq };
else ctx.redirect('/karvan/' + ctx.params.id);
})
.get('/karvan/:id/msgs', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const msgs = karvanModel.listMessages(ctx.params.id, ctx.query.since || 0);
ctx.body = { messages: msgs || [] };
})
.post('/karvan/:id/signal', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
karvanModel.postSignal(ctx.params.id, { from: b.from, to: b.to, payload: b.payload });
ctx.body = { ok: true };
})
.get('/karvan/:id/signal', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const r = karvanModel.getSignals(ctx.params.id, { forId: ctx.query.for || '', since: ctx.query.since || 0 });
ctx.body = r || { signals: [], members: [] };
})
.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 forkRouter.routes();
};

View file

@ -1,67 +0,0 @@
"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 y nombre propios. 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;
// el nombre, para el bloque de identidad de Ajustes
const name = me ? await about.name(me) : null;
// sin nombre puesto, ssb-about da un trozo de la clave: eso no es un nombre
global.__OASIS_SELF_NAME__ = typeof name === 'string' && name[0] !== '@' && !String(me).startsWith('@' + name) ? name : '';
} catch (_) {}
};
// un solo skin en el movil desde el 20/10/2026: quien venga de Acampada u
// OasisMobile pasa a Activista (su config se conserva al actualizar la APK)
let skinChecked = false;
const checkSkin = () => {
if (skinChecked) return;
skinChecked = true;
try {
const { getConfig, saveConfig } = require('../configs/config-manager.js');
const cfg = getConfig();
if ((cfg.themes || {}).current !== 'Activista') { cfg.themes = { ...(cfg.themes || {}), current: 'Activista' }; saveConfig(cfg); }
} catch (_) {}
};
return async (ctx, next) => {
if (!isMobile() || !isPage(ctx.path)) return next();
checkSkin();
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;
// comentarios abiertos en las fichas: plegados, la caja de comentar iba tras un
// "✑(0)" sin texto y quien comentaba no veia el suyo al volver. Es la opcion del dev
// para ?comments=open (comments_view.setCommentsOpen). En el parlamento hay un bloque
// por propuesta en la lista: ahi se quedan plegados
if (ctx.method === 'GET' && !ctx.path.startsWith('/parliament')) {
try { require('../views/comments_view').setCommentsOpen(true); } catch (_) {}
}
// en Ajustes y en el perfil (adonde vuelve el dev tras /profile/edit) el nombre y el
// avatar se leen al momento; en el resto, como mucho cada minuto
if (!avatarAt || ctx.path === '/settings' || ctx.path === '/profile') await refreshAvatar();
else if (Date.now() - avatarAt > 60000) refreshAvatar();
return next();
};
};

View file

@ -17,15 +17,4 @@ const set = (feedId, name, ts) => {
const has = (feedId) => !!feedId && cache.has(String(feedId)) const has = (feedId) => !!feedId && cache.has(String(feedId))
const size = () => cache.size const size = () => cache.size
const images = new Map() module.exports = { get, set, has, size }
const NULL_IMAGE_RE = /^&0{43}=\.sha256$/
const image = (feedId) => (feedId && images.get(String(feedId))) || null
const setImage = (feedId, img) => {
const link = typeof img === 'string' ? img : (img && typeof img.link === 'string' ? img.link : null)
if (!feedId || !link || !link.startsWith('&') || NULL_IMAGE_RE.test(link)) return
images.set(String(feedId), link)
}
module.exports = { get, set, has, size, image, setImage }

View file

@ -20,7 +20,6 @@ const constructive = [
"rejected", "rejected",
"needsSources", "needsSources",
"wrong", "wrong",
"outdated",
"lowQuality", "lowQuality",
"confusing", "confusing",
"misleading", "misleading",
@ -47,7 +46,7 @@ const all = [...positive, ...constructive, ...moderation];
all.positive = positive; all.positive = positive;
all.constructive = constructive; all.constructive = constructive;
all.moderation = moderation; all.moderation = moderation;
const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources", "outdated"]; const karmaNeutral = ["adultOnly", "nsfw", "triggering", "duplicate", "offTopic", "needsSources"];
const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"]; const karmaModeration = [...moderation.filter(c => !karmaNeutral.includes(c)), "propaganda"];
all.karma = { all.karma = {
positive, positive,
@ -80,19 +79,7 @@ all.messageTypes = {
podcast: 'podcastOpinion', podcast: 'podcastOpinion',
podcastEpisode: 'podcastOpinion', podcastEpisode: 'podcastOpinion',
campaign: 'campaignOpinion', campaign: 'campaignOpinion',
logisticsRoute: 'logisticsOpinion', logisticsRoute: 'logisticsOpinion'
wikiPage: 'wikiOpinion',
map: 'mapOpinion',
industryBlueprint: 'blueprintOpinion',
job: 'jobOpinion'
}; };
all.opinable = (c) => !!c && typeof c === 'object' && !!all.messageTypes[c.type] && !c.tribeId && !c.encrypted && !c.encryptedPayload && !c.contentEncrypted
&& (c.type !== 'map' || String(c.mapType || '').toUpperCase() === 'OPEN')
&& (c.type !== 'job' || String(c.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN')
&& (c.type !== 'schoolCourse' || String(c.visibility || 'PUBLIC').toUpperCase() !== 'INVITE');
all.hasOpinions = (x) => Object.values((x && x.opinions) || {}).some(n => (Number(n) || 0) > 0);
all.MEDIA_LOCKED = 'Cannot replace the file after it has received opinions.';
all.assertMediaKept = (item, replacing) => { if (replacing && all.hasOpinions(item)) throw new Error(all.MEDIA_LOCKED); };
module.exports = all; module.exports = all;

View file

@ -450,6 +450,14 @@ const reportSections = (report) => {
out.push({ kind: 'text', text: report.description }); out.push({ kind: 'text', text: report.description });
} }
const tpl = report.template && typeof report.template === 'object' ? report.template : {};
const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim());
if (tplEntries.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DETAILS' });
for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v });
}
const confirmations = asList(report.confirmations); const confirmations = asList(report.confirmations);
out.push({ kind: 'blank' }); out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CONFIRMATIONS' }); out.push({ kind: 'section', text: 'CONFIRMATIONS' });

View file

@ -1,176 +0,0 @@
const path = require('path');
const crypto = require('crypto');
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const { pathToFileURL } = require('url');
const WORKER_TAG = 'oasis-pdf-view';
const MUPDF_URL = pathToFileURL(path.join(__dirname, '..', 'server', 'node_modules', 'mupdf', 'dist', 'mupdf.js')).href;
const PDF_BASE_WIDTH = 1600;
const PDF_FIT_WIDTH = 1200;
const PDF_WEBP_EFFORT = 1;
const PDF_ZOOMS = [50, 75, 100, 125, 150, 200, 300];
const PDF_ZOOM_DEFAULT = 100;
const PDF_PAGE_MAX_PX = 4096;
const PDF_CALL_MS = 20000;
const PDF_DOCS_BYTES = 128 * 1024 * 1024;
const PDF_ROTATIONS = [0, 90, 180, 270];
const PDF_FRAME_ID_LEN = 12;
const PDF_PAGES_CACHE_BYTES = 64 * 1024 * 1024;
const PDF_WORKER_HEAP_MB = 256;
const PDF_WEBP_QUALITY = 85;
const PDF_MAGIC = Buffer.from('%PDF-');
const PDF_MAGIC_WINDOW = 1024;
const widthOf = (zoom) => (zoom <= PDF_ZOOM_DEFAULT ? PDF_FIT_WIDTH : Math.round(PDF_BASE_WIDTH * zoom / 100));
const PDF_WIDTHS = new Set(PDF_ZOOMS.map(widthOf));
const zoomOf = (value) => { const z = Number(value); return PDF_ZOOMS.includes(z) ? z : PDF_ZOOM_DEFAULT; };
const rotationOf = (value) => { const r = Number(value); return PDF_ROTATIONS.includes(r) ? r : 0; };
const looksLikePdf = (buf) => Buffer.isBuffer(buf) && buf.subarray(0, PDF_MAGIC_WINDOW).indexOf(PDF_MAGIC) >= 0;
if (!isMainThread && workerData === WORKER_TAG) {
const docs = new Map();
const sizes = new Map();
let openBytes = 0;
let mupdf = null;
const need = (key) => {
const doc = docs.get(key);
if (!doc) throw new Error('NO_DOC');
docs.delete(key);
docs.set(key, doc);
return doc;
};
const describe = (doc) => ({ pages: doc.countPages() });
const ops = {
open({ key, data }) {
const doc = mupdf.Document.openDocument(Buffer.from(data), 'application/pdf');
if (doc.needsPassword()) { doc.destroy(); throw new Error('LOCKED'); }
docs.set(key, doc);
sizes.set(key, data.byteLength);
openBytes += data.byteLength;
while (openBytes > PDF_DOCS_BYTES && docs.size > 1) {
const [oldKey, oldDoc] = docs.entries().next().value;
docs.delete(oldKey);
openBytes -= sizes.get(oldKey) || 0;
sizes.delete(oldKey);
try { oldDoc.destroy(); } catch (_) {}
}
return describe(doc);
},
info({ key }) { return describe(need(key)); },
render({ key, page, width, rotate }) {
const doc = need(key);
const pg = doc.loadPage(page - 1);
try {
const [x0, y0, x1, y1] = pg.getBounds();
let scale = width / Math.max(1, x1 - x0);
scale = Math.min(scale, PDF_PAGE_MAX_PX / Math.max(1, y1 - y0), PDF_PAGE_MAX_PX / Math.max(1, x1 - x0));
const pix = pg.toPixmap(mupdf.Matrix.concat(mupdf.Matrix.scale(scale, scale), mupdf.Matrix.rotate(rotate || 0)), mupdf.ColorSpace.DeviceRGB, false, true);
try { return { jpeg: pix.asJPEG(PDF_WEBP_QUALITY, false) }; } finally { pix.destroy(); }
} finally { pg.destroy(); }
}
};
const debug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
const ready = import(MUPDF_URL).then((m) => {
mupdf = m;
mupdf.setLog(debug ? (message) => console.error(`[pdf] ${message}`) : () => {});
});
parentPort.on('message', async ({ id, op, args }) => {
try {
await ready;
if (!ops[op]) throw new Error('BAD_OP');
const { data, ...rest } = args || {};
if (data && !docs.has(rest.key)) ops.open({ key: rest.key, data });
const value = op === 'open' ? describe(need(rest.key)) : ops[op](rest);
parentPort.postMessage({ id, value });
} catch (e) {
parentPort.postMessage({ id, error: String((e && e.message) || e) });
}
});
} else {
let worker = null;
let current = null;
const queue = [];
const stop = () => { const w = worker; worker = null; if (w) w.terminate().catch(() => {}); };
const settle = (outcome) => {
const job = current;
if (!job) return;
current = null;
clearTimeout(job.timer);
if (outcome.error) job.reject(new Error(outcome.error)); else job.resolve(outcome.value);
pump();
};
const start = () => {
const w = new Worker(__filename, { workerData: WORKER_TAG, resourceLimits: { maxOldGenerationSizeMb: PDF_WORKER_HEAP_MB } });
w.on('message', (msg) => { if (worker === w && current && msg.id === current.id) settle(msg); });
const lost = () => { if (worker === w) { worker = null; settle({ error: 'WORKER' }); } };
w.on('error', lost);
w.on('exit', lost);
w.unref();
worker = w;
};
let seq = 0;
const pump = () => {
if (current || !queue.length) return;
if (!worker) start();
current = queue.shift();
current.timer = setTimeout(() => { stop(); settle({ error: 'TIMEOUT' }); }, PDF_CALL_MS);
worker.postMessage({ id: current.id, op: current.op, args: current.args });
};
const call = (op, args) => new Promise((resolve, reject) => {
queue.push({ id: ++seq, op, args, resolve, reject, timer: null });
pump();
});
const loading = new Map();
const loadPdf = (key, load) => {
if (!loading.has(key)) {
loading.set(key, (async () => {
const data = await load();
if (!data) throw new Error('UNAVAILABLE');
if (!looksLikePdf(data)) throw new Error('NOT_PDF');
return data;
})().finally(() => loading.delete(key)));
}
return loading.get(key);
};
const withDoc = async (key, load, op, args = {}) => {
try { return await call(op, { key, ...args }); } catch (e) { if (e.message !== 'NO_DOC') throw e; }
return call(op, { key, ...args, data: await loadPdf(key, load) });
};
const pages = new Map();
let pagesBytes = 0;
const remember = (k, v) => {
pages.set(k, v);
pagesBytes += v.body.length;
for (const [old, entry] of pages) {
if (pagesBytes <= PDF_PAGES_CACHE_BYTES) break;
pages.delete(old);
pagesBytes -= entry.body.length;
}
};
const encode = async ({ jpeg }) => ({ body: Buffer.from(jpeg), type: 'image/jpeg' });
const drawing = new Map();
const keyOf = (text) => crypto.createHash('sha256').update(String(text)).digest('hex');
const frameIdOf = (src) => `pdf-${keyOf(src).slice(0, PDF_FRAME_ID_LEN)}`;
module.exports = {
PDF_BASE_WIDTH, PDF_ZOOMS, PDF_ZOOM_DEFAULT, PDF_ROTATIONS,
widthOf, zoomOf, rotationOf, looksLikePdf, keyOf, frameIdOf,
isFrameId: (v) => new RegExp(`^pdf-[0-9a-f]{${PDF_FRAME_ID_LEN}}$`).test(String(v || '')),
isPdfWidth: (w) => PDF_WIDTHS.has(Number(w)),
info: (key, load) => withDoc(key, load, 'info'),
async page(key, load, page, width, rotation) {
const r = rotationOf(rotation);
const cacheKey = `${key}|${page}|${width}|${r}`;
const hit = pages.get(cacheKey);
if (hit) { pages.delete(cacheKey); pages.set(cacheKey, hit); return hit; }
if (!drawing.has(cacheKey)) {
drawing.set(cacheKey, (async () => {
const out = await encode(await withDoc(key, load, 'render', { page: Number(page), width: Number(width), rotate: r }), r);
remember(cacheKey, out);
return out;
})().finally(() => drawing.delete(cacheKey)));
}
return drawing.get(cacheKey);
},
shutdown: () => { stop(); while (queue.length) queue.shift().reject(new Error('STOPPED')); if (current) settle({ error: 'STOPPED' }); }
};
}

View file

@ -1,4 +1,4 @@
const { a, img, video, audio, span, strong, em, u, s: strike, code, div, textarea, object } = require("../server/node_modules/hyperaxe"); 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 i18nBase = require("../client/assets/translations/i18n");
const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model"); const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model");
@ -202,20 +202,7 @@ function renderStyledText(value, opts = {}) {
result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) })); result.push(audio({ controls: true, class: 'post-audio', src: blobHref(m.blob) }));
} else if (m.type === 'blob-pdf') { } else if (m.type === 'blob-pdf') {
const i18n = getI18n(); const i18n = getI18n();
const pdfLabel = m.name || i18n.pdfFallbackLabel || 'PDF'; result.push(a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, m.name || i18n.pdfFallbackLabel || 'PDF'));
const clearnet = String(opts.blobPrefix || '').startsWith('/c/');
let lang = '';
try { lang = clearnet ? ((require('../models/typed_log').requestScope.getStore() || {}).cnLang || '') : ''; } catch (_) {}
const fid = require('./pdf_view').frameIdOf(blobHref(m.blob));
const query = new URLSearchParams({ src: blobHref(m.blob), title: String(m.name || ''), ...(lang ? { lang } : {}), fid });
const holder = (src, cls) => span({ class: 'pdf-holder' },
object({ id: fid, data: src, type: 'text/html', class: cls, title: pdfLabel, 'aria-label': pdfLabel }, ''),
a({ href: '#', class: 'pdf-unmax', title: i18n.pdfClose || 'Close' }, '✕'));
result.push(clearnet
? holder(`/c/pdf/embed?${query}`, 'cn-pdf-frame')
: opts.blobPrefix
? a({ href: blobHref(m.blob), class: 'post-pdf', target: '_blank', rel: 'noopener noreferrer' }, pdfLabel)
: holder(`/pdf/embed?${query}`, 'pdf-frame'));
} else if (m.type === 'blob-file') { } 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))); 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') { } else if (m.type === 'md-mention') {

View file

@ -68,7 +68,7 @@ const publicModeGuard = ({ isPublic, onBlocked }) => async (ctx, next) => {
await next(); await next();
}; };
const buildCsp = (isClearnet, { frames = '', framed = false, objects = '' } = {}) => (isClearnet const buildCsp = (isClearnet, { frames = false } = {}) => (isClearnet
? [ ? [
"default-src 'self'", "default-src 'self'",
"script-src 'none'", "script-src 'none'",
@ -76,27 +76,24 @@ const buildCsp = (isClearnet, { frames = '', framed = false, objects = '' } = {}
"img-src 'self' data:", "img-src 'self' data:",
"media-src 'self' blob:", "media-src 'self' blob:",
"connect-src 'self'", "connect-src 'self'",
frames ? `frame-src ${frames}` : "frame-src 'none'", "frame-src 'none'",
"form-action 'self'", "form-action 'self'",
objects ? `object-src ${objects}` : "object-src 'none'", "object-src 'none'",
"base-uri 'none'", "base-uri 'none'",
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'" "frame-ancestors 'none'"
] ]
: [ : [
"default-src 'self'", "default-src 'self'",
"script-src 'none'", "script-src 'self' http://localhost:3000/js",
"style-src 'self'", "style-src 'self'",
"img-src 'self'", "img-src 'self'",
"media-src 'self' blob:", "media-src 'self' blob:",
frames ? `frame-src ${frames}` : "frame-src 'none'", "worker-src 'self' blob:",
frames ? "frame-src 'self'" : "frame-src 'none'",
"form-action 'self'", "form-action 'self'",
objects ? `object-src ${objects}` : "object-src 'none'", "object-src 'none'",
"base-uri 'none'", "base-uri 'none'",
framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'" "frame-ancestors 'none'"
]).join('; '); ]).join('; ');
const IMAGE_SIZE_MIN = 16; module.exports = { isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };
const IMAGE_SIZE_MAX = 1024;
const isImageSize = (value) => { const n = Number(value); return Number.isInteger(n) && n >= IMAGE_SIZE_MIN && n <= IMAGE_SIZE_MAX; };
module.exports = { IMAGE_SIZE_MIN, IMAGE_SIZE_MAX, isImageSize, isMsgKey, pickMsgKeys, isLocalPath, safeReturnTo, refererPath, safeRefererRedirect, isClearnetPath, isTrustedRequest, publicModeGuard, buildCsp };

View file

@ -0,0 +1,50 @@
"use strict";
// Credenciales efimeras para un TURN propio (coturn con use-auth-secret).
//
// Es el mecanismo REST de coturn: el servidor no guarda usuarios, valida que
// credential == base64(HMAC-SHA1(static-auth-secret, username))
// y que el timestamp del username no haya caducado. Asi las credenciales se
// generan sin hablar con coturn y caducan solas.
//
// Portado del turn.js de karvan-web, cambiando sessionId por el feed id: lo que
// identifica a quien llama en Oasis es su feed, no una sesion anonima.
const crypto = require("crypto");
const DEFAULT_TTL = 3600;
// username = <caduca en unix> ":" <identificador>
function mintTurnCredentials({ urls, secret, ttlSec = DEFAULT_TTL }, who, now = Date.now()) {
if (!secret || !Array.isArray(urls) || !urls.length) return null;
const expiry = Math.floor(now / 1000) + ttlSec;
const username = expiry + ":" + String(who || "anon").slice(0, 64);
const credential = crypto.createHmac("sha1", secret).update(username).digest("base64");
return { urls, username, credential, ttl: ttlSec, expiry };
}
// Lee la configuracion de rtc y devuelve lo que el navegador espera en iceServers.
// Formas admitidas en oasis-config.json:
// "rtc": { "stun": ["stun:mi.pub:3478"] }
// "rtc": { "turn": { "urls": [...], "secret": "...", "ttlSec": 3600 }, "relayOnly": true }
// "rtc": { "turn": { "urls": [...], "username": "...", "credential": "..." } } (estatico)
function buildIceServers(rtc, who, now = Date.now()) {
const out = [];
if (!rtc || typeof rtc !== "object") return { iceServers: out, relayOnly: false };
if (Array.isArray(rtc.stun)) {
for (const u of rtc.stun) if (typeof u === "string" && u) out.push({ urls: u });
}
const t = rtc.turn;
if (t && Array.isArray(t.urls) && t.urls.length) {
if (t.secret) {
const c = mintTurnCredentials(t, who, now);
if (c) out.push({ urls: c.urls, username: c.username, credential: c.credential });
} else if (t.username && t.credential) {
out.push({ urls: t.urls, username: t.username, credential: t.credential });
}
}
return { iceServers: out, relayOnly: !!rtc.relayOnly && out.some(s => s.credential) };
}
module.exports = { mintTurnCredentials, buildIceServers };

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 17v3h16v-3"/></svg>

Before

Width:  |  Height:  |  Size: 227 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>

Before

Width:  |  Height:  |  Size: 242 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M8 7l4-4 4 4"/><path d="M5 12v8h14v-8"/></svg>

Before

Width:  |  Height:  |  Size: 226 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l16-8-6 16-3-7z"/></svg>

Before

Width:  |  Height:  |  Size: 189 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M9 12h6M9 16h6"/></svg>

Before

Width:  |  Height:  |  Size: 232 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><path d="M3 20a6 6 0 0 1 12 0"/><circle cx="17" cy="9" r="2.5"/><path d="M16 14a5 5 0 0 1 5 5"/></svg>

Before

Width:  |  Height:  |  Size: 282 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/></svg>

Before

Width:  |  Height:  |  Size: 260 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 11l7.4-3.8M8.3 13l7.4 3.8"/></svg>

Before

Width:  |  Height:  |  Size: 296 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7L4 12l5 5"/><path d="M4 12h10a6 6 0 0 1 6 6v1"/></svg>

Before

Width:  |  Height:  |  Size: 218 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M10 8l4 4-4 4"/></svg>

Before

Width:  |  Height:  |  Size: 213 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>

Before

Width:  |  Height:  |  Size: 211 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4L3 20h18z"/><path d="M12 4v16"/><path d="M9 20l3-5 3 5"/></svg>

Before

Width:  |  Height:  |  Size: 228 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/><path d="M22 2L2 22"/></svg>

Before

Width:  |  Height:  |  Size: 282 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/></svg>

Before

Width:  |  Height:  |  Size: 260 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 251 B

View file

@ -1,522 +0,0 @@
/* Lanzador, barras, tarjetas e identidades del skin Activista (rama movil).
*
* Va detras de themes/Activista.css y pinta las piezas propias de la rama con las
* variables --a26-* (las de serie estan en el tema; Colores las cambia). Hasta el
* 20/10/2026 era hive_acampada.css, del skin Acampada. */
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: none;
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: none !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; color: var(--a26-on-neon) !important; }
.oasis-mobile-topbar .omt-menus .oasis-menu-btn:active { box-shadow: none !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: none !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 {
box-shadow: none !important;
background: var(--a26-neon) !important;
border-color: var(--a26-edge) !important; color: var(--a26-on-neon) !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-bar, var(--a26-bg)) !important;
border: 0 !important;
border-top: var(--a26-bar-bd, var(--a26-bd)) !important;
box-shadow: none !important;
}
.oasis-bottombar { padding: 6px 4px calc(6px + var(--inset-bottom)) !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-on-neon) !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-on-neon) !important; border: 1px solid var(--a26-edge) !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: none !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; color: var(--a26-on-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: 1px solid var(--a26-edge) !important; }
.forum-comment.highlighted-reply { background: var(--a26-tint) !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-neon) !important;
border-color: var(--a26-neon) !important;
color: var(--a26-on-neon) !important;
}
.filter-btn.active { background-color: var(--a26-neon) !important; border-color: var(--a26-neon) !important; color: var(--a26-on-neon) !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: 44px !important; font-size: 16px !important; text-transform: none !important; }
/* aviso de bienvenida: el boton de empezar con el resalte y la X sin caja */
.welcome-banner .welcome-banner-start {
background: var(--a26-neon) !important;
color: var(--a26-on-neon) !important;
box-shadow: none !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; color: var(--a26-on-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: none; 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-on-neon) !important; }
.identities .identity-warn { color: var(--a26-danger-text) !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-neon) !important;
background: var(--a26-neon) !important;
color: var(--a26-on-neon) !important;
font-size: 16px !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: var(--a26-danger-text) !important; border-color: var(--a26-danger-text) !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: none !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; color: var(--a26-on-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: var(--a26-muted) !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-card-bd, var(--a26-bd)) !important; border-radius: var(--a26-radius) !important;
box-shadow: var(--a26-card-shadow, none) !important;
background: var(--a26-card, 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-edge) !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-edge) !important;
}
.main-column table { border: var(--a26-bd-fino) !important; border-radius: 10px !important; }
.main-column table tr + tr td { border-top: 1px solid var(--a26-line) !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: none; 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; color: var(--a26-on-neon) !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 var(--a26-line) !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; color: var(--a26-on-neon) !important; }
.activity-chip:hover { color: var(--a26-fg) !important; border-color: var(--a26-edge) !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: none !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-edge) !important;
color: var(--a26-on-neon) !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; color: var(--a26-on-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: none !important;
}
label.welcome-ux-option:has(input:checked) { background: var(--a26-neon) !important; color: var(--a26-on-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: var(--a26-tint) !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-edge) !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: none !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: none; letter-spacing: .02em; font-size: 21px !important;
}
.skin-card.is-current { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; border: var(--a26-bd) !important; }
.skin-card:active { box-shadow: none !important; background: var(--a26-neon) !important; color: var(--a26-on-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; }
/* ---------- piezas del dev con fondo oscuro fijo (pruebas del 20/10) ---------- */
/* style.css pinta .time-chip y .pref-pill en gris casi negro y Acampada pone el texto
en negro: no se leian. Se pasan a los colores del tema */
.time-chip { background: var(--a26-soft) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; }
.pref-pill { background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; border-radius: 10px !important; min-height: 44px; }
/* con especificidad alta: la regla de etiquetas de formulario de mobile_ux.css le
ponia el color del texto normal encima del resalte */
.main-column label.pref-pill:has(.pref-pill-input:checked) { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
.pref-pill.pref-pill-forced { background: #c0392b !important; border-color: #c0392b !important; color: #fff !important; }
/* el plegable de mensajes anteriores del buzon: gris claro sobre blanco */
summary.pm-thread-toggle { color: var(--a26-muted) !important; }
.pm-thread-icon { font-size: 12px !important; }
/* fichas de la actividad y de filtros: mobile.css del dev las pone en mayusculas */
.activity-chip, a.activity-chip, .filter-btn, a.filter-btn { text-transform: none !important; }

View file

@ -0,0 +1,67 @@
.hljs-comment,
.hljs-quote {
color: var(--base03);
}
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: var(--base08);
}
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: var(--base09);
}
.hljs-attribute {
color: var(--base0A);
}
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: var(--base0B);
}
.hljs-title,
.hljs-section {
color: var(--base0D);
}
.hljs-keyword,
.hljs-selector-tag {
color: var(--base0E);
}
.hljs {
display: block;
overflow-x: auto;
color: var(--base05);
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-string, .hljs-link {
background: #FFA500;
user-select: text;
}

View file

@ -0,0 +1,103 @@
/* Estilos del modulo Karvan (salas efimeras y llamadas).
*
* Viven aparte del tema para que el modulo se vea igual en cualquier tema y
* tambien en escritorio, donde OasisMobile.css no se carga. Mismo patron que
* highlight.css, que tampoco pertenece a ningun tema concreto.
*/
/* --- Reply a mensaje en chats (server-side ?replyTo=, sin JS) --- */
.chat-message-meta .chat-reply-btn { margin-left: auto; padding: 0 4px; color: #FFD700; text-decoration: none; font-size: 15px; opacity: .85; }
.chat-message-self .chat-message-meta .chat-reply-btn { margin-left: 8px; }
.chat-quote {
display: block; border-left: 3px solid #FFD700; padding: 3px 8px; margin: 0 0 5px;
background: rgba(255,215,0,.08); border-radius: 6px; font-size: 12px; line-height: 1.3;
}
.chat-quote-author, .chat-quote-author a { color: #FFD700; font-weight: 700; margin-right: 6px; }
.chat-quote-text { color: #FFDD44; }
.chat-replying-to {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
border-left: 3px solid #FFD700; background: rgba(255,215,0,.10);
padding: 6px 10px; margin-bottom: 8px; border-radius: 8px; font-size: 12px;
}
.chat-replying-label { color: #FFD700; font-weight: 700; }
.chat-reply-cancel { margin-left: auto; color: #FFD700; text-decoration: none; font-weight: 700; font-size: 14px; }
/* ==========================================================================
FORK_IA_UX · Módulo KARVAN (mensajes temporales + WebRTC) — chat estilo burbujas
========================================================================== */
.karvan-intro h2 { color: #FFD700; margin: 0 0 6px; }
.karvan-sub { color: #FFDD44; font-size: 13px; }
.karvan-create input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
.karvan-ttl { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 10px 0; }
.karvan-ttl-lbl { color: #FFDD44; font-size: 13px; }
.karvan-ttl-opt { display: inline-flex; align-items: center; gap: 4px; color: #FFD700; font-size: 13px; white-space: nowrap; }
.karvan-create button, .karvan-compose button { padding: 10px 16px; border: 1px solid #FFD700;
border-radius: 10px; background: #FFD700; color: #121212; font-weight: 700; cursor: pointer; }
.karvan-rooms { list-style: none; margin: 8px 0 0; padding: 0; }
.karvan-rooms li { margin: 0 0 8px; }
.karvan-room-item { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 12px 14px; background: #161616; border: 1px solid #333; border-radius: 12px;
color: #FFD700; text-decoration: none; min-height: 48px; box-sizing: border-box; }
.karvan-room-item .kr-title { font-weight: 600; }
.karvan-room-item .kr-meta { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-empty { color: #FFDD44; }
/* sala */
.karvan-room-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.karvan-back { color: #FFD700; text-decoration: none; font-weight: 600; }
.karvan-room-title { flex: 1 1 auto; color: #FFD700; font-weight: 700; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.karvan-expire { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-live { display: inline-flex; align-items: center; gap: 6px; color: #FFDD44; font-size: 12px; margin-bottom: 8px; }
.karvan-live .kl-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.karvan-live.on .kl-dot { background: #35d07f; }
.karvan-live.on .kl-txt { color: #35d07f; }
.karvan-msgs { list-style: none; margin: 0; padding: 6px 0; display: flex; flex-direction: column; gap: 6px;
max-height: 58vh; overflow-y: auto; }
.karvan-msg { max-width: 82%; align-self: flex-start; background: #1c1c1c; border: 1px solid #333;
border-radius: 14px; padding: 7px 11px; box-sizing: border-box; }
.karvan-msg-self { align-self: flex-end; background: #2a2412; border-color: #FFD700; }
.karvan-msg-live { border-left: 2px solid #35d07f; }
.karvan-msg .km-from { display: block; font-size: 10px; color: #FFDD44; margin-bottom: 2px; }
.karvan-msg .km-text { color: #FFD700; word-break: break-word; }
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
/* Fase 2: invitar a un contacto por feed id */
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
.karvan-invite-msg.ok { color: #35d07f; }
.karvan-invite-msg.err { color: #ff8a8a; }
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
/* el div{background:#222;padding:20px} de style.css y el div{border} del tema
convierten cada contenedor del panel en una caja: aqui se neutralizan */
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
background-color: transparent; box-shadow: none; border: 0; border-radius: 0; padding: 0; margin: 0;
}
.karvan-call { margin: 10px 0; }
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
.karvan-call.active .karvan-videos { display: grid; }
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
.kc-local { transform: scaleX(-1); }
.kc-remote { display: contents; }
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
.kc-btn-start { font-weight: 600; }
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }
/* ==========================================================================
Selector de identidades (ajustes)
========================================================================== */
.identity-list { list-style: none; margin: 10px 0; padding: 0; }
.identity-row { display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid #2a2a2a; }
.identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.identity-name { color: #FFD700; font-weight: 700; }
.identity-feed { color: #FFDD44; font-size: 12px; font-family: monospace; word-break: break-all; }
.identity-warn { color: #ff8a8a; font-size: 12px; }
.identity-active { color: #35d07f; font-size: 13px; font-weight: 600; white-space: nowrap; }

View file

@ -672,7 +672,8 @@ a.filter-btn,
.main-column .edit-btn, .main-column .edit-btn,
.main-column .join-btn, .main-column .join-btn,
.main-column .leave-btn, .main-column .leave-btn,
.main-column .buy-btn { .main-column .buy-btn,
.main-column .submit-button {
max-width: 100% !important; max-width: 100% !important;
box-sizing: border-box !important; box-sizing: border-box !important;
overflow-wrap: anywhere !important; overflow-wrap: anywhere !important;
@ -681,7 +682,8 @@ a.filter-btn,
.main-column .btn, .main-column .btn,
.main-column .create-button, .main-column .create-button,
.main-column .update-btn, .main-column .update-btn,
.main-column .delete-btn { .main-column .delete-btn,
.main-column .submit-button {
white-space: normal !important; white-space: normal !important;
} }
.main-column form { .main-column form {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -35,15 +35,6 @@ input[type="text"], input[type="email"], input[type="password"], textarea {
margin-bottom: 15px; margin-bottom: 15px;
} }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > br { display: none }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(label, span) { display: block; margin: 0 0 6px 0 }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(h2, h3, p) { margin: 0 0 16px 0 }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(input:not([type="hidden"], [type="checkbox"], [type="radio"], [type="submit"], [type="button"], .logistics-short), select:not(.logistics-price-unit), textarea) { display: block; box-sizing: border-box; width: 100%; max-width: 720px; margin: 0 0 16px 0 }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > input:is([type="checkbox"], [type="radio"]) { display: block; width: auto; margin: 0 0 16px 0 }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(.logistics-short, .logistics-price-unit) { margin: 0 8px 16px 0 }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > :is(div, table):not(.map-form-row, .map-toolbar, .map-form-map-slot) { max-width: 720px; box-sizing: border-box; margin-bottom: 16px }
section > div:is([class$="-form"], [class*="-form "]) > form:not(.inline-form) > button { margin-top: 0 }
/* Body and overall page styles */ /* Body and overall page styles */
html { html {
color: #FFA500; color: #FFA500;
@ -1611,58 +1602,7 @@ button.create-button:hover {
} }
/* Documents */ /* Documents */
.pdf-holder {
display: block;
}
.pdf-frame {
display: block;
width: 100%;
max-width: 100%;
height: 80vh;
min-height: 520px;
border: 0;
margin-bottom: 1rem;
background: transparent;
}
.pdf-frame:target {
position: fixed;
inset: 0;
z-index: 100000;
width: 100vw;
max-width: none;
height: 100vh;
min-height: 0;
margin: 0;
background: #111;
}
.pdf-unmax {
display: none;
}
.pdf-frame:target + .pdf-unmax {
display: block;
position: fixed;
top: 10px;
right: 18px;
z-index: 100001;
background-color: #2d2d2d;
color: #eee;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 1rem;
text-decoration: none;
}
.pdf-embed-root,
body.pdf-embed {
height: 100%;
}
body.pdf-embed {
margin: 0;
padding: 0;
overflow: hidden;
background: transparent !important;
}
.pdf-viewer-container { .pdf-viewer-container {
box-sizing: border-box;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
background: #1e1e1e; background: #1e1e1e;
@ -1671,116 +1611,42 @@ body.pdf-embed {
border-radius: 6px; border-radius: 6px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
} }
.pdf-embedded {
display: flex;
flex-direction: column;
height: 100%;
margin-bottom: 0;
}
.pdf-canvas-wrap { .pdf-canvas-wrap {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 420px; height: 420px;
margin-bottom: 0;
overflow: hidden;
}
.pdf-wrap-scroll {
display: block;
overflow: auto; overflow: auto;
} }
.pdf-embedded .pdf-canvas-wrap {
flex: 1 1 auto; .pdf-viewer-container canvas {
height: auto;
min-height: 0;
}
.pdf-page-image {
display: block; display: block;
box-sizing: border-box;
width: auto;
height: auto;
margin: 0 auto; margin: 0 auto;
border: 1px solid #333; border: 1px solid #333;
background-color: #fff; background-color: #fff;
} }
.pdf-z50 {
max-width: 50%;
max-height: 50%;
}
.pdf-z75 {
max-width: 75%;
max-height: 75%;
}
.pdf-z100 {
max-width: 100%;
max-height: 100%;
}
.pdf-z125 {
width: 100%;
}
.pdf-z150 {
width: 150%;
}
.pdf-z200 {
width: 200%;
}
.pdf-z300 {
width: 300%;
}
.pdf-viewer-container .pdf-controls { .pdf-viewer-container .pdf-controls {
flex-shrink: 0;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 0;
} }
.pdf-viewer-container .pdf-controls a,
.pdf-viewer-container .pdf-controls span { .pdf-viewer-container button {
display: inline-block;
margin: 0 4px 4px 0;
background-color: #2d2d2d; background-color: #2d2d2d;
color: #eee; color: #eee;
border: none; border: none;
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
font-size: 1rem; font-size: 1rem;
line-height: 1.2;
text-decoration: none;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
.pdf-viewer-container .pdf-controls a:hover {
.pdf-viewer-container button:hover {
background-color: #444; background-color: #444;
} }
.pdf-viewer-container .pdf-controls .pdf-off {
opacity: 0.4; .pdf-viewer-container span {
cursor: default;
}
.pdf-message {
padding: 12px;
color: #ccc; color: #ccc;
text-align: center; margin: 0 1rem;
} font-family: monospace;
.pdf-maximized {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
margin: 0;
padding: 6px;
border-radius: 0;
background: #111;
}
.pdf-maximized .pdf-canvas-wrap {
flex: 1 1 auto;
height: auto;
max-height: none;
}
.pdf-maximized .pdf-controls {
flex-shrink: 0;
margin: 6px 0;
} }
/* CVs */ /* CVs */
@ -2232,51 +2098,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
right: 112px; right: 112px;
font-size: 1.15em; font-size: 1.15em;
} }
.lightbox-prev,
.lightbox-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
color: #fff;
font-size: 2em;
line-height: 1;
text-decoration: none;
}
.lightbox-prev {
left: 15px;
}
.lightbox-next {
right: 15px;
}
.media-download-btn {
display: inline-flex;
margin: 10px 0;
}
.edited-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid rgba(128, 128, 128, 0.5);
border-radius: 10px;
font-size: 0.75em;
font-weight: 700;
letter-spacing: 1px;
text-decoration: none;
white-space: nowrap;
}
.media-lock-note {
font-size: 0.9em;
opacity: 0.85;
margin: 6px 0;
}
/* Opinions */ /* Opinions */
.voting-buttons { .voting-buttons {
@ -2793,7 +2614,15 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
text-align: center; text-align: center;
} }
.market-comments {
background: #2f2f2f;
border-radius: 12px;
padding: 12px;
}
.market-comments .votations-comment-card {
background: #3a3a3a;
}
.countdown-strong { .countdown-strong {
font-weight: 700; font-weight: 700;
@ -3340,6 +3169,12 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
color: #ffd740; color: #ffd740;
} }
.comment-footer {
font-size: 0.85em;
display: flex;
justify-content: space-between;
color: #ffd740;
}
.date-link { .date-link {
color: #ffd740; color: #ffd740;
@ -3964,6 +3799,8 @@ a.pm-btn:hover { text-decoration: none }
.card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word} .card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word}
.card-field-rich{flex-direction:column;align-items:flex-start} .card-field-rich{flex-direction:column;align-items:flex-start}
.card-field-rich .card-value{white-space:pre-wrap;word-break:break-word;margin-top:4px} .card-field-rich .card-value{white-space:pre-wrap;word-break:break-word;margin-top:4px}
.report-template{margin-top:12px;padding:12px;background:rgba(255,255,255,0.05);border-radius:8px}
.report-template .card-label{white-space:nowrap}
@media (max-width: 420px){ @media (max-width: 420px){
.card-field{ flex-direction:column; } .card-field{ flex-direction:column; }
@ -4474,7 +4311,6 @@ a.pm-btn:hover { text-decoration: none }
align-items: center; align-items: center;
margin: 8px 0; margin: 8px 0;
} }
.tribe-side-actions.report-confirm-actions { justify-content: center }
.tribe-side-actions form { .tribe-side-actions form {
margin: 0; margin: 0;
@ -7091,6 +6927,8 @@ a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)}
.jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(max(300px,calc((100% - 40px)/3)),1fr));gap:20px} .jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(max(300px,calc((100% - 40px)/3)),1fr));gap:20px}
.blogs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(max(300px,calc((100% - 40px)/3)),1fr));gap:20px} .blogs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(max(300px,calc((100% - 40px)/3)),1fr));gap:20px}
.jobs-grid .tribe-card,.tribe-grid .tribe-card{margin-bottom:0} .jobs-grid .tribe-card,.tribe-grid .tribe-card{margin-bottom:0}
.tribe-card .card-comments-summary{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px}
.tribe-card .card-comments-summary .inline-form{margin:0}
.jobs-info-table{table-layout:auto} .jobs-info-table{table-layout:auto}
.jobs-info-table .tribe-info-label{width:1%;white-space:nowrap} .jobs-info-table .tribe-info-label{width:1%;white-space:nowrap}
.jobs-info-table .tribe-info-value{white-space:normal;word-break:normal;overflow-wrap:anywhere} .jobs-info-table .tribe-info-value{white-space:normal;word-break:normal;overflow-wrap:anywhere}
@ -7102,13 +6940,14 @@ a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)}
.job-price-line{margin:0;font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace} .job-price-line{margin:0;font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace}
.job-card-view-btn{margin-top:8px} .job-card-view-btn{margin-top:8px}
.job-card-view-btn form{margin:0} .job-card-view-btn form{margin:0}
.vote-comments-section,.comment-form-wrapper,.comments-list,.vote-buttons-block,.vote-buttons-row,.voting-buttons,.vote-table{border:none;background:transparent;padding:0;margin:0} .vote-comments-section,.comments-count,.comment-form-wrapper,.comments-list,.vote-buttons-block,.vote-buttons-row,.voting-buttons,.vote-table{border:none;background:transparent;padding:0;margin:0}
.vote-buttons-block{display:flex;flex-direction:column;gap:8px} .vote-buttons-block{display:flex;flex-direction:column;gap:8px}
.vote-buttons-row{display:flex;gap:8px;flex-wrap:wrap} .vote-buttons-row{display:flex;gap:8px;flex-wrap:wrap}
.voting-buttons{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap} .voting-buttons{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap}
.vote-comments-section{margin-top:16px} .vote-comments-section{margin-top:16px}
.comment-form-wrapper{margin-top:12px} .comment-form-wrapper{margin-top:12px}
.comments-list{margin-top:12px} .comments-list{margin-top:12px}
.comments-count{margin-bottom:8px}
.title-with-chip{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important} .title-with-chip{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:transparent !important;border:0 !important;padding:0 !important}
.title-with-chip h2{margin:0} .title-with-chip h2{margin:0}
.pm-exposition-icon{font-size:14px;line-height:1} .pm-exposition-icon{font-size:14px;line-height:1}
@ -7116,7 +6955,7 @@ a.chip-link:hover .pm-exposition-chip{filter:brightness(1.2)}
.shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;padding:0;background:transparent;border-radius:0;box-shadow:none} .shop-title-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;padding:0;background:transparent;border-radius:0;box-shadow:none}
.shop-title-row h2{margin:0} .shop-title-row h2{margin:0}
.card-chips-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 10px 0;padding:0;border:none;background:transparent;border-radius:0;box-shadow:none} .card-chips-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 10px 0;padding:0;border:none;background:transparent;border-radius:0;box-shadow:none}
.tribe-card-body .job-price-line,.tribe-card-body .job-meta-line,.tribe-card-body .job-card-view-btn{padding:0;background:transparent;border-radius:0;box-shadow:none;border:none} .tribe-card-body .job-price-line,.tribe-card-body .job-meta-line,.tribe-card-body .job-card-view-btn,.tribe-card-body .card-comments-summary,.tribe-card-body .tribe-card-members{padding:0;background:transparent;border-radius:0;box-shadow:none;border:none}
.tribe-card-body .job-price-line{margin:4px 0} .tribe-card-body .job-price-line{margin:4px 0}
.tribe-card-body .job-card-view-btn{margin-top:8px;margin-bottom:0} .tribe-card-body .job-card-view-btn{margin-top:8px;margin-bottom:0}
.transfer-block-card{border:none;background:transparent;padding:0;margin:0 0 12px 0} .transfer-block-card{border:none;background:transparent;padding:0;margin:0 0 12px 0}
@ -7477,8 +7316,9 @@ a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bol
.industry-blueprint-card{border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0} .industry-blueprint-card{border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0}
.industry-card-wrap{position:relative;border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0;background:transparent;box-shadow:none} .industry-card-wrap{position:relative;border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:10px;margin:8px 0;background:transparent;box-shadow:none}
.industry-card-wrap .industry-blueprint-card{border:none;background:transparent;padding:0;margin:0;box-shadow:none;border-radius:0} .industry-card-wrap .industry-blueprint-card{border:none;background:transparent;padding:0;margin:0;box-shadow:none;border-radius:0}
.industry-card-wrap > .card-header:not(.card-top){position:absolute;top:8px;right:10px;margin:0;padding:0;border:none;background:transparent;box-shadow:none;z-index:1} .industry-card-wrap > .card-header{position:absolute;top:8px;right:10px;margin:0;padding:0;border:none;background:transparent;box-shadow:none;z-index:1}
.industry-card-wrap > .industry-blueprint-card{padding:0} .industry-card-wrap > .card-header > span:empty{display:none}
.industry-card-wrap > .industry-blueprint-card{padding:0 96px 0 0}
.industry-actions form,.industry-actions .project-control-form--status{margin:0;padding:0;border:none;background:transparent;display:flex;align-items:center;gap:10px} .industry-actions form,.industry-actions .project-control-form--status{margin:0;padding:0;border:none;background:transparent;display:flex;align-items:center;gap:10px}
.industry-actions .project-control-select,.industry-actions .project-control-btn,.industry-actions select,.industry-actions button{margin:0} .industry-actions .project-control-select,.industry-actions .project-control-btn,.industry-actions select,.industry-actions button{margin:0}
.industry-actions button,.industry-actions .project-control-select{display:inline-flex;align-items:center;justify-content:center;height:38px;box-sizing:border-box} .industry-actions button,.industry-actions .project-control-select{display:inline-flex;align-items:center;justify-content:center;height:38px;box-sizing:border-box}
@ -7619,6 +7459,7 @@ a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bol
.content-actions .spread-form{margin:0;display:inline-flex} .content-actions .spread-form{margin:0;display:inline-flex}
.blog-card-body, .blog-card-body,
.blog-detail-body,
.mention-card-body { .mention-card-body {
border: none !important; border: none !important;
background: transparent !important; background: transparent !important;
@ -7649,6 +7490,14 @@ a.user-link{padding:8px 16px;border-radius:5px;text-align:center;font-weight:bol
word-break: break-word; word-break: break-word;
} }
.blog-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 6px 0;
border: none !important;
background: transparent !important;
}
.blog-allow-comments { .blog-allow-comments {
display: inline-flex; display: inline-flex;
@ -9016,8 +8865,6 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
.graphos-lightbox .lightbox-close{position:fixed;top:14px;right:22px;z-index:10002;font-size:28px;text-decoration:none} .graphos-lightbox .lightbox-close{position:fixed;top:14px;right:22px;z-index:10002;font-size:28px;text-decoration:none}
.live-lightbox .lightbox-image-link{display:flex;align-items:center;justify-content:center;max-width:100%;max-height:100%;padding:0;margin:0;border:0;background:none} .live-lightbox .lightbox-image-link{display:flex;align-items:center;justify-content:center;max-width:100%;max-height:100%;padding:0;margin:0;border:0;background:none}
.shop-buy-form,.market-item.actions,.market-bid-form,.market-buy-form{border:0;background:transparent;box-shadow:none;padding:0} .shop-buy-form,.market-item.actions,.market-bid-form,.market-buy-form{border:0;background:transparent;box-shadow:none;padding:0}
.pending-media{display:flex;align-items:center;justify-content:center;min-height:120px;max-width:480px;padding:16px;border:1px dashed #8a8a8a;border-radius:10px;background:rgba(128,128,128,0.15);color:#a0a0a0;text-align:center;box-sizing:border-box;margin:8px 0}
.buy-box{border:1px solid rgba(255,165,0,0.35);border-radius:8px;padding:14px 16px;margin:0 0 16px 0;background:rgba(255,255,255,0.03)}
.project-progress-block .confirmations-progress{width:100%} .project-progress-block .confirmations-progress{width:100%}
.tags-list .tag-table th:last-child,.tags-list .tag-table td:last-child{width:1%;white-space:nowrap;text-align:right} .tags-list .tag-table th:last-child,.tags-list .tag-table td:last-child{width:1%;white-space:nowrap;text-align:right}
.stats-block .tag-table,.stats-card .tag-table{width:100%;border-collapse:collapse;table-layout:fixed} .stats-block .tag-table,.stats-card .tag-table{width:100%;border-collapse:collapse;table-layout:fixed}
@ -9077,81 +8924,3 @@ label.wallet-create-transfer input[type="checkbox"]{width:auto !important;flex:0
.live-lightbox.live-fullscreen{background:#000;z-index:10050} .live-lightbox.live-fullscreen{background:#000;z-index:10050}
.live-fullscreen .lightbox-image-link{display:flex;align-items:center;justify-content:center;width:100vw;height:100vh} .live-fullscreen .lightbox-image-link{display:flex;align-items:center;justify-content:center;width:100vw;height:100vh}
.live-fullscreen .lightbox-image{width:100vw;height:100vh;max-width:100vw;max-height:100vh;object-fit:contain;border-radius:0} .live-fullscreen .lightbox-image{width:100vw;height:100vh;max-width:100vw;max-height:100vh;object-fit:contain;border-radius:0}
.graphos-zoom-icon{font-size:1.5em;line-height:1;vertical-align:0.06em}
.card-top{display:flex;flex-wrap:nowrap;align-items:center;justify-content:space-between;gap:8px;margin:0 0 6px !important;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important;overflow:visible !important}
.card-byline{display:flex;align-items:center;flex-wrap:wrap;gap:4px 6px;flex:1 1 auto;min-width:0;margin:0 !important;font-size:12px;line-height:16px;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important;overflow:visible !important}
.card-byline-avatar{display:inline-flex;flex:0 0 auto;background:transparent !important;border:0 !important;padding:0 !important}
.card-byline-avatar img{display:block;width:24px;height:24px;border-radius:50%;object-fit:cover;margin:0;border:0}
.card-byline a.user-link{display:inline;background:transparent !important;border:0 !important;padding:0 !important;margin:0;border-radius:0;color:#FFD700 !important;font-size:12px;font-weight:700;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:100%;box-shadow:none !important}
.card-byline a.user-link:hover{text-decoration:underline;background:transparent !important}
.card-byline-time{opacity:.65;white-space:nowrap}
.card-byline-time::before{content:"\2022";margin-right:6px}
.card-byline .card-chips-row{display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px;margin:0 !important;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important}
.card-byline .pm-exposition-chip{font-size:11px;padding:1px 8px}
.card-byline :is(.emergency-severity,.edited-chip,.eco-tax-chip){font-size:11px;padding:1px 8px;margin:0;line-height:16px}
.card-byline :is(.edited-badge,.votations-comment-date){font-size:12px;opacity:.65;margin:0}
.card-top>.content-actions{display:flex;flex:0 0 auto;flex-wrap:nowrap;align-items:center;gap:4px;margin:0 0 0 auto !important;background:transparent !important;border:0 !important;padding:0 !important;box-shadow:none !important;overflow:visible !important}
.card-top .btn-singleview.btn-content{min-width:32px;height:32px;padding:0 8px;font-size:.95em}
.card-menu{position:relative}
.card-menu>.card-menu-toggle.btn-singleview{list-style:none;min-width:32px;height:32px;padding:0 6px;font-size:1.3em;border-radius:50%}
.card-menu[open]>.card-menu-toggle::before{content:"";position:fixed;inset:0;z-index:29;cursor:default}
.card-menu[open]{z-index:40}
.card-top .spread-form{display:inline-flex;align-items:center;margin:0;padding:0}
.card-top .spread-form .btn-singleview{box-sizing:border-box;height:32px;min-width:32px;margin:0;padding:0 12px;gap:6px;border-radius:16px;font-size:.95em;line-height:1}
.card-top .spread-form .btn-singleview .btn-count{line-height:1}
.card-top>.content-actions>*{margin-top:0;margin-bottom:0}
.card-menu-toggle::-webkit-details-marker{display:none}
.card-menu-toggle::marker{content:""}
.card-menu-items{position:absolute;right:0;top:calc(100% + 4px);z-index:30;display:flex;flex-direction:column;gap:0;min-width:200px;margin:0 !important;padding:6px 0 !important;background:#1e1f23;border:1px solid #333;border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,.45);overflow:visible !important}
.card-menu-items .content-action-form,.card-menu-items .spread-form{display:block;margin:0;background:transparent !important;border:0 !important;padding:0 !important}
.card-menu-items .btn-singleview{width:100%;justify-content:flex-start;gap:12px;height:40px;margin:0;padding:0 16px;border-radius:0;font-size:1em;background:transparent !important}
.card-menu-items .btn-singleview:hover{background:rgba(128,128,128,.2) !important}
.card-menu-items .btn-singleview:has(>.btn-icon:only-child){width:100%;min-width:0;height:40px;padding:0 16px;flex-shrink:0}
.card-menu-items .btn-singleview .btn-icon{flex:0 0 18px;width:18px;height:18px}
.card-menu-items .btn-singleview::after{content:attr(title);font-size:13px;white-space:nowrap}
div:has(.card-menu[open]){overflow:visible !important}
div:has(>.card-top):not(.tribe-side){padding:8px 16px 12px !important;border-radius:16px !important}
.podcast-episode-row .podcast-episode-main{padding:0 !important;border-radius:0 !important}
:is(.campaign-update,.logistics-booking,.logistics-rating):has(>.card-top){border-radius:0 !important}
:where(:is(div,li):has(>.card-top):not(.tribe-side)) div{margin:0 0 8px;padding:0;border:0;border-radius:0;background-color:transparent;box-shadow:none}
:where(:is(div,li):has(>.card-top):not(.tribe-side)) :is(.card-section,.card-body){margin:0 !important;padding:0 !important;border:0 !important;border-radius:0 !important;background:transparent !important;box-shadow:none !important}
:is(div,li):has(>.card-top):not(.tribe-side){font-family:'Inter','Roboto','Segoe UI','Helvetica Neue',Arial,sans-serif;font-size:14px;line-height:20px;font-weight:400}
:where(:is(div,li):has(>.card-top):not(.tribe-side)) :is(p,.blog-card-text,.feed-text,.post-text){font-size:14px;line-height:20px;font-weight:400}
:where(:is(div,li):has(>.card-top)) :is(h2,.tribe-card-title){font-size:18px;font-weight:600;line-height:24px;letter-spacing:0;margin:4px 0 6px}
:where(:is(div,li):has(>.card-top)) :is(h2,.tribe-card-title) a{text-decoration:none}
:where(:is(div,li):has(>.card-top)) :is(h2,.tribe-card-title) a:hover{text-decoration:underline}
.card-engage{display:flex !important;flex-wrap:wrap;align-items:center;justify-content:flex-start;gap:8px;margin:8px 0 0 !important}
.card-engage>*{flex:0 0 auto;margin:0 !important}
.card-engage>details[open],.card-engage>details[open]~details,.card-engage>.feed-votes-summary{flex:1 0 100%}
.card-engage>details:not([open]){width:auto}
.card-meta-row{gap:8px}
.card-engage .opinions-summary,.card-engage .comments-summary,.card-meta-row .opinions-summary,.card-meta-row .comments-summary-link,.card-meta-row .comments-summary,.share-summary-link{display:inline-flex !important;align-items:center;gap:6px;width:auto !important;max-width:none;box-sizing:border-box;height:32px;padding:0 12px !important;border-radius:5px !important;font-size:12px;font-weight:600;line-height:30px;text-decoration:none}
.share-summary-link{border:1px solid rgba(128,128,128,.5);background:rgba(128,128,128,.12);color:inherit}
.share-summary-link:hover{background:rgba(128,128,128,.25);text-decoration:none}
.card-meta-row>.feed-votes-summary{flex:1 0 100%;order:-1;margin:0}
.card-share{display:inline-flex;flex:0 0 auto;background:transparent !important;border:0 !important;padding:0 !important}
.share-summary-icon{margin:0}
.tribe-section-nav{gap:6px;padding:10px 12px !important;margin:0 0 12px !important;overflow:visible}
.tribe-section-group{display:contents}
.tribe-section-group>form:last-child{margin-right:12px}
.tribe-section-nav .filter-btn{margin:0}
.wallet-error-actions{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0 4px;padding:0;border:0;background:none;box-shadow:none;overflow:visible}
.wallet-error-actions .update-download-link{display:inline-block;padding:10px 18px;background:#FFA500;border:0;border-radius:6px;color:#000;font-size:14px;font-weight:bold;text-decoration:none}
.wallet-error-actions .update-download-link:hover{background:#FFD700;color:#000;text-decoration:none}
.tribe-card:has(>.card-top)>.tribe-card-body{padding-bottom:0}
.tribe-card:has(>.card-top)>.tribe-card-body>:last-child{margin-bottom:0}
.bookmark-title .card-title-link:not([href^="/tribe/"]){color:#ff6600}
.blockexplorer-search{margin:12px 0 16px;padding:10px 12px}
.blockexplorer-search-form :is(.blockexplorer-search-row,.blockexplorer-search-pair,.blockexplorer-search-dates,.blockexplorer-search-actions,.filter-box__controls){margin:0;padding:0;border:0;background:transparent;box-shadow:none;overflow:visible}
.blockexplorer-search-form .blockexplorer-search-row{flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px}
.blockexplorer-search-form .blockexplorer-search-pair,.blockexplorer-search-form .blockexplorer-search-dates{gap:8px}
.blockexplorer-search-form .blockexplorer-search-input{min-width:180px;margin:0}
.tribe-main>.media-download-btn{align-self:flex-start}
.tribe-side-actions .update-btn.danger-btn{border-color:#d9534f;color:#d9534f;background:transparent}
.tribe-side-actions .update-btn.danger-btn:hover{background:#d9534f;color:#000}
.detail-share{display:flex;justify-content:flex-start;margin:4px 0 !important;padding:0 !important;border:0 !important;background:transparent !important;box-shadow:none !important;overflow:visible}
.detail-share .share-summary-link{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border:1px solid rgba(128,128,128,.5);border-radius:5px;background:rgba(128,128,128,.12);font-size:12px;font-weight:600;line-height:30px;text-decoration:none;color:inherit}
.detail-share .share-summary-link:hover{background:rgba(128,128,128,.25);text-decoration:none}
.link-lightbox > .lightbox-close::before { content: ""; position: fixed; inset: 0; z-index: 0; cursor: default; }
.link-lightbox > .link-box { position: relative; z-index: 1; }

View file

@ -1,190 +0,0 @@
/* Skin Activista (rama movil): el unico del movil, lo mas simple posible.
*
* Letra del sistema, titulos en negrita sin mayusculas, bordes de 1px, sin sombras y
* el boton principal relleno con el color de resalte. Los colores salen de las
* variables --a26-* (el nombre viene del skin Acampada, que se quito el 20/10/2026):
* aqui van los de serie y Colores en Ajustes los cambia (mobile_settings.js, hoja
* /mobile-colors.css, que va la ultima).
*
* La maqueta de movil es styles/mobile_base.css, que main_views.js carga justo antes;
* aqui solo se cambian colores, letra y formas encima. Por eso casi todo lleva
* !important: la maqueta tambien lo usa. Las piezas propias de la rama (lanzador,
* barras) se ajustan en styles/activista.css. */
:root {
--a26-bg: #fff;
--a26-fg: #111;
--a26-neon: #00e5ff;
--a26-soft: #f2f2f2;
--a26-muted: #555;
--a26-on-neon: #000;
--a26-tint: #e5fcff;
--a26-line: #d6d6d6;
--a26-edge: #8a8a8a;
--a26-danger-text: #c4003b;
--a26-bd: 1px solid var(--a26-edge);
--a26-bd-fino: 1px solid var(--a26-edge);
--a26-radius: 12px;
--a26-tit: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--a26-txt: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color-scheme: light;
}
/* --- fondo y texto --- */
html, body {
background: var(--a26-bg) !important;
color: var(--a26-fg) !important;
font-family: var(--a26-txt) !important;
}
body { font-size: 17px; line-height: 1.5; }
header, footer, .header, .main-content, .main-column, .sidebar-left, .sidebar-right,
section, article, div, table, .profile {
background-color: transparent !important;
color: var(--a26-fg) !important;
border-color: var(--a26-line) !important;
}
div { border: 0 !important; box-shadow: none !important; }
span, p, li, td, th, label, small, strong, em, h4, h5, h6 { color: inherit !important; }
a { color: var(--a26-fg) !important; }
a:hover, a:focus-visible { color: var(--a26-on-neon) !important; background-color: var(--a26-neon); }
/* --- titulos: negrita, sin mayusculas --- */
h1, h2, h3, .card-label, .tags-header h2, .oasis-nav-header {
font-family: var(--a26-tit) !important;
font-weight: 700 !important;
text-transform: none !important;
letter-spacing: 0;
line-height: 1.2 !important;
color: var(--a26-title, var(--a26-fg)) !important;
}
h1 { font-size: 26px !important; }
h2 { font-size: 22px !important; }
h3 { font-size: 18px !important; }
.card-label { font-size: 14px !important; }
/* cabecera de cada modulo: titulo y una raya fina debajo */
.tags-header {
background: transparent !important;
border: 0 !important;
border-bottom: 1px solid var(--a26-line) !important;
border-radius: 0 !important;
padding: 0 0 8px !important;
margin: 0 0 16px !important;
text-align: left !important;
}
.tags-header p { color: var(--a26-muted) !important; font-size: 15px !important; margin: 4px 0 0 !important; }
/* --- tarjetas: borde fino, sin sombra --- */
.main-column article, .main-column [class$="-card"], .main-column [class*="-card "],
.main-column .card, .forum-comment, .feed-row {
background: var(--a26-card, var(--a26-bg)) !important;
border: var(--a26-card-bd, 1px solid var(--a26-line)) !important;
border-radius: var(--a26-radius) !important;
box-shadow: var(--a26-card-shadow, none) !important;
}
.main-column > section { border: 0 !important; padding: 0 !important; }
/* --- botones: contorno fino; el elegido y el principal, rellenos --- */
button, input[type="submit"], input[type="button"],
.filter-btn, .create-button, .buy-btn, .delete-btn,
a.user-link, button.user-link, .date-link, .welcome-banner-start, .update-download-link {
font-family: var(--a26-txt) !important;
font-weight: 600 !important;
text-transform: none !important;
letter-spacing: 0;
background: var(--a26-bg) !important;
color: var(--a26-fg) !important;
border: var(--a26-bd) !important;
border-radius: var(--a26-radius) !important;
box-shadow: none !important;
text-decoration: none !important;
}
button, input[type="submit"], input[type="button"], .filter-btn, .create-button {
min-height: 44px;
padding: 8px 16px !important;
font-size: 16px !important;
margin-bottom: 6px;
}
button:active, input[type="submit"]:active, input[type="button"]:active,
.filter-btn:active, a.user-link:active {
background: var(--a26-soft) !important;
}
.filter-btn.active, .mode-buttons .filter-btn.active, .mode-buttons-row .filter-btn.active,
.tribe-mode-buttons .filter-btn.active, .filters .filter-btn.active,
.activity-filter-grid .filter-btn.active, .activity-sub-filter .filter-btn.active,
.bank-range-tabs .filter-btn.active {
background: var(--a26-neon) !important;
border-color: var(--a26-neon) !important;
color: var(--a26-on-neon) !important;
}
/* el principal de cada pantalla (crear, escribir): relleno con el resalte */
.create-button, .mode-buttons .create-button, .mode-buttons-row .create-button,
.tribe-mode-buttons .create-button, .filters .create-button {
background: var(--a26-neon) !important;
border-color: var(--a26-neon) !important;
color: var(--a26-on-neon) !important;
font-size: 16px !important;
padding: 8px 16px !important;
min-height: 48px !important;
}
.create-button:active { filter: brightness(.92); }
.delete-btn { border-color: var(--a26-danger-text) !important; color: var(--a26-danger-text) !important; }
/* --- formularios --- */
input, textarea, select {
background: var(--a26-bg) !important;
color: var(--a26-fg) !important;
border: var(--a26-bd-fino) !important;
border-radius: 10px !important;
font-family: var(--a26-txt) !important;
font-size: 16px !important;
min-height: 44px;
}
input:focus, textarea:focus, select:focus {
outline: 2px solid var(--a26-neon) !important;
outline-offset: 1px;
}
input[type="checkbox"], input[type="radio"] { min-height: 0; accent-color: var(--a26-neon); }
/* --- tablas --- */
table { border: 1px solid var(--a26-line) !important; border-radius: 10px; overflow: hidden; }
table th { background: var(--a26-soft) !important; color: var(--a26-fg) !important; }
table td { border-color: var(--a26-line) !important; }
/* --- avisos de upstream (bienvenida, actualizaciones) --- */
.update-banner, .welcome-banner {
background: var(--a26-tint) !important;
color: var(--a26-fg) !important;
border: 0 !important;
border-left: 4px solid var(--a26-neon) !important;
border-radius: 10px !important;
margin: 8px 10px !important;
}
.update-banner-link, .update-banner-text { color: var(--a26-fg) !important; }
.welcome-banner-close-btn {
background: transparent !important;
border: 0 !important;
min-height: 44px; width: 44px; padding: 0 !important;
}
/* --- colores sueltos de OasisMobile que se quedaban en amarillo o naranja --- */
.avatar, .article img, .post-preview img { border-color: var(--a26-line) !important; }
.profile .name, .snh-invite-code, .stats-kpi-label, .stats-kpi-value, .carbon-bar-formula,
a.hashtag, .chat-topic-title, .chat-topic-active .chat-topic-title,
.chat-topic-meta, .chat-topic-last-author, .chat-workspace-details > .tribe-side .tribe-card-members {
color: var(--a26-fg) !important;
}
.chat-topic-active { background: var(--a26-neon) !important; color: var(--a26-on-neon) !important; }
a.hashtag { text-decoration: underline !important; }
.pdf-maximized { background: var(--a26-bg) !important; }
/* --- pie --- */
.oasis-footer, .oasis-footer * { color: var(--a26-muted) !important; background: transparent !important; }
.oasis-footer { border-top: 1px solid var(--a26-line) !important; margin-top: 32px; }
.oasis-footer a { text-decoration: underline !important; }
::-webkit-scrollbar-thumb { background-color: var(--a26-edge) !important; }
::-webkit-scrollbar-track { background-color: var(--a26-soft) !important; }

View file

@ -320,8 +320,6 @@ a.user-link:focus, button.user-link:focus {
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; } .btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.btn-singleview.btn-content { background: #FF6F00 !important; color: #F0F0F0 !important; border: 1px solid #FF6F00 !important; } .btn-singleview.btn-content { background: #FF6F00 !important; color: #F0F0F0 !important; border: 1px solid #FF6F00 !important; }
.btn-singleview.btn-content:hover { background: #FF8F00 !important; color: #FFFFFF !important; border-color: #FF8F00 !important; } .btn-singleview.btn-content:hover { background: #FF8F00 !important; color: #FFFFFF !important; border-color: #FF8F00 !important; }
.card-menu-items { background: #F0F0F0 !important; border-color: #D0D0D0 !important; }
.card-byline a.user-link { color: #FF6F00 !important; }
.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; } .btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; } .btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; } .block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
@ -366,6 +364,19 @@ button.tribe-action-btn { background: transparent !important; border: 1px solid
.chat-topic-active { background: #FFF3E0 !important; } .chat-topic-active { background: #FFF3E0 !important; }
.chat-topic-title { color: #2D2D2D !important; } .chat-topic-title { color: #2D2D2D !important; }
.chat-workspace-empty { border-color: #DDD !important; } .chat-workspace-empty { border-color: #DDD !important; }
/* Karvan: su hoja (karvan.css) esta pensada para fondo oscuro; aqui se traduce
a la paleta clara, igual que este tema hace con el resto de la interfaz */
.karvan-back, .karvan-room-title, .kr-title { color: #B25000 !important; }
.karvan-sub, .karvan-expire, .karvan-live, .kr-meta,
.karvan-ttl-lbl, .karvan-ttl-opt { color: #6B5300 !important; }
.karvan-room-item, .karvan-create input[type="text"] {
background-color: #FFFFFF !important; border: 1px solid #E0E0E0 !important; color: #2C2C2C !important;
}
.karvan-msg { background-color: #FFF8E1 !important; border: 1px solid #E6CE86 !important; }
.karvan-msg-self { background-color: #FFEFC2 !important; border-color: #D9A400 !important; }
.karvan-msg .km-from { color: #8A6D00 !important; }
.karvan-msg .km-text { color: #2C2C2C !important; }
.map-viewer, .map-stage div, .map-hero, .map-toolbar, .map-toolbar div, .map-place-results, .map-markers-list, .map-markers-list div, .map-detail-grid, .map-card-thumb .map-viewer { background: transparent !important; border: 0 !important; } .map-viewer, .map-stage div, .map-hero, .map-toolbar, .map-toolbar div, .map-place-results, .map-markers-list, .map-markers-list div, .map-detail-grid, .map-card-thumb .map-viewer { background: transparent !important; border: 0 !important; }
.map-pick-cell, .map-pick-cell:hover { border: 0 !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; } .map-pick-cell, .map-pick-cell:hover { border: 0 !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
.map-pick-cell { background: transparent !important; } .map-pick-cell { background: transparent !important; }
@ -411,7 +422,3 @@ button.tribe-action-btn { background: transparent !important; border: 1px solid
.oasis-pager-info { color: #888888; } .oasis-pager-info { color: #888888; }
.oasis-pager-btn { background: #FFFFFF; color: #555555 !important; border: 1px solid #E0E0E0; text-decoration: none !important; } .oasis-pager-btn { background: #FFFFFF; color: #555555 !important; border: 1px solid #E0E0E0; text-decoration: none !important; }
.oasis-pager-btn:hover, .oasis-pager-btn.active { border-color: #2C2C2C; color: #2C2C2C !important; text-decoration: none !important; } .oasis-pager-btn:hover, .oasis-pager-btn.active { border-color: #2C2C2C; color: #2C2C2C !important; text-decoration: none !important; }
.pdf-viewer-full {
background-color: #F9F9F9;
}

View file

@ -98,11 +98,6 @@ div {
border: 1px solid #333; border: 1px solid #333;
} }
:where(:is(div, li):has(> .card-top):not(.tribe-side)) div {
background-color: transparent;
border: 0;
}
div .header-content { div .header-content {
width: 100%; width: 100%;
} }
@ -325,7 +320,6 @@ a.user-link:focus, button.user-link:focus {
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; } .btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; }
.btn-singleview.btn-content { background: #ffa300 !important; color: #1e1f23 !important; border: 1px solid #ffa300 !important; } .btn-singleview.btn-content { background: #ffa300 !important; color: #1e1f23 !important; border: 1px solid #ffa300 !important; }
.btn-singleview.btn-content:hover { background: #FFD700 !important; color: #1e1f23 !important; border-color: #FFD700 !important; } .btn-singleview.btn-content:hover { background: #FFD700 !important; color: #1e1f23 !important; border-color: #FFD700 !important; }
.card-menu-items { background: #1e1f23 !important; border-color: #333333 !important; }
.btn-back { background: #21232b !important; color: #ffa300 !important; } .btn-back { background: #21232b !important; color: #ffa300 !important; }
.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; } .btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
.block-info-table td, .pm-info-table td { border-color: #444 !important; } .block-info-table td, .pm-info-table td { border-color: #444 !important; }

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