Compare commits
No commits in common. "PRUEBAS" and "v1.3.3-76" have entirely different histories.
53
.gitea/workflows/apk.yml
Normal 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
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**[⬇ 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

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

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

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

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

|

|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [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
|

|
||||||
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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
61
android/app/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
57
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
243
android/app/src/main/java/net/laenre/oasis/MainActivity.kt
Normal 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ón…<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
117
android/app/src/main/java/net/laenre/oasis/NodeService.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#121212</color>
|
||||||
|
</resources>
|
||||||
6
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
7
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||||
11
android/app/src/main/res/xml/network_security_config.xml
Normal 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
|
|
@ -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
|
||||||
|
}
|
||||||
7
android/gradle.properties
Normal 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.
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 134 KiB |
81
android/scripts/prepare.sh
Executable 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
|
|
@ -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"
|
||||||
|
|
@ -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)"
|
|
||||||
9
android/settings.gradle.kts
Normal 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")
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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 |
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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 |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 146 KiB |
BIN
docs/capturas/01-portada.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/capturas/02-chats.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 64 KiB |
BIN
docs/capturas/03-karvan.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 65 KiB |
BIN
docs/capturas/04-foros.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB |
BIN
docs/capturas/05-feed.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 59 KiB |
BIN
docs/capturas/06-barra.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/capturas/07-karvan-salas.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/capturas/esquema-interfaz.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 42 KiB |
40
main.js
|
|
@ -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();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()]);
|
|
||||||
};
|
|
||||||
237
src/backend/karvan_routes.js
Normal 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();
|
||||||
|
};
|
||||||
|
|
@ -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();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
|
|
@ -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' }); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
50
src/backend/turnCredentials.js
Normal 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 };
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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; }
|
|
||||||
67
src/client/assets/styles/highlight.css
Normal 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;
|
||||||
|
}
|
||||||
103
src/client/assets/styles/karvan.css
Normal 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; }
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||