chore: remove experimental frontends (web, android, playground, mobile)
Limpieza de los frontends de prueba (SPA React, app Kotlin, gateway playground, binding gomobile) tras la fase de exploración. El bus (cmd/membershipd + pkg/*) queda intacto y verde. Empezamos un frontend web nuevo desde cero, construido de forma incremental. Todo lo borrado permanece en el historial git por si hay que recuperar algo.
This commit is contained in:
@@ -1,12 +0,0 @@
|
|||||||
.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
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
Vendored
-4
@@ -1,4 +0,0 @@
|
|||||||
# 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.** { *; }
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
android:theme="@style/Theme.Unibus">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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<ChatMessage> = 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<BusState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var session: Session? = null
|
|
||||||
private var myEndpoint: String = ""
|
|
||||||
|
|
||||||
private val idPath: String
|
|
||||||
get() = File(getApplication<Application>().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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">unibus</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- A minimal Material3 base theme; the real UI styling is driven by Compose
|
|
||||||
Material3 (MaterialTheme) at runtime. -->
|
|
||||||
<style name="Theme.Unibus" parent="android:Theme.Material.NoActionBar" />
|
|
||||||
</resources>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.caching=true
|
|
||||||
android.useAndroidX=true
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
BIN
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
Vendored
-252
@@ -1,252 +0,0 @@
|
|||||||
#!/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" "$@"
|
|
||||||
Vendored
-94
@@ -1,94 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
|
||||||
@rem This is normally unused
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
// Package mobile exposes a flat, gomobile-friendly API over the unibus client
|
|
||||||
// so an Android app can join rooms, publish, and receive messages with the same
|
|
||||||
// end-to-end encryption as any native Go peer.
|
|
||||||
//
|
|
||||||
// gomobile only supports a limited set of types across the binding boundary
|
|
||||||
// (string, []byte, int, bool, error, named structs, and interfaces). This layer
|
|
||||||
// translates the richer client API into those primitives and delivers incoming
|
|
||||||
// frames through a Java/Kotlin-implemented FrameListener callback. No protocol
|
|
||||||
// or cryptography is reimplemented here: every call delegates to pkg/client,
|
|
||||||
// which is the single source of truth shared with every other peer on the bus.
|
|
||||||
package mobile
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/client"
|
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
|
||||||
"github.com/enmanuel/unibus/pkg/room"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FrameListener receives decrypted messages for a subscribed room. The Android
|
|
||||||
// side implements this interface. Its methods are invoked from a NATS delivery
|
|
||||||
// goroutine, so implementations must hop back to the UI thread (for example via
|
|
||||||
// a coroutine on the main dispatcher) before touching Android views.
|
|
||||||
type FrameListener interface {
|
|
||||||
OnFrame(roomID string, sender string, msgID string, text string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session is a connected unibus peer. Create it with NewSession and close it
|
|
||||||
// with Close when the app stops.
|
|
||||||
type Session struct {
|
|
||||||
c *client.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateIdentity creates (or loads) the long-term keypair stored at path.
|
|
||||||
// Call it once on first launch. The resulting file holds the peer's private
|
|
||||||
// Ed25519 and X25519 keys and must be kept private to the app sandbox.
|
|
||||||
func GenerateIdentity(path string) error {
|
|
||||||
_, err := client.LoadOrCreateIdentity(path)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSession loads the identity at idPath and connects to the bus. natsURL is
|
|
||||||
// the data plane (for example tls://host:4250) and ctrlURL is the control plane
|
|
||||||
// HTTP endpoint (for example http://host:8470). caPath is the path to the bus
|
|
||||||
// CA certificate (ca.crt) bundled with the app: when set, the session connects
|
|
||||||
// securely (TLS pinned to that CA + nkey authentication on the data plane),
|
|
||||||
// matching a bus running with auth + TLS. Pass an empty caPath to connect in
|
|
||||||
// plaintext to an unsecured (dev) bus.
|
|
||||||
func NewSession(idPath, natsURL, ctrlURL, caPath string) (*Session, error) {
|
|
||||||
id, err := client.LoadOrCreateIdentity(idPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c, err := client.Connect(natsURL, ctrlURL, id, caPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Session{c: c}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EndpointID returns this peer's stable endpoint identifier, derived from its
|
|
||||||
// signing public key. It is the value that appears as the sender of frames.
|
|
||||||
func (s *Session) EndpointID() string {
|
|
||||||
return s.c.Endpoint().ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRoom opens a room on the given subject. mode is "matrix" for the
|
|
||||||
// encrypted, persisted and signed policy, or "nats" for plain cleartext. It
|
|
||||||
// returns the room id used by Join, Publish and Subscribe.
|
|
||||||
func (s *Session) CreateRoom(subject, mode string) (string, error) {
|
|
||||||
p := room.ModeNATS
|
|
||||||
if mode == "matrix" {
|
|
||||||
p = room.ModeMatrix
|
|
||||||
}
|
|
||||||
return s.c.CreateRoom(subject, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Join fetches the room key when the room is encrypted and prepares the session
|
|
||||||
// to publish to and receive from the room.
|
|
||||||
func (s *Session) Join(roomID string) error {
|
|
||||||
return s.c.Join(roomID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshSession reconnects the data plane so the bus re-derives this peer's
|
|
||||||
// per-subject permissions from its current room membership.
|
|
||||||
//
|
|
||||||
// Membership-change contract (issue 0006e): a secured bus (--bus-auth enforce)
|
|
||||||
// freezes a connection's permissions at connect time. After ANY membership change
|
|
||||||
// — a room you just created, were invited to, or joined — call RefreshSession
|
|
||||||
// BEFORE Publish/Subscribe on that room, or the bus denies the new room's subject.
|
|
||||||
// It also drops active subscriptions, so re-Subscribe afterwards. On an unsecured
|
|
||||||
// bus it is a harmless reconnect. A mobile/gateway caller wires this exactly like
|
|
||||||
// cmd/chat and cmd/worker do: CreateRoom -> RefreshSession -> Subscribe/Publish.
|
|
||||||
func (s *Session) RefreshSession() error {
|
|
||||||
return s.c.RefreshSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish sends a UTF-8 text message to the room.
|
|
||||||
func (s *Session) Publish(roomID, text string) error {
|
|
||||||
return s.c.Publish(roomID, []byte(text))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe streams decrypted messages of the room to the listener until the
|
|
||||||
// session is closed.
|
|
||||||
func (s *Session) Subscribe(roomID string, l FrameListener) error {
|
|
||||||
_, err := s.c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
|
||||||
l.OnFrame(roomID, f.Sender, f.MsgID, string(plaintext))
|
|
||||||
})
|
|
||||||
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) {
|
|
||||||
out, err := s.c.Request(subject, []byte(text), time.Duration(timeoutMs)*time.Millisecond)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(out), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close disconnects the peer from the bus.
|
|
||||||
func (s *Session) Close() error {
|
|
||||||
return s.c.Close()
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# unibus playground
|
|
||||||
|
|
||||||
An all-in-one, web-based sandbox for the **unibus** message bus. One command
|
|
||||||
brings up the entire stack embedded — no NATS to install, no services to wire —
|
|
||||||
and a browser UI lets you exercise the bus visually: create peers, create and
|
|
||||||
join rooms (cleartext or end-to-end encrypted), invite, publish, watch messages
|
|
||||||
arrive live, and kick members (forward secrecy).
|
|
||||||
|
|
||||||
This is a **playground** (see `.claude/rules/playgrounds.md`): it lives inside
|
|
||||||
the `unibus` app, reuses the parent Go module (no separate `go.mod`), is not
|
|
||||||
indexed, and keeps all runtime state under `playground/local_files/` (ephemeral,
|
|
||||||
safe to delete).
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
From the `unibus` app directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/enmanuel/fn_registry/projects/message_bus/apps/unibus
|
|
||||||
go run ./playground
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open **http://localhost:7700** in your browser.
|
|
||||||
|
|
||||||
Stop with `Ctrl-C` — the server tears down the web UI, every bus client, the
|
|
||||||
control plane, and the embedded NATS cleanly (no orphaned processes).
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The browser never speaks NATS. The Go server is the actual bus peer:
|
|
||||||
|
|
||||||
```
|
|
||||||
browser ──fetch/SSE──▶ playground server (:7700)
|
|
||||||
│ holds one unibus client per named peer
|
|
||||||
├──HTTP──▶ membership control plane (127.0.0.1:8480)
|
|
||||||
└──NATS──▶ embedded NATS + JetStream (:4260)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **:7700** — web UI (the only browser-facing port).
|
|
||||||
- **127.0.0.1:8480** — membership control plane (rooms, members, sealed keys,
|
|
||||||
rekey, blobs). Internal only.
|
|
||||||
- **:4260** — embedded NATS + JetStream (the data plane). Internal only.
|
|
||||||
|
|
||||||
Each named peer gets its own long-term identity, persisted to
|
|
||||||
`playground/local_files/<name>.id`, so a peer keeps the same endpoint across
|
|
||||||
restarts. When a peer creates or joins a room, the server subscribes on its
|
|
||||||
behalf and streams every received frame to that peer's open browser tabs over
|
|
||||||
Server-Sent Events.
|
|
||||||
|
|
||||||
The playground only orchestrates the public unibus client API
|
|
||||||
(`CreateRoom`, `Join`, `Subscribe`, `Publish`, `Invite`, `Kick`); it never
|
|
||||||
reimplements bus or crypto logic.
|
|
||||||
|
|
||||||
## Try it: 2 peers + encryption + kick
|
|
||||||
|
|
||||||
1. Open **two browser tabs** on http://localhost:7700.
|
|
||||||
2. Tab A: type `alice`, click **Connect**.
|
|
||||||
3. Tab B: type `bob`, click **Connect**.
|
|
||||||
4. Tab A (alice): type a subject like `room.general`, tick **🔒 encrypted
|
|
||||||
(E2E)**, click **Create room**. Copy the resulting `room_id`.
|
|
||||||
5. Tab A (alice): in the Action panel, pick `bob` as the target peer (use the
|
|
||||||
↻ button to refresh the peer list if needed) and click **Invite to this
|
|
||||||
room**.
|
|
||||||
6. Tab B (bob): paste the `room_id` into the join field and click **Join**.
|
|
||||||
7. Type messages in **both** tabs and hit Send — each message appears live in
|
|
||||||
both tabs, tagged with subject, sender, time, and 🔒 (encrypted) or `clear`.
|
|
||||||
8. Tab A (alice): click **Kick from this room** with `bob` selected. The room
|
|
||||||
key rotates to a new epoch. New messages alice sends are no longer visible to
|
|
||||||
bob — **forward secrecy**: bob no longer holds the current key.
|
|
||||||
|
|
||||||
Cleartext rooms (leave the checkbox unticked) behave like plain NATS fan-out:
|
|
||||||
fast, ephemeral, unsigned. Encrypted rooms are the Matrix-like mode: E2E
|
|
||||||
encrypted, persisted, and per-message signed.
|
|
||||||
|
|
||||||
## Benchmark: throughput simulator
|
|
||||||
|
|
||||||
The bottom panel of the UI is a performance simulator. Press **▶ Ejecutar
|
|
||||||
benchmark** and one publisher floods a fresh room with thousands of messages
|
|
||||||
that N subscribers receive (fan-out); a live canvas chart animates the sent vs
|
|
||||||
received totals while it runs.
|
|
||||||
|
|
||||||
The two policy axes are exposed as **independent flags**, so the benchmark
|
|
||||||
measures the cost of each layer in isolation:
|
|
||||||
|
|
||||||
| JetStream | Encryption | Room policy | What it costs |
|
|
||||||
|---|---|---|---|
|
|
||||||
| off | off | `{Encrypt:false, Persist:false}` | plain core NATS fan-out |
|
|
||||||
| **on** | off | `{Encrypt:false, Persist:true}` | durable JetStream (publish ack per message) |
|
|
||||||
| off | **on** | `{Encrypt:true, Persist:false}` | AEAD + Ed25519 signature per message, core transport |
|
|
||||||
| **on** | **on** | `{Encrypt:true, Persist:true}` | full E2E + durable history |
|
|
||||||
|
|
||||||
A **payload size** slider (16 B – 8 KiB) sets the message size. Encrypted or
|
|
||||||
persistent runs are capped to 30 000 messages (each message pays per-message
|
|
||||||
crypto and/or a JetStream ack, so they run much slower than plain NATS).
|
|
||||||
|
|
||||||
The benchmark uses its own ephemeral peers (fresh identities, never persisted),
|
|
||||||
so it never touches the named peers of the manual sandbox.
|
|
||||||
|
|
||||||
It is driven by an SSE endpoint that streams progress samples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -N "http://localhost:7700/api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0"
|
|
||||||
# emits: data: {"type":"start",...} data: {"type":"sample",...} data: {"type":"done",...}
|
|
||||||
```
|
|
||||||
|
|
||||||
Query params: `n_msgs`, `n_subs` (1–16), `payload` (bytes), `encrypt` (0/1),
|
|
||||||
`persist` (0/1).
|
|
||||||
|
|
||||||
## State / cleanup
|
|
||||||
|
|
||||||
All writable state lives under `playground/local_files/`:
|
|
||||||
|
|
||||||
- `<name>.id` — per-peer identity (private keys; treat like an SSH key).
|
|
||||||
- `play.db` — membership store (rooms, members, sealed keys).
|
|
||||||
- `blobs/` — media blob store.
|
|
||||||
- `js/` — embedded JetStream store.
|
|
||||||
|
|
||||||
Delete the whole `playground/local_files/` directory to reset to a clean slate.
|
|
||||||
It is gitignored and never distributed.
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>unibus playground</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0d1117;
|
|
||||||
--panel: #161b22;
|
|
||||||
--panel2: #1c2230;
|
|
||||||
--border: #2b333f;
|
|
||||||
--fg: #e6edf3;
|
|
||||||
--muted: #8b98a5;
|
|
||||||
--accent: #2f81f7;
|
|
||||||
--green: #3fb950;
|
|
||||||
--gold: #d29922;
|
|
||||||
--red: #f85149;
|
|
||||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
padding: 14px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
header h1 { margin: 0; font-size: 18px; letter-spacing: 0.5px; }
|
|
||||||
header .sub { color: var(--muted); font-size: 12px; }
|
|
||||||
.wrap {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 360px 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
.col { display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
.card {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
.card h2 {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 3px; }
|
|
||||||
input[type=text], select {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--panel2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--fg);
|
|
||||||
padding: 7px 9px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.row { display: flex; gap: 8px; align-items: center; }
|
|
||||||
.row > * { flex: 1; }
|
|
||||||
.checkrow { display: flex; align-items: center; gap: 6px; margin: 10px 0; }
|
|
||||||
.checkrow input { flex: 0 0 auto; width: auto; }
|
|
||||||
.checkrow label { margin: 0; flex: 0 0 auto; }
|
|
||||||
button {
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
padding: 7px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 13px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
button:hover { filter: brightness(1.12); }
|
|
||||||
button.ghost { background: var(--panel2); border: 1px solid var(--border); color: var(--fg); }
|
|
||||||
button.danger { background: #3a1d1d; border: 1px solid var(--red); color: var(--red); }
|
|
||||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
.pill {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--panel2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2px 9px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.pill.on { color: var(--green); border-color: var(--green); }
|
|
||||||
.ident { word-break: break-all; font-size: 11px; color: var(--gold); margin-top: 6px; }
|
|
||||||
.copy {
|
|
||||||
cursor: pointer; color: var(--accent); font-size: 11px;
|
|
||||||
margin-left: 6px; text-decoration: underline;
|
|
||||||
}
|
|
||||||
#log {
|
|
||||||
background: #08090c;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
height: 520px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 12.5px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.msg { padding: 2px 0; border-bottom: 1px solid #11151b; }
|
|
||||||
.msg .subj { color: var(--accent); }
|
|
||||||
.msg .from { color: var(--gold); }
|
|
||||||
.msg .meta { color: var(--muted); font-size: 11px; }
|
|
||||||
.msg .enc { color: var(--green); }
|
|
||||||
.msg .clear { color: var(--muted); }
|
|
||||||
.sys { color: var(--muted); font-style: italic; }
|
|
||||||
.err { color: var(--red); }
|
|
||||||
.help {
|
|
||||||
background: var(--panel2);
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.help b { color: var(--fg); }
|
|
||||||
.help code { color: var(--gold); }
|
|
||||||
.status { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
|
|
||||||
.status.ok { color: var(--green); }
|
|
||||||
.status.bad { color: var(--red); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>unibus playground</h1>
|
|
||||||
<span class="sub">embedded NATS + JetStream · E2E rooms · forward secrecy · SSE</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="wrap">
|
|
||||||
<!-- LEFT COLUMN: controls -->
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<h2>1 · Identity</h2>
|
|
||||||
<label>Peer name</label>
|
|
||||||
<div class="row">
|
|
||||||
<input id="peerName" type="text" placeholder="alice" autocomplete="off" />
|
|
||||||
<button id="connectBtn" style="flex:0 0 auto">Connect</button>
|
|
||||||
</div>
|
|
||||||
<div id="peerIdent" class="ident"></div>
|
|
||||||
<div id="connStatus" class="status"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>2 · Rooms</h2>
|
|
||||||
<label>Subject (e.g. room.general)</label>
|
|
||||||
<input id="roomSubject" type="text" placeholder="room.general" autocomplete="off" />
|
|
||||||
<div class="checkrow">
|
|
||||||
<input id="roomEncrypt" type="checkbox" />
|
|
||||||
<label for="roomEncrypt">🔒 encrypted (E2E)</label>
|
|
||||||
</div>
|
|
||||||
<div class="checkrow">
|
|
||||||
<input id="roomPersist" type="checkbox" />
|
|
||||||
<label for="roomPersist">🗂 persistente (historial)</label>
|
|
||||||
</div>
|
|
||||||
<div class="help" style="margin:-4px 0 8px; font-size:12px; color:var(--muted)">
|
|
||||||
persistente = quien se une despues ve el historial; sin persistir = solo mensajes nuevos (NATS simple).
|
|
||||||
</div>
|
|
||||||
<button id="createRoomBtn" disabled>Create room</button>
|
|
||||||
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
|
|
||||||
<label>Join by room_id</label>
|
|
||||||
<input id="joinRoomId" type="text" placeholder="01J..." autocomplete="off" />
|
|
||||||
<button id="joinBtn" class="ghost" disabled>Join</button>
|
|
||||||
<div id="roomStatus" class="status"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>3 · Action</h2>
|
|
||||||
<label>Active room</label>
|
|
||||||
<select id="activeRoom"></select>
|
|
||||||
<label>Message</label>
|
|
||||||
<div class="row">
|
|
||||||
<input id="msgText" type="text" placeholder="hello bus" autocomplete="off" />
|
|
||||||
<button id="sendBtn" style="flex:0 0 auto" disabled>Send</button>
|
|
||||||
</div>
|
|
||||||
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
|
|
||||||
<label>Target peer</label>
|
|
||||||
<div class="row">
|
|
||||||
<select id="targetPeer"></select>
|
|
||||||
<button id="refreshPeersBtn" class="ghost" style="flex:0 0 auto" title="reload peer list">↻</button>
|
|
||||||
</div>
|
|
||||||
<button id="inviteBtn" disabled>Invite to this room</button>
|
|
||||||
<button id="kickBtn" class="danger" disabled>Kick from this room</button>
|
|
||||||
<div id="actionStatus" class="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: live messages + help -->
|
|
||||||
<div class="col">
|
|
||||||
<div class="card" style="padding-bottom:8px">
|
|
||||||
<h2>Live messages <span id="streamPill" class="pill">disconnected</span></h2>
|
|
||||||
<div id="log"></div>
|
|
||||||
</div>
|
|
||||||
<div class="help">
|
|
||||||
<b>ⓘ How to try it</b><br />
|
|
||||||
Open <b>2 tabs</b>. Connect as <code>alice</code> in one and <code>bob</code> in the other.
|
|
||||||
In alice: create a <code>🔒 encrypted</code> room, copy the <code>room_id</code>,
|
|
||||||
then pick <code>bob</code> as target and <b>Invite to this room</b>.
|
|
||||||
In bob: paste that <code>room_id</code> and <b>Join</b>.
|
|
||||||
Type in both → messages appear live on each side.
|
|
||||||
In alice: <b>Kick</b> bob → bob stops seeing new messages (forward secrecy: the room
|
|
||||||
key rotates and bob no longer holds it).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BENCHMARK: full-width performance simulator -->
|
|
||||||
<div style="padding: 0 20px 32px; max-width: 1200px;">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Benchmark de rendimiento · 1 publisher → N subscribers</h2>
|
|
||||||
<div style="display:flex; gap:26px; flex-wrap:wrap; align-items:flex-end; margin-bottom:6px;">
|
|
||||||
<div style="min-width:230px;">
|
|
||||||
<label>Mensajes a publicar · <span id="bMsgsVal" style="color:var(--fg)">20 000</span></label>
|
|
||||||
<input id="bMsgs" type="range" min="1000" max="200000" step="1000" value="20000" style="width:100%; accent-color:var(--accent);" />
|
|
||||||
</div>
|
|
||||||
<div style="min-width:160px;">
|
|
||||||
<label>Subscribers · <span id="bSubsVal" style="color:var(--fg)">3</span></label>
|
|
||||||
<input id="bSubs" type="range" min="1" max="16" step="1" value="3" style="width:100%; accent-color:var(--accent);" />
|
|
||||||
</div>
|
|
||||||
<div style="min-width:200px;">
|
|
||||||
<label>Tamaño payload · <span id="bPayVal" style="color:var(--fg)">128 B</span></label>
|
|
||||||
<input id="bPay" type="range" min="16" max="8192" step="16" value="128" style="width:100%; accent-color:var(--accent);" />
|
|
||||||
</div>
|
|
||||||
<div class="checkrow" style="margin:0;">
|
|
||||||
<input id="bPersist" type="checkbox" />
|
|
||||||
<label for="bPersist">🗂 JetStream (persistente)</label>
|
|
||||||
</div>
|
|
||||||
<div class="checkrow" style="margin:0;">
|
|
||||||
<input id="bEncrypt" type="checkbox" />
|
|
||||||
<label for="bEncrypt">🔒 Encriptación E2E</label>
|
|
||||||
</div>
|
|
||||||
<button id="bRun" style="margin:0;">▶ Ejecutar benchmark</button>
|
|
||||||
</div>
|
|
||||||
<div class="help" style="margin:6px 0 12px;">
|
|
||||||
<b>JetStream</b> y <b>Encriptación</b> son ejes independientes: NATS core (ambos off) · JetStream durable · E2E (AEAD + firma Ed25519 por mensaje) · E2E + JetStream. Los modos con cripto o persistencia se limitan a 30 000 mensajes (cada mensaje paga cifrado/firma/ack).
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:30px; flex-wrap:wrap; margin:4px 2px 8px;">
|
|
||||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Enviados</div><div id="bSent" style="font-size:22px; color:var(--accent);">0</div></div>
|
|
||||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Recibidos (Σ subs)</div><div id="bRecv" style="font-size:22px; color:var(--green);">0</div></div>
|
|
||||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Throughput recv</div><div id="bTps" style="font-size:22px; color:var(--gold);">0</div></div>
|
|
||||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Tiempo</div><div id="bTime" style="font-size:22px;">0.00 s</div></div>
|
|
||||||
</div>
|
|
||||||
<canvas id="bChart" style="width:100%; height:300px; display:block; background:#08090c; border:1px solid var(--border); border-radius:8px;"></canvas>
|
|
||||||
<div style="display:flex; gap:18px; font-size:12px; color:var(--muted); margin-top:6px;">
|
|
||||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--accent);margin-right:6px;"></span>enviados (publisher)</span>
|
|
||||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--green);margin-right:6px;"></span>recibidos (suma de subscribers)</span>
|
|
||||||
</div>
|
|
||||||
<div id="bStatus" class="status" style="margin-top:8px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
peer: null, // connected peer name
|
|
||||||
rooms: {}, // room_id -> {subject, encrypt}
|
|
||||||
es: null, // EventSource
|
|
||||||
};
|
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
async function api(path, body) {
|
|
||||||
const opts = { method: "POST", headers: { "Content-Type": "application/json" } };
|
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
||||||
const res = await fetch(path, opts);
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
async function apiGet(path) {
|
|
||||||
const res = await fetch(path);
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(id, msg, kind) {
|
|
||||||
const el = $(id);
|
|
||||||
el.textContent = msg || "";
|
|
||||||
el.className = "status" + (kind ? " " + kind : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function short(s, n = 10) {
|
|
||||||
if (!s) return "";
|
|
||||||
return s.length <= n * 2 ? s : s.slice(0, n) + "…" + s.slice(-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hhmmss(ms) {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const p = (x) => String(x).padStart(2, "0");
|
|
||||||
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
||||||
}
|
|
||||||
|
|
||||||
function logSys(text, cls) {
|
|
||||||
const log = $("log");
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.className = "msg " + (cls || "sys");
|
|
||||||
div.textContent = text;
|
|
||||||
log.appendChild(div);
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logMsg(ev) {
|
|
||||||
const log = $("log");
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.className = "msg";
|
|
||||||
const enc = ev.encrypted
|
|
||||||
? '<span class="enc">🔒</span>'
|
|
||||||
: '<span class="clear">clear</span>';
|
|
||||||
div.innerHTML =
|
|
||||||
'<span class="subj">[' + escapeHtml(ev.subject) + ']</span> ' +
|
|
||||||
'<span class="from">' + escapeHtml(short(ev.sender)) + '</span> ↦ ' +
|
|
||||||
escapeHtml(ev.text) +
|
|
||||||
' <span class="meta">· ' + hhmmss(ev.ts) + ' · ' + enc + '</span>';
|
|
||||||
log.appendChild(div);
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
||||||
}[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshRoomSelect() {
|
|
||||||
const sel = $("activeRoom");
|
|
||||||
const cur = sel.value;
|
|
||||||
sel.innerHTML = "";
|
|
||||||
for (const [id, info] of Object.entries(state.rooms)) {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = id;
|
|
||||||
opt.textContent = info.subject + " (" + short(id, 6) + ")" + (info.encrypt ? " 🔒" : "");
|
|
||||||
sel.appendChild(opt);
|
|
||||||
}
|
|
||||||
if (state.rooms[cur]) sel.value = cur;
|
|
||||||
const has = Object.keys(state.rooms).length > 0;
|
|
||||||
$("sendBtn").disabled = !has;
|
|
||||||
$("inviteBtn").disabled = !has;
|
|
||||||
$("kickBtn").disabled = !has;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPeers() {
|
|
||||||
try {
|
|
||||||
const peers = await apiGet("/api/peers");
|
|
||||||
const sel = $("targetPeer");
|
|
||||||
const cur = sel.value;
|
|
||||||
sel.innerHTML = "";
|
|
||||||
for (const p of peers) {
|
|
||||||
if (p.name === state.peer) continue; // don't target yourself
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = p.name;
|
|
||||||
opt.textContent = p.name + " (" + short(p.endpoint_id, 6) + ")";
|
|
||||||
sel.appendChild(opt);
|
|
||||||
}
|
|
||||||
if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
|
|
||||||
} catch (e) {
|
|
||||||
setStatus("actionStatus", "peers: " + e.message, "bad");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openStream(name) {
|
|
||||||
if (state.es) state.es.close();
|
|
||||||
const es = new EventSource("/api/stream?peer=" + encodeURIComponent(name));
|
|
||||||
es.onopen = () => {
|
|
||||||
$("streamPill").textContent = "live: " + name;
|
|
||||||
$("streamPill").className = "pill on";
|
|
||||||
};
|
|
||||||
es.onmessage = (e) => {
|
|
||||||
try { logMsg(JSON.parse(e.data)); } catch (_) {}
|
|
||||||
};
|
|
||||||
es.onerror = () => {
|
|
||||||
$("streamPill").textContent = "reconnecting…";
|
|
||||||
$("streamPill").className = "pill";
|
|
||||||
};
|
|
||||||
state.es = es;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- handlers ----
|
|
||||||
|
|
||||||
$("connectBtn").onclick = async () => {
|
|
||||||
const name = $("peerName").value.trim();
|
|
||||||
if (!name) { setStatus("connStatus", "enter a name", "bad"); return; }
|
|
||||||
try {
|
|
||||||
const res = await api("/api/peer", { name });
|
|
||||||
state.peer = res.name;
|
|
||||||
state.rooms = {};
|
|
||||||
refreshRoomSelect();
|
|
||||||
$("peerIdent").innerHTML =
|
|
||||||
'endpoint: ' + escapeHtml(res.endpoint_id) +
|
|
||||||
' <span class="copy" id="copyId">copy</span>';
|
|
||||||
$("copyId").onclick = () => navigator.clipboard.writeText(res.endpoint_id);
|
|
||||||
setStatus("connStatus", "connected as " + res.name, "ok");
|
|
||||||
$("createRoomBtn").disabled = false;
|
|
||||||
$("joinBtn").disabled = false;
|
|
||||||
$("log").innerHTML = "";
|
|
||||||
logSys("connected as " + res.name + " — listening for messages");
|
|
||||||
openStream(res.name);
|
|
||||||
refreshPeers();
|
|
||||||
} catch (e) {
|
|
||||||
setStatus("connStatus", e.message, "bad");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$("createRoomBtn").onclick = async () => {
|
|
||||||
const subject = $("roomSubject").value.trim();
|
|
||||||
const encrypt = $("roomEncrypt").checked;
|
|
||||||
const persist = $("roomPersist").checked;
|
|
||||||
if (!subject) { setStatus("roomStatus", "subject required", "bad"); return; }
|
|
||||||
try {
|
|
||||||
const res = await api("/api/room", { peer: state.peer, subject, encrypt, persist });
|
|
||||||
state.rooms[res.room_id] = { subject: res.subject, encrypt: res.encrypt };
|
|
||||||
refreshRoomSelect();
|
|
||||||
$("activeRoom").value = res.room_id;
|
|
||||||
setStatus("roomStatus", "created " + res.room_id + " (click to copy)", "ok");
|
|
||||||
$("roomStatus").style.cursor = "pointer";
|
|
||||||
$("roomStatus").onclick = () => navigator.clipboard.writeText(res.room_id);
|
|
||||||
logSys("created room " + res.subject + " [" + short(res.room_id) + "]" + (encrypt ? " 🔒" : "") + (res.persist ? " 🗄" : ""));
|
|
||||||
} catch (e) {
|
|
||||||
setStatus("roomStatus", e.message, "bad");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$("joinBtn").onclick = async () => {
|
|
||||||
const roomId = $("joinRoomId").value.trim();
|
|
||||||
if (!roomId) { setStatus("roomStatus", "room_id required", "bad"); return; }
|
|
||||||
try {
|
|
||||||
const res = await api("/api/join", { peer: state.peer, room_id: roomId });
|
|
||||||
state.rooms[roomId] = { subject: res.subject, encrypt: res.encrypt };
|
|
||||||
refreshRoomSelect();
|
|
||||||
$("activeRoom").value = roomId;
|
|
||||||
setStatus("roomStatus", "joined " + res.subject + (res.encrypt ? " 🔒" : ""), "ok");
|
|
||||||
logSys("joined room " + res.subject + " [" + short(roomId) + "]");
|
|
||||||
} catch (e) {
|
|
||||||
setStatus("roomStatus", e.message, "bad");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$("sendBtn").onclick = async () => {
|
|
||||||
const roomId = $("activeRoom").value;
|
|
||||||
const text = $("msgText").value;
|
|
||||||
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
|
||||||
try {
|
|
||||||
await api("/api/publish", { peer: state.peer, room_id: roomId, text });
|
|
||||||
$("msgText").value = "";
|
|
||||||
setStatus("actionStatus", "sent", "ok");
|
|
||||||
} catch (e) {
|
|
||||||
setStatus("actionStatus", e.message, "bad");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$("msgText").addEventListener("keydown", (e) => { if (e.key === "Enter") $("sendBtn").click(); });
|
|
||||||
|
|
||||||
$("inviteBtn").onclick = async () => {
|
|
||||||
const roomId = $("activeRoom").value;
|
|
||||||
const target = $("targetPeer").value;
|
|
||||||
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
|
||||||
if (!target) { setStatus("actionStatus", "no target peer (connect another peer first)", "bad"); return; }
|
|
||||||
try {
|
|
||||||
await api("/api/invite", { peer: state.peer, room_id: roomId, target });
|
|
||||||
setStatus("actionStatus", "invited " + target, "ok");
|
|
||||||
logSys("invited " + target + " to " + short(roomId));
|
|
||||||
} catch (e) {
|
|
||||||
setStatus("actionStatus", e.message, "bad");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$("kickBtn").onclick = async () => {
|
|
||||||
const roomId = $("activeRoom").value;
|
|
||||||
const target = $("targetPeer").value;
|
|
||||||
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
|
||||||
if (!target) { setStatus("actionStatus", "no target peer", "bad"); return; }
|
|
||||||
try {
|
|
||||||
await api("/api/kick", { peer: state.peer, room_id: roomId, target });
|
|
||||||
setStatus("actionStatus", "kicked " + target + " (key rotated)", "ok");
|
|
||||||
logSys("kicked " + target + " from " + short(roomId) + " — key rotated (forward secrecy)");
|
|
||||||
} catch (e) {
|
|
||||||
setStatus("actionStatus", e.message, "bad");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$("refreshPeersBtn").onclick = refreshPeers;
|
|
||||||
$("peerName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("connectBtn").click(); });
|
|
||||||
|
|
||||||
// ---- benchmark ----
|
|
||||||
const fmtN = (n) => Number(n).toLocaleString("es-ES");
|
|
||||||
const bMsgs = $("bMsgs"), bSubs = $("bSubs"), bPay = $("bPay");
|
|
||||||
bMsgs.oninput = () => $("bMsgsVal").textContent = fmtN(+bMsgs.value);
|
|
||||||
bSubs.oninput = () => $("bSubsVal").textContent = bSubs.value;
|
|
||||||
bPay.oninput = () => $("bPayVal").textContent = fmtN(+bPay.value) + " B";
|
|
||||||
|
|
||||||
let bSamples = [], bRunning = false, bES = null;
|
|
||||||
const bCanvas = $("bChart"), bCtx = bCanvas.getContext("2d");
|
|
||||||
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
|
||||||
|
|
||||||
function bResize() {
|
|
||||||
const dpr = window.devicePixelRatio || 1, r = bCanvas.getBoundingClientRect();
|
|
||||||
bCanvas.width = r.width * dpr; bCanvas.height = r.height * dpr;
|
|
||||||
bCtx.setTransform(dpr, 0, 0, dpr, 0, 0); bDraw();
|
|
||||||
}
|
|
||||||
window.addEventListener("resize", bResize);
|
|
||||||
|
|
||||||
function bDraw() {
|
|
||||||
const r = bCanvas.getBoundingClientRect(), W = r.width, H = r.height;
|
|
||||||
const padL = 70, padR = 14, padT = 12, padB = 26;
|
|
||||||
bCtx.clearRect(0, 0, W, H);
|
|
||||||
const tMax = bSamples.length ? Math.max(bSamples[bSamples.length - 1].t, 0.001) : 1;
|
|
||||||
const yMax = bSamples.length ? Math.max(...bSamples.map(s => Math.max(s.sent, s.recv)), 1) : 1;
|
|
||||||
bCtx.strokeStyle = "#2b333f"; bCtx.fillStyle = "#8b98a5"; bCtx.font = "11px ui-monospace";
|
|
||||||
for (let i = 0; i <= 5; i++) {
|
|
||||||
const yy = (H - padB) - (i / 5) * (H - padT - padB);
|
|
||||||
bCtx.beginPath(); bCtx.moveTo(padL, yy); bCtx.lineTo(W - padR, yy); bCtx.stroke();
|
|
||||||
bCtx.textAlign = "right"; bCtx.fillText(fmtN(Math.round((i / 5) * yMax)), padL - 8, yy + 3);
|
|
||||||
}
|
|
||||||
bCtx.textAlign = "center";
|
|
||||||
bCtx.fillText("0 s", padL, H - padB + 15);
|
|
||||||
bCtx.fillText(tMax.toFixed(2) + " s", W - padR, H - padB + 15);
|
|
||||||
if (bSamples.length < 2) return;
|
|
||||||
const x = (t) => padL + (t / tMax) * (W - padL - padR);
|
|
||||||
const y = (v) => (H - padB) - (v / yMax) * (H - padT - padB);
|
|
||||||
const line = (key, color) => {
|
|
||||||
bCtx.beginPath(); bCtx.lineWidth = 2.2; bCtx.strokeStyle = color;
|
|
||||||
bSamples.forEach((s, i) => { const px = x(s.t), py = y(s[key]); i ? bCtx.lineTo(px, py) : bCtx.moveTo(px, py); });
|
|
||||||
bCtx.stroke();
|
|
||||||
};
|
|
||||||
line("sent", cssVar("--accent"));
|
|
||||||
line("recv", cssVar("--green"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function bSetRunning(v) { bRunning = v; $("bRun").disabled = v; }
|
|
||||||
|
|
||||||
$("bRun").onclick = () => {
|
|
||||||
if (bRunning) return;
|
|
||||||
bSamples = []; bSetRunning(true);
|
|
||||||
$("bSent").textContent = "0"; $("bRecv").textContent = "0"; $("bTps").textContent = "0"; $("bTime").textContent = "0.00 s";
|
|
||||||
setStatus("bStatus", "conectando…");
|
|
||||||
const qs = new URLSearchParams({
|
|
||||||
n_msgs: bMsgs.value, n_subs: bSubs.value, payload: bPay.value,
|
|
||||||
encrypt: $("bEncrypt").checked ? "1" : "0", persist: $("bPersist").checked ? "1" : "0",
|
|
||||||
});
|
|
||||||
const es = new EventSource("/api/bench?" + qs.toString());
|
|
||||||
bES = es;
|
|
||||||
const finish = () => { try { es.close(); } catch (_) {} bSetRunning(false); };
|
|
||||||
es.addEventListener("end", finish);
|
|
||||||
es.onmessage = (e) => {
|
|
||||||
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
|
|
||||||
if (m.type === "start") {
|
|
||||||
setStatus("bStatus",
|
|
||||||
"corriendo… " + fmtN(m.n_msgs) + " msgs → " + m.n_subs + " subs · payload " + fmtN(m.payload) + "B"
|
|
||||||
+ (m.encrypt ? " · \u{1F512} E2E" : "") + (m.persist ? " · \u{1F5C4} JetStream" : "")
|
|
||||||
+ (m.capped ? " · (limitado a 30k)" : ""), "");
|
|
||||||
} else if (m.type === "sample") {
|
|
||||||
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
|
|
||||||
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv); $("bTime").textContent = m.t.toFixed(2) + " s";
|
|
||||||
if (bSamples.length >= 2) {
|
|
||||||
const a = bSamples[bSamples.length - 2], b = bSamples[bSamples.length - 1], dt = b.t - a.t;
|
|
||||||
if (dt > 0) $("bTps").textContent = fmtN(Math.round((b.recv - a.recv) / dt));
|
|
||||||
}
|
|
||||||
bDraw();
|
|
||||||
} else if (m.type === "done") {
|
|
||||||
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
|
|
||||||
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv);
|
|
||||||
$("bTps").textContent = fmtN(m.recv_tps); $("bTime").textContent = m.t.toFixed(2) + " s";
|
|
||||||
setStatus("bStatus",
|
|
||||||
"✓ " + m.t.toFixed(2) + "s · pub " + fmtN(m.pub_tps) + "/s · recv " + fmtN(m.recv_tps) + "/s · fan-out ×"
|
|
||||||
+ m.n_subs + " · por sub [" + (m.per_sub || []).map(fmtN).join(", ") + "]", "ok");
|
|
||||||
bDraw(); finish();
|
|
||||||
} else if (m.type === "error") {
|
|
||||||
setStatus("bStatus", "error: " + m.msg, "bad"); finish();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
es.onerror = () => { if (bRunning) { setStatus("bStatus", "conexión SSE perdida", "bad"); finish(); } };
|
|
||||||
};
|
|
||||||
|
|
||||||
bResize();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,933 +0,0 @@
|
|||||||
// Command playground is an all-in-one, web-based sandbox for the unibus message
|
|
||||||
// bus. A single `go run ./playground` launches the entire stack embedded:
|
|
||||||
//
|
|
||||||
// - an embedded NATS server with JetStream (the data plane),
|
|
||||||
// - the membership control plane (rooms, members, sealed keys, rekey) over an
|
|
||||||
// internal HTTP server,
|
|
||||||
// - the media blob store, and
|
|
||||||
// - a browser-facing web UI on :7700.
|
|
||||||
//
|
|
||||||
// The browser never speaks NATS. The Go server is the actual bus peer: it holds
|
|
||||||
// one unibus client per named peer, subscribes to rooms on the peer's behalf,
|
|
||||||
// and streams received messages to the browser over Server-Sent Events. The
|
|
||||||
// browser drives everything with plain fetch() + EventSource() — no build step,
|
|
||||||
// no JS framework, no external libraries.
|
|
||||||
//
|
|
||||||
// This is a playground (see .claude/rules/playgrounds.md): it lives inside the
|
|
||||||
// unibus app, reuses the parent module (no new go.mod), is not indexed, and
|
|
||||||
// stores ephemeral state under playground/local_files/.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "embed"
|
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
|
||||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
|
||||||
"github.com/enmanuel/unibus/pkg/client"
|
|
||||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
|
||||||
"github.com/enmanuel/unibus/pkg/membership"
|
|
||||||
"github.com/enmanuel/unibus/pkg/room"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fixed ports (verified free before assignment — do not change without reason).
|
|
||||||
const (
|
|
||||||
webAddr = "127.0.0.1:7700" // browser-facing web UI
|
|
||||||
ctrlAddr = "127.0.0.1:8480" // internal membership control plane
|
|
||||||
ctrlURL = "http://" + ctrlAddr
|
|
||||||
natsPort = 4260 // internal embedded NATS
|
|
||||||
natsURL = "nats://127.0.0.1:4260"
|
|
||||||
localFiles = "playground/local_files"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed index.html
|
|
||||||
var indexHTML []byte
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Event: a message received by a peer on one of its subscribed rooms. Fanned
|
|
||||||
// out to every SSE listener attached to that peer.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Event struct {
|
|
||||||
RoomID string `json:"room_id"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Sender string `json:"sender"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Encrypted bool `json:"encrypted"`
|
|
||||||
TS int64 `json:"ts"` // unix millis
|
|
||||||
}
|
|
||||||
|
|
||||||
// roomInfo caches the per-room metadata a peer needs to label incoming frames.
|
|
||||||
type roomInfo struct {
|
|
||||||
subject string
|
|
||||||
encrypt bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// peerState holds everything about one named peer: its bus client, its public
|
|
||||||
// endpoint, its live subscriptions, the rooms it knows, and the set of SSE
|
|
||||||
// listener channels currently attached to it.
|
|
||||||
type peerState struct {
|
|
||||||
name string
|
|
||||||
client *client.Client
|
|
||||||
endpoint client.Endpoint
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
subs map[string]*client.Sub // roomID -> subscription
|
|
||||||
rooms map[string]roomInfo // roomID -> subject/encrypt
|
|
||||||
listeners map[chan Event]struct{} // attached SSE channels
|
|
||||||
}
|
|
||||||
|
|
||||||
// emit fans an event out to all attached listeners without blocking on a slow
|
|
||||||
// or disconnected consumer.
|
|
||||||
func (p *peerState) emit(ev Event) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for ch := range p.listeners {
|
|
||||||
select {
|
|
||||||
case ch <- ev:
|
|
||||||
default: // listener buffer full: drop rather than block the NATS callback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *peerState) addListener(ch chan Event) {
|
|
||||||
p.mu.Lock()
|
|
||||||
p.listeners[ch] = struct{}{}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *peerState) removeListener(ch chan Event) {
|
|
||||||
p.mu.Lock()
|
|
||||||
delete(p.listeners, ch)
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *peerState) setRoom(roomID string, info roomInfo) {
|
|
||||||
p.mu.Lock()
|
|
||||||
p.rooms[roomID] = info
|
|
||||||
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.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Hub struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
peers map[string]*peerState
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHub() *Hub { return &Hub{peers: map[string]*peerState{}} }
|
|
||||||
|
|
||||||
// getOrCreate returns the peer for name, creating its identity + bus client on
|
|
||||||
// first use. Identities persist to playground/local_files/<name>.id so a peer
|
|
||||||
// keeps the same endpoint across restarts.
|
|
||||||
func (h *Hub) getOrCreate(name string) (*peerState, error) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
if p, ok := h.peers[name]; ok {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
idPath := filepath.Join(localFiles, name+".id")
|
|
||||||
id, err := client.LoadOrCreateIdentity(idPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("identity for %q: %w", name, err)
|
|
||||||
}
|
|
||||||
c, err := client.New(natsURL, ctrlURL, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("client for %q: %w", name, err)
|
|
||||||
}
|
|
||||||
p := &peerState{
|
|
||||||
name: name,
|
|
||||||
client: c,
|
|
||||||
endpoint: c.Endpoint(),
|
|
||||||
subs: map[string]*client.Sub{},
|
|
||||||
rooms: map[string]roomInfo{},
|
|
||||||
listeners: map[chan Event]struct{}{},
|
|
||||||
}
|
|
||||||
h.peers[name] = p
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookup returns an already-created peer or false.
|
|
||||||
func (h *Hub) lookup(name string) (*peerState, bool) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
p, ok := h.peers[name]
|
|
||||||
return p, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// list returns a snapshot of all peers (name + endpoint id).
|
|
||||||
func (h *Hub) list() []map[string]string {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
out := make([]map[string]string, 0, len(h.peers))
|
|
||||||
for name, p := range h.peers {
|
|
||||||
out = append(out, map[string]string{"name": name, "endpoint_id": p.endpoint.ID})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) closeAll() {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
for _, p := range h.peers {
|
|
||||||
p.mu.Lock()
|
|
||||||
for _, sub := range p.subs {
|
|
||||||
_ = sub.Unsubscribe()
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
_ = p.client.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribeRoom subscribes the peer to a room (idempotent) and wires the frame
|
|
||||||
// handler to fan incoming messages out as Events. info labels each event with
|
|
||||||
// the room's subject and encryption flag.
|
|
||||||
func (p *peerState) subscribeRoom(roomID string, info roomInfo) error {
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, already := p.subs[roomID]; already {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
sub, err := p.client.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
|
||||||
p.emit(Event{
|
|
||||||
RoomID: roomID,
|
|
||||||
Subject: info.subject,
|
|
||||||
Sender: f.Sender,
|
|
||||||
Text: string(plaintext),
|
|
||||||
Encrypted: info.encrypt,
|
|
||||||
TS: time.Now().UnixMilli(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("subscribe room %s: %w", roomID, err)
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
p.subs[roomID] = sub
|
|
||||||
p.mu.Unlock()
|
|
||||||
p.setRoom(roomID, info)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Control-plane helper: fetch a room's subject + policy from membershipd. The
|
|
||||||
// client package keeps fetchRoom private, so the playground talks to the
|
|
||||||
// control plane directly (read endpoints are unauthenticated by design).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type ctrlRoomResp struct {
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Epoch int `json:"epoch"`
|
|
||||||
Policy struct {
|
|
||||||
Encrypt bool `json:"encrypt"`
|
|
||||||
Persist bool `json:"persist"`
|
|
||||||
SignMsgs bool `json:"sign_msgs"`
|
|
||||||
} `json:"policy"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchRoomInfo(roomID string) (roomInfo, error) {
|
|
||||||
resp, err := http.Get(ctrlURL + "/rooms/" + roomID)
|
|
||||||
if err != nil {
|
|
||||||
return roomInfo{}, fmt.Errorf("fetch room %s: %w", roomID, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
return roomInfo{}, fmt.Errorf("room %s not found (status %d)", roomID, resp.StatusCode)
|
|
||||||
}
|
|
||||||
var r ctrlRoomResp
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
|
||||||
return roomInfo{}, fmt.Errorf("decode room %s: %w", roomID, err)
|
|
||||||
}
|
|
||||||
return roomInfo{subject: r.Subject, encrypt: r.Policy.Encrypt}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTTP handlers (web UI on :7700).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
|
||||||
writeJSON(w, code, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeBody(r *http.Request, out any) error {
|
|
||||||
defer r.Body.Close()
|
|
||||||
return json.NewDecoder(r.Body).Decode(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_, _ = w.Write(indexHTML)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handlePeer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := decodeBody(r, &req); err != nil || req.Name == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "name required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, err := h.getOrCreate(req.Name)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"name": p.name, "endpoint_id": p.endpoint.ID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handlePeers(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, h.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handleRoom(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Peer string `json:"peer"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Encrypt bool `json:"encrypt"`
|
|
||||||
Persist bool `json:"persist"`
|
|
||||||
}
|
|
||||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.Subject == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "peer and subject required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, ok := h.lookup(req.Peer)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// The two checkboxes map to an explicit per-room policy. encrypt drives both
|
|
||||||
// encryption and per-message signing; persist (default false) independently
|
|
||||||
// toggles durable JetStream history. persist=false keeps plain ephemeral NATS.
|
|
||||||
policy := room.Policy{Encrypt: req.Encrypt, Persist: req.Persist, SignMsgs: req.Encrypt}
|
|
||||||
roomID, err := p.client.CreateRoom(req.Subject, policy)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
info := roomInfo{subject: req.Subject, encrypt: req.Encrypt}
|
|
||||||
if err := p.subscribeRoom(roomID, info); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"room_id": roomID, "subject": req.Subject, "encrypt": req.Encrypt, "persist": req.Persist,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handleJoin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Peer string `json:"peer"`
|
|
||||||
RoomID string `json:"room_id"`
|
|
||||||
}
|
|
||||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "peer and room_id required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, ok := h.lookup(req.Peer)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := p.client.Join(req.RoomID); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "join failed: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
info, err := fetchRoomInfo(req.RoomID)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := p.subscribeRoom(req.RoomID, info); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"subject": info.subject, "encrypt": info.encrypt})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handleInvite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Peer string `json:"peer"`
|
|
||||||
RoomID string `json:"room_id"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
}
|
|
||||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "peer, room_id and target required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, ok := h.lookup(req.Peer)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target, ok := h.lookup(req.Target)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist; connect it first")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := p.client.Invite(req.RoomID, target.endpoint); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "invited", "target": req.Target})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handlePublish(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Peer string `json:"peer"`
|
|
||||||
RoomID string `json:"room_id"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "peer and room_id required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, ok := h.lookup(req.Peer)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := p.client.Publish(req.RoomID, []byte(req.Text)); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "published"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) handleKick(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Peer string `json:"peer"`
|
|
||||||
RoomID string `json:"room_id"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
}
|
|
||||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "peer, room_id and target required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, ok := h.lookup(req.Peer)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target, ok := h.lookup(req.Target)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := p.client.Kick(req.RoomID, target.endpoint.ID); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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=<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: <json>\n\n` block. The listener is
|
|
||||||
// cleaned up when the HTTP request context is cancelled (tab closed / reload).
|
|
||||||
func (h *Hub) handleStream(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
|
|
||||||
}
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
|
|
||||||
ch := make(chan Event, 64)
|
|
||||||
p.addListener(ch)
|
|
||||||
defer p.removeListener(ch)
|
|
||||||
|
|
||||||
// Initial comment so the browser marks the stream open immediately.
|
|
||||||
fmt.Fprintf(w, ": connected to %s\n\n", name)
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
ping := time.NewTicker(20 * time.Second)
|
|
||||||
defer ping.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ping.C:
|
|
||||||
fmt.Fprintf(w, ": ping\n\n")
|
|
||||||
flusher.Flush()
|
|
||||||
case ev := <-ch:
|
|
||||||
b, err := json.Marshal(ev)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Benchmark: one publisher floods a room with thousands of messages that N
|
|
||||||
// subscribers receive. The two policy axes are exposed as independent flags:
|
|
||||||
// encrypt (AEAD payload + Ed25519 per-message signature) and persist (durable
|
|
||||||
// JetStream history vs ephemeral core NATS). Payload size is configurable. The
|
|
||||||
// benchmark uses its own ephemeral peers (not the hub's named peers) so it never
|
|
||||||
// interferes with the manual sandbox, and streams progress samples over SSE so
|
|
||||||
// the browser can animate a live throughput chart.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// benchSample is one Server-Sent Event of a running benchmark.
|
|
||||||
type benchSample struct {
|
|
||||||
Type string `json:"type"` // "start" | "sample" | "done" | "error"
|
|
||||||
T float64 `json:"t"`
|
|
||||||
Sent int64 `json:"sent"`
|
|
||||||
Recv int64 `json:"recv"`
|
|
||||||
NMsgs int `json:"n_msgs,omitempty"`
|
|
||||||
NSubs int `json:"n_subs,omitempty"`
|
|
||||||
Payload int `json:"payload,omitempty"`
|
|
||||||
Encrypt bool `json:"encrypt,omitempty"`
|
|
||||||
Persist bool `json:"persist,omitempty"`
|
|
||||||
Capped bool `json:"capped,omitempty"`
|
|
||||||
PubTps int64 `json:"pub_tps,omitempty"`
|
|
||||||
RecvTps int64 `json:"recv_tps,omitempty"`
|
|
||||||
PerSub []int64 `json:"per_sub,omitempty"`
|
|
||||||
Msg string `json:"msg,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// runBench wires up one publisher + nSubs subscribers, publishes nMsgs payloads,
|
|
||||||
// and calls emit periodically with the running totals. emit is only ever called
|
|
||||||
// from the calling goroutine (the SSE handler), so it needs no locking.
|
|
||||||
func runBench(ctx context.Context, emit func(benchSample), nMsgs, nSubs, payloadBytes int, encrypt, persist bool) {
|
|
||||||
policy := room.Policy{Encrypt: encrypt, Persist: persist, SignMsgs: encrypt}
|
|
||||||
subject := fmt.Sprintf("bench.%d", time.Now().UnixNano())
|
|
||||||
|
|
||||||
newPeer := func() (*client.Client, error) {
|
|
||||||
id, err := cs.GenerateIdentity()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.New(natsURL, ctrlURL, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub, err := newPeer()
|
|
||||||
if err != nil {
|
|
||||||
emit(benchSample{Type: "error", Msg: "publisher: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer pub.Close()
|
|
||||||
|
|
||||||
roomID, err := pub.CreateRoom(subject, policy)
|
|
||||||
if err != nil {
|
|
||||||
emit(benchSample{Type: "error", Msg: "create room: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
counters := make([]int64, nSubs)
|
|
||||||
subClients := make([]*client.Client, 0, nSubs)
|
|
||||||
defer func() {
|
|
||||||
for _, c := range subClients {
|
|
||||||
_ = c.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// One room, N subscribers. For encrypted rooms each subscriber must be invited
|
|
||||||
// (sealed key) and join before subscribing; for cleartext rooms Subscribe on
|
|
||||||
// the shared roomID is enough.
|
|
||||||
for i := 0; i < nSubs; i++ {
|
|
||||||
c, err := newPeer()
|
|
||||||
if err != nil {
|
|
||||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscriber %d: %v", i, err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
subClients = append(subClients, c)
|
|
||||||
if encrypt {
|
|
||||||
if err := pub.Invite(roomID, c.Endpoint()); err != nil {
|
|
||||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("invite %d: %v", i, err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := c.Join(roomID); err != nil {
|
|
||||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("join %d: %v", i, err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
idx := i
|
|
||||||
if _, err := c.Subscribe(roomID, func(_ frame.Frame, _ []byte) {
|
|
||||||
atomic.AddInt64(&counters[idx], 1)
|
|
||||||
}); err != nil {
|
|
||||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscribe %d: %v", i, err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sumRecv := func() int64 {
|
|
||||||
var s int64
|
|
||||||
for i := range counters {
|
|
||||||
s += atomic.LoadInt64(&counters[i])
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := bytes.Repeat([]byte{'x'}, payloadBytes)
|
|
||||||
var sent int64
|
|
||||||
|
|
||||||
emit(benchSample{Type: "start", NMsgs: nMsgs, NSubs: nSubs, Payload: payloadBytes, Encrypt: encrypt, Persist: persist})
|
|
||||||
|
|
||||||
t0 := time.Now()
|
|
||||||
done := make(chan struct{})
|
|
||||||
var pubErr atomic.Value
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for k := 0; k < nMsgs; k++ {
|
|
||||||
if err := pub.Publish(roomID, payload); err != nil {
|
|
||||||
pubErr.Store(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
atomic.AddInt64(&sent, 1)
|
|
||||||
if k%256 == 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(60 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
deadline := time.After(120 * time.Second)
|
|
||||||
target := int64(nMsgs) * int64(nSubs)
|
|
||||||
|
|
||||||
sampleLoop:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-deadline:
|
|
||||||
break sampleLoop
|
|
||||||
case <-done:
|
|
||||||
break sampleLoop
|
|
||||||
case <-ticker.C:
|
|
||||||
emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := pubErr.Load(); v != nil {
|
|
||||||
emit(benchSample{Type: "error", Msg: "publish: " + v.(error).Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final drain: keep sampling until every subscriber has caught up (or we give up).
|
|
||||||
for i := 0; i < 240; i++ {
|
|
||||||
if sumRecv() >= target {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(25 * time.Millisecond):
|
|
||||||
}
|
|
||||||
emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()})
|
|
||||||
}
|
|
||||||
|
|
||||||
dur := time.Since(t0).Seconds()
|
|
||||||
finalSent := atomic.LoadInt64(&sent)
|
|
||||||
finalRecv := sumRecv()
|
|
||||||
per := make([]int64, nSubs)
|
|
||||||
for i := range counters {
|
|
||||||
per[i] = atomic.LoadInt64(&counters[i])
|
|
||||||
}
|
|
||||||
var pubTps, recvTps int64
|
|
||||||
if dur > 0 {
|
|
||||||
pubTps = int64(float64(finalSent) / dur)
|
|
||||||
recvTps = int64(float64(finalRecv) / dur)
|
|
||||||
}
|
|
||||||
emit(benchSample{Type: "done", T: dur, Sent: finalSent, Recv: finalRecv, PerSub: per, PubTps: pubTps, RecvTps: recvTps, NSubs: nSubs})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleBench is the SSE endpoint that drives a benchmark from query params:
|
|
||||||
//
|
|
||||||
// GET /api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0
|
|
||||||
//
|
|
||||||
// Encrypted/persistent runs are capped to a lower message count (the per-message
|
|
||||||
// crypto + JetStream ack make them far slower); the cap is reported in the start
|
|
||||||
// sample so the UI can show it.
|
|
||||||
func (h *Hub) handleBench(w http.ResponseWriter, r *http.Request) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
atoiDef := func(k string, def int) int {
|
|
||||||
if v, err := strconv.Atoi(q.Get(k)); err == nil {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
truthy := func(k string) bool { v := q.Get(k); return v == "1" || v == "true" }
|
|
||||||
|
|
||||||
nMsgs := atoiDef("n_msgs", 20000)
|
|
||||||
nSubs := atoiDef("n_subs", 3)
|
|
||||||
payload := atoiDef("payload", 128)
|
|
||||||
encrypt := truthy("encrypt")
|
|
||||||
persist := truthy("persist")
|
|
||||||
|
|
||||||
if nSubs < 1 {
|
|
||||||
nSubs = 1
|
|
||||||
} else if nSubs > 16 {
|
|
||||||
nSubs = 16
|
|
||||||
}
|
|
||||||
if payload < 1 {
|
|
||||||
payload = 1
|
|
||||||
} else if payload > 8192 {
|
|
||||||
payload = 8192
|
|
||||||
}
|
|
||||||
if nMsgs < 100 {
|
|
||||||
nMsgs = 100
|
|
||||||
}
|
|
||||||
maxMsgs := 200000
|
|
||||||
if encrypt || persist {
|
|
||||||
maxMsgs = 30000 // crypto + JetStream ack are much slower; keep the run bounded
|
|
||||||
}
|
|
||||||
capped := false
|
|
||||||
if nMsgs > maxMsgs {
|
|
||||||
nMsgs, capped = maxMsgs, true
|
|
||||||
}
|
|
||||||
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
fmt.Fprintf(w, ": bench start\n\n")
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
emit := func(s benchSample) {
|
|
||||||
if s.Type == "start" {
|
|
||||||
s.Capped = capped
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(s)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
runBench(r.Context(), emit, nMsgs, nSubs, payload, encrypt, persist)
|
|
||||||
fmt.Fprintf(w, "event: end\ndata: {}\n\n")
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// main: bring up NATS, control plane, and the web server; tear them all down
|
|
||||||
// cleanly on signal.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
|
||||||
log.SetPrefix("[playground] ")
|
|
||||||
|
|
||||||
if err := os.MkdirAll(localFiles, 0o755); err != nil {
|
|
||||||
log.Fatalf("mkdir %s: %v", localFiles, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Data plane: embedded NATS + JetStream on the fixed internal port.
|
|
||||||
ns, err := embeddednats.Start(filepath.Join(localFiles, "js"), natsPort)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("start embedded nats: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("embedded NATS (JetStream) ready: %s", embeddednats.ClientURL(ns))
|
|
||||||
|
|
||||||
// 2. Control plane: membership store + blob store + internal HTTP server.
|
|
||||||
store, err := membership.Open(filepath.Join(localFiles, "play.db"))
|
|
||||||
if err != nil {
|
|
||||||
ns.Shutdown()
|
|
||||||
log.Fatalf("open membership store: %v", err)
|
|
||||||
}
|
|
||||||
blobs, err := blobstore.New(filepath.Join(localFiles, "blobs"))
|
|
||||||
if err != nil {
|
|
||||||
store.Close()
|
|
||||||
ns.Shutdown()
|
|
||||||
log.Fatalf("open blob store: %v", err)
|
|
||||||
}
|
|
||||||
// AuthOff: the playground is a local dev gateway that has not migrated to
|
|
||||||
// signed control-plane requests or a secured upstream bus yet. What it would
|
|
||||||
// need is written up in dev/0001e-remaining-clients.md (issue 0001, phase 0001e).
|
|
||||||
ctrlSrv := &http.Server{Addr: ctrlAddr, Handler: membership.NewServer(store, blobs, membership.AuthOff)}
|
|
||||||
go func() {
|
|
||||||
if err := ctrlSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
log.Fatalf("control plane: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if err := waitHealthy(ctrlURL+"/healthz", 5*time.Second); err != nil {
|
|
||||||
log.Fatalf("control plane not healthy: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("control plane ready: %s", ctrlURL)
|
|
||||||
|
|
||||||
// 3. Web UI on :7700.
|
|
||||||
hub := newHub()
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/", hub.handleIndex)
|
|
||||||
mux.HandleFunc("POST /api/peer", hub.handlePeer)
|
|
||||||
mux.HandleFunc("GET /api/peers", hub.handlePeers)
|
|
||||||
mux.HandleFunc("POST /api/room", hub.handleRoom)
|
|
||||||
mux.HandleFunc("POST /api/join", hub.handleJoin)
|
|
||||||
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: withCORS(mux)}
|
|
||||||
go func() {
|
|
||||||
if err := webSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
log.Fatalf("web server: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
log.Printf("web UI ready: http://%s", webAddr)
|
|
||||||
log.Printf("open http://localhost:7700 in two browser tabs to try the bus")
|
|
||||||
|
|
||||||
// 4. Graceful shutdown.
|
|
||||||
stop := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-stop
|
|
||||||
log.Printf("shutting down...")
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = webSrv.Shutdown(ctx)
|
|
||||||
hub.closeAll()
|
|
||||||
_ = ctrlSrv.Shutdown(ctx)
|
|
||||||
store.Close()
|
|
||||||
ns.Shutdown()
|
|
||||||
ns.WaitForShutdown()
|
|
||||||
log.Printf("bye")
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitHealthy polls url until it returns a 2xx/3xx or the deadline elapses.
|
|
||||||
func waitHealthy(url string, timeout time.Duration) error {
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
c := &http.Client{Timeout: 500 * time.Millisecond}
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
resp, err := c.Get(url)
|
|
||||||
if err == nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode < 400 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("timeout waiting for %s", url)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.local
|
|
||||||
.vite/
|
|
||||||
*.tsbuildinfo
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>unibus · chat</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-1481
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
esbuild: true
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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<Connection | null>(null);
|
|
||||||
|
|
||||||
if (!conn) {
|
|
||||||
return <ConnectScreen onConnect={(client, peer) => setConn({ client, peer })} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ChatLayout
|
|
||||||
client={conn.client}
|
|
||||||
peer={conn.peer}
|
|
||||||
onDisconnect={() => setConn(null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// 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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
||||||
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<Peer> {
|
|
||||||
return this.req<Peer>("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<Peer[]> {
|
|
||||||
return this.req<Peer[]>("GET", "/api/peers");
|
|
||||||
}
|
|
||||||
|
|
||||||
// rooms lists the rooms the named peer knows (created or joined).
|
|
||||||
rooms(peer: string): Promise<Room[]> {
|
|
||||||
return this.req<Room[]>("GET", `/api/rooms?peer=${encodeURIComponent(peer)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// members lists the participants of a room.
|
|
||||||
members(roomID: string): Promise<Member[]> {
|
|
||||||
return this.req<Member[]>("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<Room & { persist: boolean }> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
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<Room[]>([]);
|
|
||||||
const [activeRoom, setActiveRoom] = useState<string | null>(null);
|
|
||||||
const [messages, setMessages] = useState<Record<string, Message[]>>({});
|
|
||||||
const [peers, setPeers] = useState<Peer[]>([]);
|
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<AppShell
|
|
||||||
header={{ height: 60 }}
|
|
||||||
navbar={{ width: 300, breakpoint: "sm" }}
|
|
||||||
aside={{ width: 300, breakpoint: "md", collapsed: { desktop: !activeRoom, mobile: true } }}
|
|
||||||
padding={0}
|
|
||||||
>
|
|
||||||
<AppShell.Header>
|
|
||||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="violet" radius="md">
|
|
||||||
<IconBolt size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Title order={4}>unibus</Title>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Badge variant="light" color="violet" size="lg">
|
|
||||||
{peer.name}
|
|
||||||
</Badge>
|
|
||||||
<CopyButton value={peer.endpoint_id}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Tooltip label={copied ? "¡copiado!" : peer.endpoint_id} withArrow>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={copy}>
|
|
||||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
leftSection={<IconLogout size={16} />}
|
|
||||||
onClick={onDisconnect}
|
|
||||||
>
|
|
||||||
Salir
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</AppShell.Header>
|
|
||||||
|
|
||||||
<AppShell.Navbar>
|
|
||||||
<RoomList
|
|
||||||
rooms={rooms}
|
|
||||||
activeRoom={activeRoom}
|
|
||||||
onSelect={setActiveRoom}
|
|
||||||
onCreateRoom={onCreateRoom}
|
|
||||||
onJoinRoom={onJoinRoom}
|
|
||||||
/>
|
|
||||||
</AppShell.Navbar>
|
|
||||||
|
|
||||||
<AppShell.Main h="100vh">
|
|
||||||
{error && (
|
|
||||||
<Transition mounted={!!error} transition="slide-down">
|
|
||||||
{(styles) => (
|
|
||||||
<Alert
|
|
||||||
style={{ ...styles, position: "absolute", top: 70, left: "50%", transform: "translateX(-50%)", zIndex: 200, minWidth: 360 }}
|
|
||||||
color="red"
|
|
||||||
variant="filled"
|
|
||||||
icon={<IconAlertTriangle size={18} />}
|
|
||||||
withCloseButton
|
|
||||||
onClose={() => setError(null)}
|
|
||||||
title="Error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
)}
|
|
||||||
<MessagePane
|
|
||||||
room={activeRoomObj}
|
|
||||||
messages={activeRoom ? messages[activeRoom] ?? [] : []}
|
|
||||||
myEndpoint={peer.endpoint_id}
|
|
||||||
nameFor={nameFor}
|
|
||||||
onPublish={onPublish}
|
|
||||||
/>
|
|
||||||
</AppShell.Main>
|
|
||||||
|
|
||||||
<AppShell.Aside>
|
|
||||||
{activeRoomObj && (
|
|
||||||
<MembersPane
|
|
||||||
room={activeRoomObj}
|
|
||||||
members={members}
|
|
||||||
peers={peers}
|
|
||||||
myEndpoint={peer.endpoint_id}
|
|
||||||
iAmOwner={iAmOwner}
|
|
||||||
nameFor={nameFor}
|
|
||||||
onInvite={onInvite}
|
|
||||||
onKick={onKick}
|
|
||||||
onRefresh={() => activeRoom && refreshMembers(activeRoom)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AppShell.Aside>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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<string | null>(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 (
|
|
||||||
<Center h="100vh" p="md">
|
|
||||||
<Card withBorder shadow="md" radius="lg" p="xl" w={420} maw="100%">
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon size="xl" radius="md" variant="light" color="violet">
|
|
||||||
<IconBolt size={26} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Title order={3}>unibus</Title>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
chat cifrado extremo a extremo sobre NATS
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Gateway"
|
|
||||||
description="URL del gateway web de unibus"
|
|
||||||
placeholder="http://localhost:7700"
|
|
||||||
value={gateway}
|
|
||||||
onChange={(e) => setGateway(e.currentTarget.value)}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Identidad"
|
|
||||||
description="Tu nombre de peer en el bus (persistente)"
|
|
||||||
placeholder="ana"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
|
||||||
disabled={busy}
|
|
||||||
data-autofocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
icon={<IconAlertTriangle size={18} />}
|
|
||||||
title="No se pudo conectar"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
leftSection={<IconPlugConnected size={18} />}
|
|
||||||
onClick={connect}
|
|
||||||
loading={busy}
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
Conectar
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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<string | null>(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 (
|
|
||||||
<Stack gap={0} h="100%">
|
|
||||||
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconUsers size={18} />
|
|
||||||
<Text fw={600}>Miembros</Text>
|
|
||||||
<Badge size="sm" variant="light">
|
|
||||||
{members.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
<Tooltip label="Recargar" withArrow>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={onRefresh}>
|
|
||||||
<IconRefresh size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Box p="md">
|
|
||||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
|
|
||||||
Invitar {room.encrypt && "(reparte la clave)"}
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
|
||||||
<Select
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
size="xs"
|
|
||||||
placeholder="peer conectado"
|
|
||||||
data={candidates}
|
|
||||||
value={target}
|
|
||||||
onChange={setTarget}
|
|
||||||
searchable
|
|
||||||
nothingFoundMessage="sin peers libres"
|
|
||||||
comboboxProps={{ withinPortal: true }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
leftSection={<IconUserPlus size={14} />}
|
|
||||||
onClick={invite}
|
|
||||||
disabled={!target}
|
|
||||||
>
|
|
||||||
Invitar
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
|
||||||
<Stack gap={4} p="md">
|
|
||||||
{members.map((m) => {
|
|
||||||
const isMe = m.endpoint === myEndpoint;
|
|
||||||
const name = nameFor(m.endpoint);
|
|
||||||
const canKick = iAmOwner && !isMe && m.role !== "owner";
|
|
||||||
return (
|
|
||||||
<Group key={m.endpoint} justify="space-between" wrap="nowrap" gap="xs">
|
|
||||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Avatar size="sm" radius="xl" color="violet">
|
|
||||||
{name.slice(0, 2).toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text size="sm" fw={isMe ? 700 : 500} truncate>
|
|
||||||
{name} {isMe && "(tú)"}
|
|
||||||
</Text>
|
|
||||||
<Text size="9px" c="dimmed" truncate>
|
|
||||||
{m.endpoint}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
<Group gap={4} wrap="nowrap">
|
|
||||||
{m.role === "owner" && (
|
|
||||||
<Badge size="xs" color="yellow" variant="light">
|
|
||||||
owner
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{canKick && (
|
|
||||||
<Tooltip label="Expulsar (rota la clave)" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onKick(name)}
|
|
||||||
>
|
|
||||||
<IconUserMinus size={15} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to the newest message.
|
|
||||||
useEffect(() => {
|
|
||||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight, behavior: "smooth" });
|
|
||||||
}, [messages.length]);
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return (
|
|
||||||
<Center h="100%">
|
|
||||||
<Stack align="center" gap="xs">
|
|
||||||
<ThemeIcon size={64} radius="xl" variant="light" color="gray">
|
|
||||||
<IconMessages size={34} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text c="dimmed">Elige o crea una room para empezar a chatear</Text>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const send = () => {
|
|
||||||
const t = text.trim();
|
|
||||||
if (t) {
|
|
||||||
onPublish(t);
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={0} h="100%">
|
|
||||||
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
{room.encrypt ? <IconLock size={18} /> : <IconHash size={18} />}
|
|
||||||
<Text fw={600}>{room.subject}</Text>
|
|
||||||
{room.encrypt && (
|
|
||||||
<Badge size="sm" color="teal" variant="light">
|
|
||||||
cifrada E2E
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<CopyButton value={room.room_id}>
|
|
||||||
{({ copied, copy }) => (
|
|
||||||
<Tooltip label={copied ? "¡copiado!" : "copiar room id"} withArrow>
|
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={copy}>
|
|
||||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</CopyButton>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} p="md">
|
|
||||||
<Stack gap="sm">
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
|
||||||
No hay mensajes todavía.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{messages.map((m) => {
|
|
||||||
const mine = m.sender === myEndpoint;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={m.id}
|
|
||||||
style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start" }}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
shadow="xs"
|
|
||||||
radius="md"
|
|
||||||
p="xs"
|
|
||||||
bg={mine ? "violet.9" : undefined}
|
|
||||||
maw="75%"
|
|
||||||
>
|
|
||||||
{!mine && (
|
|
||||||
<Text size="xs" fw={700} c="violet.4">
|
|
||||||
{nameFor(m.sender)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text size="sm" style={{ wordBreak: "break-word", whiteSpace: "pre-wrap" }}>
|
|
||||||
{m.text}
|
|
||||||
</Text>
|
|
||||||
<Text size="9px" c="dimmed" ta="right" mt={2}>
|
|
||||||
{formatTime(m.ts)}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<Group p="md" gap="xs" wrap="nowrap" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
|
||||||
<TextInput
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
placeholder={`Mensaje a ${room.subject}…`}
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
|
||||||
/>
|
|
||||||
<ActionIcon size="lg" onClick={send} disabled={!text.trim()}>
|
|
||||||
<IconSend size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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 (
|
|
||||||
<Stack gap={0} h="100%">
|
|
||||||
<Box p="md">
|
|
||||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
|
|
||||||
Crear room
|
|
||||||
</Text>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<TextInput
|
|
||||||
size="xs"
|
|
||||||
placeholder="subject (room.general)"
|
|
||||||
leftSection={<IconHash size={14} />}
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && create()}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
size="xs"
|
|
||||||
label="Cifrado extremo a extremo"
|
|
||||||
checked={encrypt}
|
|
||||||
onChange={(e) => setEncrypt(e.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
<Button size="xs" leftSection={<IconPlus size={14} />} onClick={create}>
|
|
||||||
Crear
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Box p="md">
|
|
||||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
|
|
||||||
Unirse por id
|
|
||||||
</Text>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
size="xs"
|
|
||||||
placeholder="room id"
|
|
||||||
value={joinID}
|
|
||||||
onChange={(e) => setJoinID(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && join()}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button size="xs" variant="light" onClick={join} px="sm">
|
|
||||||
<IconDoorEnter size={16} />
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" px="md" pt="md" pb="xs">
|
|
||||||
Rooms ({rooms.length})
|
|
||||||
</Text>
|
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
|
||||||
<Stack gap={2} px="xs" pb="md">
|
|
||||||
{rooms.length === 0 && (
|
|
||||||
<Text size="sm" c="dimmed" px="sm" py="lg" ta="center">
|
|
||||||
Aún no hay rooms. Crea o únete a una.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{rooms.map((r) => (
|
|
||||||
<NavLink
|
|
||||||
key={r.room_id}
|
|
||||||
active={r.room_id === activeRoom}
|
|
||||||
onClick={() => onSelect(r.room_id)}
|
|
||||||
label={r.subject}
|
|
||||||
description={r.room_id.slice(0, 14) + "…"}
|
|
||||||
leftSection={
|
|
||||||
r.encrypt ? <IconLock size={16} /> : <IconHash size={16} />
|
|
||||||
}
|
|
||||||
variant="filled"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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(
|
|
||||||
<React.StrictMode>
|
|
||||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
|
||||||
<App />
|
|
||||||
</MantineProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user