diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..c35e177 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,12 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ +captures/ +.cxx/ + +# The gomobile binding is a build artifact (~24 MB). Regenerate it from ../mobile +# with `gomobile bind` (see README.md); it is not versioned. +app/libs/*.aar +app/libs/*.jar diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..6f4341e --- /dev/null +++ b/android/README.md @@ -0,0 +1,83 @@ +# unibus · app Android + +Cliente móvil nativo de unibus. La app no habla con un gateway: embebe un **peer +real** del bus a través del binding gomobile `mobile/unibus.go`, de modo que el +cifrado extremo a extremo corre **en el dispositivo**. Cada teléfono es un peer +de primera clase del bus, igual que cualquier peer Go. + +## Arquitectura + +``` +Kotlin/Compose UI ──> BusViewModel ──> com.unibus.core.mobile.Session (.aar) + │ (NATS data plane + E2E crypto, en Go) + ▼ + membershipd (control plane HTTP :8470) + NATS (data plane :4250) +``` + +- `BusViewModel` traduce intents de UI en llamadas al binding. Las llamadas de red + (`newSession`, `createRoom`, `join`, `publish`) corren en `Dispatchers.IO`. +- Los frames entrantes llegan por `FrameListener.onFrame` en una goroutine NATS + (hilo JNI); se publican en un `StateFlow` (thread-safe) que Compose recolecta en + el hilo principal. + +## Requisitos + +- Android SDK (compileSdk 34), NDK (para regenerar el `.aar`), JDK 17. +- El binding `app/libs/unibus.aar` (no versionado: es un artefacto de ~24 MB). + +## 1. Generar el binding (.aar) + +Desde la raíz del repo de la app (`projects/message_bus/apps/unibus`): + +```bash +export ANDROID_HOME=$HOME/android-sdk +export ANDROID_NDK_HOME=$HOME/android-sdk/ndk/26.3.11579264 +mkdir -p android/app/libs +gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \ + -o android/app/libs/unibus.aar ./mobile +``` + +Esto produce `unibus.aar` con la clase estática `com.unibus.core.mobile.Mobile` +(`generateIdentity`, `newSession`) y los tipos `Session` y `FrameListener`. + +## 2. Compilar el APK + +```bash +cd android +export JAVA_HOME=$HOME/android-sdk/jdk-17/jdk-17.0.19+10 +export ANDROID_HOME=$HOME/android-sdk +./gradlew assembleDebug +# APK: app/build/outputs/apk/debug/app-debug.apk +``` + +`local.properties` apunta a `sdk.dir`; ajústalo si tu SDK está en otra ruta. + +## 3. Arrancar el bus y probar en el emulador + +```bash +# 1. En el PC: control plane + NATS embebido (HTTP :8470, NATS :4250) +cd projects/message_bus/apps/unibus && go run ./cmd/membershipd + +# 2. Emulador Pixel_API34 +$ANDROID_HOME/emulator/emulator -avd Pixel_API34 & + +# 3. Instalar + lanzar +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.unibus.app/.MainActivity +``` + +En la pantalla de conexión, desde el emulador el host del PC es `10.0.2.2`: + +- **Host (control plane):** `http://10.0.2.2:8470` +- **NATS (data plane):** `nats://10.0.2.2:4250` + +Para un teléfono físico en la misma LAN, usa la IP LAN del PC en lugar de +`10.0.2.2`. + +## Notas + +- La identidad del peer se guarda en `filesDir/peer.id` (claves privadas + Ed25519 + X25519). No se sincroniza ni se respalda. +- Una room creada en modo "cifrar (E2E)" usa la política Matrix (cifrada, + persistida, firmada); en modo normal usa NATS cleartext. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..78c52a3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.unibus.app" + compileSdk = 34 + + defaultConfig { + applicationId = "com.unibus.app" + minSdk = 21 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // The unibus gomobile binding: a real bus peer that does NATS + E2E crypto + // on the device. All protocol logic lives here, shared with every other peer. + implementation(files("libs/unibus.aar")) + + val composeBom = platform("androidx.compose:compose-bom:2024.09.03") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.9.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e3de6b1 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# gomobile generates JNI-bound classes under com.unibus.core.mobile and go.*. +# They are reached from native code, so keep them intact even when minifying. +-keep class com.unibus.core.mobile.** { *; } +-keep class go.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..55345ef --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/unibus/app/BusViewModel.kt b/android/app/src/main/java/com/unibus/app/BusViewModel.kt new file mode 100644 index 0000000..28deafb --- /dev/null +++ b/android/app/src/main/java/com/unibus/app/BusViewModel.kt @@ -0,0 +1,162 @@ +package com.unibus.app + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.unibus.core.mobile.FrameListener +import com.unibus.core.mobile.Mobile +import com.unibus.core.mobile.Session +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File + +/** One chat message shown in the UI. */ +data class ChatMessage( + val sender: String, + val text: String, + val mine: Boolean, + val ts: Long, +) + +/** The whole observable UI state of the app. */ +data class BusState( + val connecting: Boolean = false, + val connected: Boolean = false, + val endpointId: String = "", + val roomId: String = "", + val roomSubject: String = "", + val status: String = "", + val error: String? = null, + val messages: List = emptyList(), +) + +/** + * BusViewModel drives a real unibus peer on the device through the gomobile + * binding. The binding performs NATS transport and end-to-end crypto natively; + * this class only translates UI intents into binding calls and exposes the + * incoming frames as observable state. + * + * Threading: every binding call that touches the network (newSession, createRoom, + * join, publish) runs off the main thread on Dispatchers.IO to avoid + * NetworkOnMainThreadException. Incoming frames arrive on a JNI-attached NATS + * goroutine via [onFrame]; we only append to a thread-safe StateFlow there, and + * Compose collects that flow on the main thread. + */ +class BusViewModel(app: Application) : AndroidViewModel(app), FrameListener { + private val _state = MutableStateFlow(BusState()) + val state: StateFlow = _state.asStateFlow() + + private var session: Session? = null + private var myEndpoint: String = "" + + private val idPath: String + get() = File(getApplication().filesDir, "peer.id").absolutePath + + override fun onFrame(roomID: String, sender: String, msgID: String, text: String) { + _state.update { + it.copy( + messages = it.messages + ChatMessage( + sender = sender, + text = text, + mine = sender == myEndpoint, + ts = System.currentTimeMillis(), + ), + ) + } + } + + fun connect(host: String, nats: String, peerName: String) { + if (_state.value.connecting) return + _state.update { it.copy(connecting = true, error = null, status = "Conectando…") } + viewModelScope.launch(Dispatchers.IO) { + try { + val s = Mobile.newSession(idPath, nats.trim(), host.trim()) + session = s + myEndpoint = s.endpointID() + _state.update { + it.copy( + connecting = false, + connected = true, + endpointId = myEndpoint, + status = "Conectado como $peerName", + ) + } + } catch (e: Exception) { + _state.update { + it.copy(connecting = false, connected = false, error = e.message ?: "error desconocido") + } + } + } + } + + fun createRoom(subject: String, encrypted: Boolean) { + val s = session ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + val mode = if (encrypted) "matrix" else "nats" + val roomId = s.createRoom(subject.trim(), mode) + s.subscribe(roomId, this@BusViewModel) + _state.update { + it.copy( + roomId = roomId, + roomSubject = subject.trim(), + messages = emptyList(), + status = "Room creada", + ) + } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "error al crear room") } + } + } + } + + fun joinRoom(roomId: String) { + val s = session ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + val rid = roomId.trim() + s.join(rid) + s.subscribe(rid, this@BusViewModel) + _state.update { + it.copy(roomId = rid, roomSubject = "(unida)", messages = emptyList(), status = "Unido a la room") + } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "error al unirse") } + } + } + } + + fun publish(text: String) { + val s = session ?: return + val room = _state.value.roomId + if (room.isEmpty() || text.isBlank()) return + viewModelScope.launch(Dispatchers.IO) { + try { + s.publish(room, text) + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "error al publicar") } + } + } + } + + /** card returns this peer's shareable public identity (no secret). */ + fun card(): String = try { + session?.card() ?: "" + } catch (_: Exception) { + "" + } + + fun clearError() = _state.update { it.copy(error = null) } + + override fun onCleared() { + try { + session?.close() + } catch (_: Exception) { + } + session = null + } +} diff --git a/android/app/src/main/java/com/unibus/app/MainActivity.kt b/android/app/src/main/java/com/unibus/app/MainActivity.kt new file mode 100644 index 0000000..ba45af9 --- /dev/null +++ b/android/app/src/main/java/com/unibus/app/MainActivity.kt @@ -0,0 +1,307 @@ +package com.unibus.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class MainActivity : ComponentActivity() { + private val vm: BusViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + UnibusApp(vm) + } + } + } + } +} + +@Composable +fun UnibusApp(vm: BusViewModel) { + val state by vm.state.collectAsState() + if (!state.connected) { + ConnectScreen( + connecting = state.connecting, + error = state.error, + onConnect = { host, nats, name -> vm.connect(host, nats, name) }, + ) + } else { + ChatScreen(state = state, vm = vm) + } +} + +@Composable +fun ConnectScreen( + connecting: Boolean, + error: String?, + onConnect: (String, String, String) -> Unit, +) { + var host by rememberSaveable { mutableStateOf("http://10.0.2.2:8470") } + var nats by rememberSaveable { mutableStateOf("nats://10.0.2.2:4250") } + var name by rememberSaveable { mutableStateOf("android") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text("unibus", style = MaterialTheme.typography.headlineMedium) + Text( + "chat cifrado extremo a extremo sobre NATS", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host (control plane)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = nats, + onValueChange = { nats = it }, + label = { Text("NATS (data plane)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Identidad") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Spacer(Modifier.height(12.dp)) + Text(error, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = { onConnect(host, nats, name) }, + enabled = !connecting, + modifier = Modifier.fillMaxWidth(), + ) { + if (connecting) { + CircularProgressIndicator(modifier = Modifier.height(18.dp).width(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(if (connecting) "Conectando…" else "Conectar") + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen(state: BusState, vm: BusViewModel) { + var subject by rememberSaveable { mutableStateOf("room.general") } + var encrypt by rememberSaveable { mutableStateOf(false) } + var joinId by rememberSaveable { mutableStateOf("") } + var draft by rememberSaveable { mutableStateOf("") } + val listState = rememberLazyListState() + + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("unibus", style = MaterialTheme.typography.titleMedium) + Text( + state.status.ifEmpty { state.endpointId.take(12) + "…" }, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) + }, + ) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .padding(horizontal = 12.dp), + ) { + // Room controls. + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + label = { Text("subject") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button(onClick = { vm.createRoom(subject, encrypt) }) { + Icon(Icons.Filled.Add, contentDescription = "crear") + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = encrypt, onCheckedChange = { encrypt = it }) + Spacer(Modifier.width(8.dp)) + Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.height(16.dp)) + Text("cifrar (E2E)", style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = joinId, + onValueChange = { joinId = it }, + label = { Text("unirse por room id") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = { if (joinId.isNotBlank()) vm.joinRoom(joinId) }) { + Text("Unir") + } + } + if (state.roomId.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + "room: ${state.roomSubject} · ${state.roomId}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + if (state.error != null) { + Text( + state.error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) + } + + // Messages. + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + itemsIndexed(state.messages, key = { i, m -> "${m.ts}-$i" }) { _, m -> + MessageBubble(m) + } + } + + // Composer. + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + placeholder = { Text("Mensaje…") }, + singleLine = true, + enabled = state.roomId.isNotEmpty(), + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { + vm.publish(draft) + draft = "" + }, + enabled = state.roomId.isNotEmpty() && draft.isNotBlank(), + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "enviar") + } + } + } + } +} + +private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + +@Composable +fun MessageBubble(m: ChatMessage) { + val align = if (m.mine) Alignment.End else Alignment.Start + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) { + Card( + modifier = Modifier.fillMaxWidth(0.8f), + ) { + Column(Modifier.padding(8.dp)) { + if (!m.mine) { + Text( + m.sender.take(12) + "…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + Text(m.text, style = MaterialTheme.typography.bodyMedium) + Text( + timeFmt.format(Date(m.ts)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..968ab78 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + unibus + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..3f5162b --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..d790680 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,8 @@ +// Top-level build file. Plugin versions are declared here and applied in the +// module build scripts. AGP 8.5 + Kotlin 2.0 (with the dedicated Compose +// compiler plugin) target the locally installed SDK (compileSdk 34). +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..71e8721 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android/gradlew.bat @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..7a2d0e2 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "unibus" +include(":app") diff --git a/mobile/unibus.go b/mobile/unibus.go index d2a6a9c..3e4d79b 100644 --- a/mobile/unibus.go +++ b/mobile/unibus.go @@ -11,6 +11,9 @@ package mobile import ( + "encoding/base64" + "encoding/json" + "fmt" "time" "github.com/enmanuel/unibus/pkg/client" @@ -92,6 +95,56 @@ func (s *Session) Subscribe(roomID string, l FrameListener) error { return err } +// cardJSON is the portable, copy-pasteable public identity a peer shares so a +// room owner can invite it to an encrypted room. It carries no secret: only the +// endpoint id and the two public keys (signing + key-exchange), base64-encoded +// for transport over text or a QR code. +type cardJSON struct { + ID string `json:"id"` + SignPub string `json:"sign_pub"` // base64 std of the Ed25519 public key + KexPub string `json:"kex_pub"` // base64 std of the X25519 public key +} + +// Card returns this peer's public identity as a portable JSON string. Share it +// (paste, QR) with a room owner so they can Invite you to an encrypted room. It +// contains no private key and is safe to transmit in the clear. +func (s *Session) Card() string { + ep := s.c.Endpoint() + b, _ := json.Marshal(cardJSON{ + ID: ep.ID, + SignPub: base64.StdEncoding.EncodeToString(ep.SignPub), + KexPub: base64.StdEncoding.EncodeToString(ep.KexPub), + }) + return string(b) +} + +// Invite adds the holder of peerCard to roomID. peerCard is the JSON string the +// invitee produced with Card(). For encrypted rooms this seals the current room +// key to the invitee's X25519 public key and signs the request; the caller must +// be the room owner. +func (s *Session) Invite(roomID, peerCard string) error { + var card cardJSON + if err := json.Unmarshal([]byte(peerCard), &card); err != nil { + return fmt.Errorf("mobile: bad peer card: %w", err) + } + signPub, err := base64.StdEncoding.DecodeString(card.SignPub) + if err != nil { + return fmt.Errorf("mobile: bad sign_pub in card: %w", err) + } + kexPub, err := base64.StdEncoding.DecodeString(card.KexPub) + if err != nil { + return fmt.Errorf("mobile: bad kex_pub in card: %w", err) + } + return s.c.Invite(roomID, client.Endpoint{ID: card.ID, SignPub: signPub, KexPub: kexPub}) +} + +// Kick removes endpointID from roomID and, for encrypted rooms, rotates the room +// key to a new epoch so the removed peer cannot decrypt messages published after +// the kick (forward secrecy). The caller must be the room owner. +func (s *Session) Kick(roomID, endpointID string) error { + return s.c.Kick(roomID, endpointID) +} + // Request performs an RPC request/reply against subject and returns the reply // payload as text. timeoutMs bounds the wait in milliseconds. func (s *Session) Request(subject, text string, timeoutMs int) (string, error) { diff --git a/playground/server.go b/playground/server.go index 5690913..24db2e8 100644 --- a/playground/server.go +++ b/playground/server.go @@ -24,6 +24,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net/http" "os" @@ -124,6 +125,22 @@ func (p *peerState) setRoom(roomID string, info roomInfo) { p.mu.Unlock() } +// roomList returns a snapshot of the rooms this peer knows (created or joined), +// so the SPA can render the peer's room list without re-deriving it client-side. +func (p *peerState) roomList() []map[string]any { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]map[string]any, 0, len(p.rooms)) + for id, info := range p.rooms { + out = append(out, map[string]any{ + "room_id": id, + "subject": info.subject, + "encrypt": info.encrypt, + }) + } + return out +} + // --------------------------------------------------------------------------- // Hub: the registry of peers, protected by a single mutex. // --------------------------------------------------------------------------- @@ -449,6 +466,64 @@ func (h *Hub) handleKick(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "target": req.Target}) } +// handleRooms returns the rooms a peer knows (created or joined). The SPA polls +// or calls this after create/join to refresh its room list. +// +// GET /api/rooms?peer=ana +func (h *Hub) handleRooms(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("peer") + if name == "" { + writeErr(w, http.StatusBadRequest, "peer query param required") + return + } + p, ok := h.lookup(name) + if !ok { + writeErr(w, http.StatusBadRequest, "unknown peer "+name) + return + } + writeJSON(w, http.StatusOK, p.roomList()) +} + +// handleMembers lists the members of a room (endpoint id + role) so the SPA can +// render a members panel and drive invite/kick. It proxies the control plane's +// unauthenticated read endpoint; the public keys it returns are not secret. +// +// GET /api/members?room_id= +func (h *Hub) handleMembers(w http.ResponseWriter, r *http.Request) { + roomID := r.URL.Query().Get("room_id") + if roomID == "" { + writeErr(w, http.StatusBadRequest, "room_id query param required") + return + } + resp, err := http.Get(ctrlURL + "/rooms/" + roomID + "/members") + if err != nil { + writeErr(w, http.StatusInternalServerError, "fetch members: "+err.Error()) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(body) +} + +// withCORS allows the SPA running under the Vite dev server (a different origin) +// to call the gateway. It answers preflight OPTIONS and tags every response with +// permissive CORS headers. v1 trusts the local network, mirroring the control +// plane's auth model. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + // handleStream is the SSE endpoint. The browser opens one EventSource per peer; // each received Event is emitted as a `data: \n\n` block. The listener is // cleaned up when the HTTP request context is cancelled (tab closed / reload). @@ -807,9 +882,11 @@ func main() { mux.HandleFunc("POST /api/invite", hub.handleInvite) mux.HandleFunc("POST /api/publish", hub.handlePublish) mux.HandleFunc("POST /api/kick", hub.handleKick) + mux.HandleFunc("GET /api/rooms", hub.handleRooms) + mux.HandleFunc("GET /api/members", hub.handleMembers) mux.HandleFunc("GET /api/stream", hub.handleStream) mux.HandleFunc("GET /api/bench", hub.handleBench) - webSrv := &http.Server{Addr: webAddr, Handler: mux} + webSrv := &http.Server{Addr: webAddr, Handler: withCORS(mux)} go func() { if err := webSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("web server: %v", err) diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..de9ca1d --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.local +.vite/ +*.tsbuildinfo diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..500d5dd --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + unibus · chat + + + + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..bc95271 --- /dev/null +++ b/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "unibus-web", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "SPA de chat para el bus unibus (rooms cifradas E2E, mensajes en vivo por SSE).", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@tabler/icons-react": "^3.36.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^4.3.4", + "postcss": "^8.4.49", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..6b89b20 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1481 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@mantine/core': + specifier: ^9.3.0 + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': + specifier: ^9.3.0 + version: 9.3.0(react@19.2.7) + '@tabler/icons-react': + specifier: ^3.36.0 + version: 3.44.0(react@19.2.7) + react: + specifier: ^19.2.0 + version: 19.2.7 + react-dom: + specifier: ^19.2.0 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.0 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))) + postcss: + specifier: ^8.4.49 + version: 8.5.15 + postcss-preset-mantine: + specifier: ^1.17.0 + version: 1.18.0(postcss@8.5.15) + postcss-simple-vars: + specifier: ^7.0.1 + version: 7.0.1(postcss@8.5.15) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mantine/core@9.3.0': + resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==} + peerDependencies: + '@mantine/hooks': 9.3.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + '@mantine/hooks@9.3.0': + resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==} + peerDependencies: + react: ^19.2.0 + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + + '@tabler/icons-react@3.44.0': + resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.44.0': + resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.16': + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + electron-to-chromium@1.5.368: + resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-mixins@12.1.2: + resolution: {integrity: sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==} + engines: {node: ^20.0 || ^22.0 || >=24.0} + peerDependencies: + postcss: ^8.2.14 + + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-preset-mantine@1.18.0: + resolution: {integrity: sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==} + peerDependencies: + postcss: '>=8.0.0' + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-simple-vars@7.0.1: + resolution: {integrity: sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==} + engines: {node: '>=14.0'} + peerDependencies: + postcss: ^8.2.1 + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-number-format@5.4.5: + resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sugarss@5.0.1: + resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.3.3 + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/react@0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.11 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + type-fest: 5.7.0 + transitivePeerDependencies: + - '@types/react' + + '@mantine/hooks@9.3.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + + '@tabler/icons-react@3.44.0(react@19.2.7)': + dependencies: + '@tabler/icons': 3.44.0 + react: 19.2.7 + + '@tabler/icons@3.44.0': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.3(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + + '@types/react@19.2.16': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.10.33: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.368 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001793: {} + + clsx@2.1.1: {} + + convert-source-map@2.0.0: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-node-es@1.1.0: {} + + electron-to-chromium@1.5.368: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + node-releases@2.0.47: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-mixins@12.1.2(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-js: 4.1.0(postcss@8.5.15) + postcss-simple-vars: 7.0.1(postcss@8.5.15) + sugarss: 5.0.1(postcss@8.5.15) + tinyglobby: 0.2.17 + + postcss-nested@7.0.2(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-preset-mantine@1.18.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-mixins: 12.1.2(postcss@8.5.15) + postcss-nested: 7.0.2(postcss@8.5.15) + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-simple-vars@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-number-format@5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + + react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react@19.2.7: {} + + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + sugarss@5.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + tabbable@6.4.0: {} + + tagged-tag@1.0.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + util-deprecate@1.0.2: {} + + vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + sugarss: 5.0.1(postcss@8.5.15) + + yallist@3.1.1: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..e817f56 --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..c268f66 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { GatewayClient } from "./api"; +import type { Peer } from "./types"; +import { ConnectScreen } from "./components/ConnectScreen"; +import { ChatLayout } from "./components/ChatLayout"; + +// Connection holds the live gateway client plus the identity it connected as. +interface Connection { + client: GatewayClient; + peer: Peer; +} + +// App is the root: it shows the connect screen until the user picks a gateway +// URL and a peer name, then swaps to the full chat layout. Disconnecting drops +// back to the connect screen. +export function App() { + const [conn, setConn] = useState(null); + + if (!conn) { + return setConn({ client, peer })} />; + } + return ( + setConn(null)} + /> + ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..bc3c40e --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,99 @@ +// GatewayClient is the SPA's typed wrapper over the unibus gateway HTTP API. +// Every method is a thin fetch against the gateway, which hosts one real Go bus +// peer per name and performs all NATS + end-to-end crypto on the browser's +// behalf. The base URL is chosen at runtime on the connect screen. +import type { BusEvent, Member, Peer, Room } from "./types"; + +export class GatewayClient { + constructor(public readonly baseURL: string) { + // Normalize: drop a trailing slash so `${base}/api/...` never doubles up. + this.baseURL = baseURL.replace(/\/+$/, ""); + } + + private async req(method: string, path: string, body?: unknown): Promise { + const res = await fetch(this.baseURL + path, { + method, + headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) { + let msg = text; + try { + const j = JSON.parse(text); + if (j && typeof j.error === "string") msg = j.error; + } catch { + // not JSON: keep the raw text + } + throw new Error(msg || `HTTP ${res.status}`); + } + return (text ? JSON.parse(text) : {}) as T; + } + + // connect creates (or recovers) the named peer on the gateway and returns its + // public identity. The identity persists across gateway restarts. + connect(name: string): Promise { + return this.req("POST", "/api/peer", { name }); + } + + // peers lists every peer currently hosted by the gateway (for the invite picker + // and to label senders by name). + peers(): Promise { + return this.req("GET", "/api/peers"); + } + + // rooms lists the rooms the named peer knows (created or joined). + rooms(peer: string): Promise { + return this.req("GET", `/api/rooms?peer=${encodeURIComponent(peer)}`); + } + + // members lists the participants of a room. + members(roomID: string): Promise { + return this.req("GET", `/api/members?room_id=${encodeURIComponent(roomID)}`); + } + + // createRoom opens a room on the given subject. encrypt drives both E2E + // encryption and per-message signing; the peer is auto-subscribed. + createRoom(peer: string, subject: string, encrypt: boolean): Promise { + return this.req("POST", "/api/room", { peer, subject, encrypt, persist: false }); + } + + // join subscribes the peer to an existing room (must have been invited first + // when the room is encrypted). + join(peer: string, roomID: string): Promise<{ subject: string; encrypt: boolean }> { + return this.req("POST", "/api/join", { peer, room_id: roomID }); + } + + // invite adds another connected peer (by name) to a room, sealing the room key + // to it. Caller must be the room owner. + invite(peer: string, roomID: string, target: string): Promise<{ status: string }> { + return this.req("POST", "/api/invite", { peer, room_id: roomID, target }); + } + + // publish sends a text message to a room. + publish(peer: string, roomID: string, text: string): Promise<{ status: string }> { + return this.req("POST", "/api/publish", { peer, room_id: roomID, text }); + } + + // kick removes a peer (by name) from a room and rotates the key (forward + // secrecy). Caller must be the room owner. + kick(peer: string, roomID: string, target: string): Promise<{ status: string }> { + return this.req("POST", "/api/kick", { peer, room_id: roomID, target }); + } + + // stream opens the SSE channel for a peer. onEvent fires for each received bus + // message; onError fires if the stream drops. Returns the EventSource so the + // caller can close it. + stream(peer: string, onEvent: (ev: BusEvent) => void, onError?: () => void): EventSource { + const es = new EventSource(`${this.baseURL}/api/stream?peer=${encodeURIComponent(peer)}`); + es.onmessage = (e) => { + try { + onEvent(JSON.parse(e.data) as BusEvent); + } catch { + // ignore malformed frames (keepalive comments never reach onmessage) + } + }; + if (onError) es.onerror = onError; + return es; + } +} diff --git a/web/src/components/ChatLayout.tsx b/web/src/components/ChatLayout.tsx new file mode 100644 index 0000000..2c544e3 --- /dev/null +++ b/web/src/components/ChatLayout.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AppShell, + Group, + Title, + Badge, + Button, + CopyButton, + Tooltip, + ActionIcon, + ThemeIcon, + Alert, + Transition, +} from "@mantine/core"; +import { + IconBolt, + IconLogout, + IconCopy, + IconCheck, + IconAlertTriangle, +} from "@tabler/icons-react"; +import { GatewayClient } from "../api"; +import type { Member, Message, Peer, Room } from "../types"; +import { RoomList } from "./RoomList"; +import { MessagePane } from "./MessagePane"; +import { MembersPane } from "./MembersPane"; + +interface Props { + client: GatewayClient; + peer: Peer; + onDisconnect: () => void; +} + +// short renders the first 10 chars of an endpoint id, enough to disambiguate. +export function short(endpoint: string): string { + return endpoint.length > 12 ? endpoint.slice(0, 10) + "…" : endpoint; +} + +// ChatLayout owns all chat state: the peer's rooms, the active room, the +// per-room message log fed by the SSE stream, the directory of connected peers +// (to label senders and pick invitees), and the active room's member list. Every +// bus action goes through the gateway client. +export function ChatLayout({ client, peer, onDisconnect }: Props) { + const [rooms, setRooms] = useState([]); + const [activeRoom, setActiveRoom] = useState(null); + const [messages, setMessages] = useState>({}); + const [peers, setPeers] = useState([]); + const [members, setMembers] = useState([]); + const [error, setError] = useState(null); + const seq = useRef(0); + + const fail = useCallback((e: unknown) => { + setError(e instanceof Error ? e.message : String(e)); + }, []); + + // ---- data refreshers ---------------------------------------------------- + + const refreshRooms = useCallback(async () => { + try { + setRooms(await client.rooms(peer.name)); + } catch (e) { + fail(e); + } + }, [client, peer.name, fail]); + + const refreshPeers = useCallback(async () => { + try { + setPeers(await client.peers()); + } catch (e) { + fail(e); + } + }, [client, fail]); + + const refreshMembers = useCallback( + async (roomID: string) => { + try { + setMembers(await client.members(roomID)); + } catch (e) { + fail(e); + } + }, + [client, fail], + ); + + // ---- live stream (SSE) -------------------------------------------------- + + useEffect(() => { + const es = client.stream( + peer.name, + (ev) => { + seq.current += 1; + const msg: Message = { ...ev, id: `${ev.ts}-${seq.current}` }; + setMessages((prev) => { + const list = prev[ev.room_id] ?? []; + return { ...prev, [ev.room_id]: [...list, msg] }; + }); + }, + () => setError("Se perdió la conexión con el gateway (stream SSE)"), + ); + return () => es.close(); + }, [client, peer.name]); + + // Initial load. + useEffect(() => { + refreshRooms(); + refreshPeers(); + }, [refreshRooms, refreshPeers]); + + // Refresh members whenever the active room changes. + useEffect(() => { + if (activeRoom) refreshMembers(activeRoom); + else setMembers([]); + }, [activeRoom, refreshMembers]); + + // ---- actions ------------------------------------------------------------ + + const onCreateRoom = useCallback( + async (subject: string, encrypt: boolean) => { + try { + const r = await client.createRoom(peer.name, subject, encrypt); + await refreshRooms(); + setActiveRoom(r.room_id); + } catch (e) { + fail(e); + } + }, + [client, peer.name, refreshRooms, fail], + ); + + const onJoinRoom = useCallback( + async (roomID: string) => { + try { + await client.join(peer.name, roomID); + await refreshRooms(); + setActiveRoom(roomID); + } catch (e) { + fail(e); + } + }, + [client, peer.name, refreshRooms, fail], + ); + + const onInvite = useCallback( + async (target: string) => { + if (!activeRoom) return; + try { + await client.invite(peer.name, activeRoom, target); + await refreshMembers(activeRoom); + } catch (e) { + fail(e); + } + }, + [client, peer.name, activeRoom, refreshMembers, fail], + ); + + const onKick = useCallback( + async (target: string) => { + if (!activeRoom) return; + try { + await client.kick(peer.name, activeRoom, target); + await refreshMembers(activeRoom); + } catch (e) { + fail(e); + } + }, + [client, peer.name, activeRoom, refreshMembers, fail], + ); + + const onPublish = useCallback( + async (text: string) => { + if (!activeRoom) return; + try { + await client.publish(peer.name, activeRoom, text); + } catch (e) { + fail(e); + } + }, + [client, peer.name, activeRoom, fail], + ); + + // endpoint -> display name, using the peer directory; falls back to a short id. + const nameFor = useMemo(() => { + const byEndpoint = new Map(peers.map((p) => [p.endpoint_id, p.name])); + return (endpoint: string) => + endpoint === peer.endpoint_id ? peer.name : byEndpoint.get(endpoint) ?? short(endpoint); + }, [peers, peer]); + + const activeRoomObj = rooms.find((r) => r.room_id === activeRoom) ?? null; + const iAmOwner = members.some((m) => m.endpoint === peer.endpoint_id && m.role === "owner"); + + return ( + + + + + + + + unibus + + + + {peer.name} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + } + onClick={onDisconnect} + > + Salir + + + + + + + + + + + {error && ( + + {(styles) => ( + } + withCloseButton + onClose={() => setError(null)} + title="Error" + > + {error} + + )} + + )} + + + + + {activeRoomObj && ( + activeRoom && refreshMembers(activeRoom)} + /> + )} + + + ); +} diff --git a/web/src/components/ConnectScreen.tsx b/web/src/components/ConnectScreen.tsx new file mode 100644 index 0000000..901582a --- /dev/null +++ b/web/src/components/ConnectScreen.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { + Button, + Card, + Center, + Group, + Stack, + Text, + TextInput, + Title, + Alert, + ThemeIcon, +} from "@mantine/core"; +import { IconBolt, IconPlugConnected, IconAlertTriangle } from "@tabler/icons-react"; +import { GatewayClient } from "../api"; +import type { Peer } from "../types"; + +const LS_GATEWAY = "unibus.gateway"; +const LS_PEER = "unibus.peer"; + +interface Props { + onConnect: (client: GatewayClient, peer: Peer) => void; +} + +// ConnectScreen asks for the gateway URL and the identity (peer name) to connect +// as. Both persist in localStorage so a reload reconnects with one click. The +// gateway hosts the real Go bus peer; the browser only drives it. +export function ConnectScreen({ onConnect }: Props) { + const [gateway, setGateway] = useState( + () => localStorage.getItem(LS_GATEWAY) ?? "http://localhost:7700", + ); + const [name, setName] = useState(() => localStorage.getItem(LS_PEER) ?? ""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const connect = async () => { + const trimmed = name.trim(); + if (!trimmed) { + setError("Elige un nombre de identidad"); + return; + } + setBusy(true); + setError(null); + try { + const client = new GatewayClient(gateway.trim()); + const peer = await client.connect(trimmed); + localStorage.setItem(LS_GATEWAY, client.baseURL); + localStorage.setItem(LS_PEER, trimmed); + onConnect(client, peer); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + + + + unibus + + chat cifrado extremo a extremo sobre NATS + + + + + setGateway(e.currentTarget.value)} + disabled={busy} + /> + setName(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && connect()} + disabled={busy} + data-autofocus + /> + + {error && ( + } + title="No se pudo conectar" + > + {error} + + )} + + } + onClick={connect} + loading={busy} + fullWidth + size="md" + > + Conectar + + + + + ); +} diff --git a/web/src/components/MembersPane.tsx b/web/src/components/MembersPane.tsx new file mode 100644 index 0000000..891247c --- /dev/null +++ b/web/src/components/MembersPane.tsx @@ -0,0 +1,153 @@ +import { useState } from "react"; +import { + Stack, + Group, + Text, + Badge, + Select, + Button, + ActionIcon, + Divider, + Box, + Avatar, + Tooltip, + ScrollArea, +} from "@mantine/core"; +import { IconUserPlus, IconUserMinus, IconRefresh, IconUsers } from "@tabler/icons-react"; +import type { Member, Peer, Room } from "../types"; + +interface Props { + room: Room; + members: Member[]; + peers: Peer[]; + myEndpoint: string; + iAmOwner: boolean; + nameFor: (endpoint: string) => string; + onInvite: (target: string) => void; + onKick: (target: string) => void; + onRefresh: () => void; +} + +// MembersPane is the right column: who is in the active room, plus invite (pick a +// connected peer) and kick (owner only). Invite/kick address peers by name; the +// gateway resolves the name to its bus endpoint. +export function MembersPane({ + room, + members, + peers, + myEndpoint, + iAmOwner, + nameFor, + onInvite, + onKick, + onRefresh, +}: Props) { + const [target, setTarget] = useState(null); + + const memberEndpoints = new Set(members.map((m) => m.endpoint)); + // Candidates to invite: connected peers not already in the room. + const candidates = peers + .filter((p) => !memberEndpoints.has(p.endpoint_id)) + .map((p) => ({ value: p.name, label: p.name })); + + const invite = () => { + if (target) { + onInvite(target); + setTarget(null); + } + }; + + return ( + + + + + Miembros + + {members.length} + + + + + + + + + + + + Invitar {room.encrypt && "(reparte la clave)"} + + + + } + onClick={invite} + disabled={!target} + > + Invitar + + + + + + + + + {members.map((m) => { + const isMe = m.endpoint === myEndpoint; + const name = nameFor(m.endpoint); + const canKick = iAmOwner && !isMe && m.role !== "owner"; + return ( + + + + {name.slice(0, 2).toUpperCase()} + + + + {name} {isMe && "(tú)"} + + + {m.endpoint} + + + + + {m.role === "owner" && ( + + owner + + )} + {canKick && ( + + onKick(name)} + > + + + + )} + + + ); + })} + + + + ); +} diff --git a/web/src/components/MessagePane.tsx b/web/src/components/MessagePane.tsx new file mode 100644 index 0000000..73a657d --- /dev/null +++ b/web/src/components/MessagePane.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from "react"; +import { + Stack, + Group, + Text, + Badge, + Paper, + ScrollArea, + TextInput, + ActionIcon, + Center, + ThemeIcon, + Box, + CopyButton, + Tooltip, +} from "@mantine/core"; +import { + IconLock, + IconHash, + IconSend, + IconMessages, + IconCopy, + IconCheck, +} from "@tabler/icons-react"; +import type { Message, Room } from "../types"; + +interface Props { + room: Room | null; + messages: Message[]; + myEndpoint: string; + nameFor: (endpoint: string) => string; + onPublish: (text: string) => void; +} + +// formatTime renders a message timestamp as HH:mm:ss in 24h European style. +function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString("es-ES", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +// MessagePane is the center column: the active room's live message log plus the +// composer. Own messages align right; others align left and show the sender. +export function MessagePane({ room, messages, myEndpoint, nameFor, onPublish }: Props) { + const [text, setText] = useState(""); + const viewport = useRef(null); + + // Auto-scroll to the newest message. + useEffect(() => { + viewport.current?.scrollTo({ top: viewport.current.scrollHeight, behavior: "smooth" }); + }, [messages.length]); + + if (!room) { + return ( + + + + + + Elige o crea una room para empezar a chatear + + + ); + } + + const send = () => { + const t = text.trim(); + if (t) { + onPublish(t); + setText(""); + } + }; + + return ( + + + + {room.encrypt ? : } + {room.subject} + {room.encrypt && ( + + cifrada E2E + + )} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + + + {messages.length === 0 && ( + + No hay mensajes todavía. + + )} + {messages.map((m) => { + const mine = m.sender === myEndpoint; + return ( + + + {!mine && ( + + {nameFor(m.sender)} + + )} + + {m.text} + + + {formatTime(m.ts)} + + + + ); + })} + + + + + setText(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && send()} + /> + + + + + + ); +} diff --git a/web/src/components/RoomList.tsx b/web/src/components/RoomList.tsx new file mode 100644 index 0000000..3a424b4 --- /dev/null +++ b/web/src/components/RoomList.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { + Stack, + TextInput, + Checkbox, + Button, + Divider, + Text, + NavLink, + ScrollArea, + Group, + Box, +} from "@mantine/core"; +import { IconLock, IconHash, IconPlus, IconDoorEnter } from "@tabler/icons-react"; +import type { Room } from "../types"; + +interface Props { + rooms: Room[]; + activeRoom: string | null; + onSelect: (roomID: string) => void; + onCreateRoom: (subject: string, encrypt: boolean) => void; + onJoinRoom: (roomID: string) => void; +} + +// RoomList is the navbar: create a room, join one by id, and pick the active +// room from the peer's known rooms. +export function RoomList({ rooms, activeRoom, onSelect, onCreateRoom, onJoinRoom }: Props) { + const [subject, setSubject] = useState("room.general"); + const [encrypt, setEncrypt] = useState(true); + const [joinID, setJoinID] = useState(""); + + const create = () => { + if (subject.trim()) onCreateRoom(subject.trim(), encrypt); + }; + const join = () => { + if (joinID.trim()) { + onJoinRoom(joinID.trim()); + setJoinID(""); + } + }; + + return ( + + + + Crear room + + + } + value={subject} + onChange={(e) => setSubject(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && create()} + /> + setEncrypt(e.currentTarget.checked)} + /> + } onClick={create}> + Crear + + + + + + + + + Unirse por id + + + setJoinID(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && join()} + style={{ flex: 1 }} + /> + + + + + + + + + + Rooms ({rooms.length}) + + + + {rooms.length === 0 && ( + + Aún no hay rooms. Crea o únete a una. + + )} + {rooms.map((r) => ( + onSelect(r.room_id)} + label={r.subject} + description={r.room_id.slice(0, 14) + "…"} + leftSection={ + r.encrypt ? : + } + variant="filled" + /> + ))} + + + + ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..82cc289 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import "@mantine/core/styles.css"; +import { theme } from "./theme"; +import { App } from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..11b54d0 --- /dev/null +++ b/web/src/theme.ts @@ -0,0 +1,14 @@ +import { createTheme } from "@mantine/core"; + +// The unibus theme: a single accent color and a slightly tighter default radius. +// Mantine generates all its CSS variables from this; the SPA never hand-writes +// CSS or color literals. +export const theme = createTheme({ + primaryColor: "violet", + defaultRadius: "md", + fontFamily: + "Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + headings: { + fontWeight: "650", + }, +}); diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..bd25a89 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,41 @@ +// Domain types shared across the SPA. They mirror the JSON the unibus gateway +// (playground/server.go) returns; the browser never speaks NATS or crypto +// directly — the Go peer behind the gateway does, so every type here is a plain +// view of a gateway response. + +// Peer is a named identity hosted by the gateway. endpoint_id is the stable bus +// endpoint (base64url of sha256(signPub)). +export interface Peer { + name: string; + endpoint_id: string; +} + +// Room is a channel the connected peer created or joined. encrypt true means the +// payloads are sealed end-to-end with the room key. +export interface Room { + room_id: string; + subject: string; + encrypt: boolean; +} + +// Member is one participant of a room as reported by the control plane. +export interface Member { + endpoint: string; + role: string; +} + +// BusEvent is one Server-Sent Event delivered on /api/stream: a message a peer +// received on one of its subscribed rooms, already decrypted by the Go peer. +export interface BusEvent { + room_id: string; + subject: string; + sender: string; + text: string; + encrypted: boolean; + ts: number; // unix millis +} + +// Message is a BusEvent enriched with a stable local id for React keys. +export interface Message extends BusEvent { + id: string; +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..7ac96e0 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..9c43072 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..8acd431 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// The SPA talks to the unibus gateway over plain fetch + EventSource; the +// gateway URL is chosen at runtime on the connect screen, so nothing is proxied +// here. The dev server runs on a fixed port so the gateway's permissive CORS is +// predictable. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + }, +});