Consola: diagnóstico en pantalla — bitácora en vivo del handshake WS/RPC
Nueva DebugLog (singleton observable por Compose) que registra cada paso de RPC y del WebSocket. El panel se abre solo al fallar; el EOFException sin respuesta ahora trae una pista interpretada. Antes todo iba solo a Logcat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
6 archivos · +152 −2
- .gitignore +3 −0
- consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt +4 −0
- consola/src/main/java/com/rizoma/consola/DebugLog.kt +39 −0
- consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt +40 −1
- consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt +2 −0
- consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt +64 −1
diff --git a/.gitignore b/.gitignore
index 1d22aea..c3f2d6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ captures/
*.aab
*.keystore
!debug.keystore
+
+# screenshots personales sueltos en la raíz
+/Screenshot_*.jpg
diff --git a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
index 870be52..3da3969 100644
--- a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
+++ b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
@@ -57,6 +57,10 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
val termTitle: String get() = service?.terminal?.title ?: ""
val activeSessionId: String? get() = service?.terminal?.activeSessionId
+ // Bitácora de diagnóstico en pantalla (handshake WS, RPC, errores).
+ val debug: List<String> get() = DebugLog.lines
+ fun clearDebug() = DebugLog.clear()
+
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val svc = (binder as? ConsolaService.LocalBinder)?.service ?: return
diff --git a/consola/src/main/java/com/rizoma/consola/DebugLog.kt b/consola/src/main/java/com/rizoma/consola/DebugLog.kt
new file mode 100644
index 0000000..442f3f2
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/DebugLog.kt
@@ -0,0 +1,39 @@
+package com.rizoma.consola
+
+import android.util.Log
+import androidx.compose.runtime.mutableStateListOf
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Bitácora de diagnóstico en memoria, observable por Compose. Sirve para ver
+ * **en pantalla** qué pasa con la conexión (handshake WS, RPC, errores) sin
+ * tener que enchufar `adb logcat`. Es un singleton de proceso: lo escriben
+ * tanto el cliente de red (hilo de OkHttp) como el servicio/ViewModel, y lo
+ * lee la UI. Cada línea va también a Logcat por si hace falta el histórico.
+ *
+ * Mutar un [mutableStateListOf] desde hilos de fondo es seguro: el estado de
+ * snapshot de Compose es thread-safe y la recomposición ve copias coherentes.
+ */
+object DebugLog {
+ private const val MAX = 300
+ private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
+
+ /** Líneas más recientes al final. La UI las invierte para mostrar. */
+ val lines = mutableStateListOf<String>()
+
+ @Synchronized
+ fun log(tag: String, msg: String) {
+ val line = "${fmt.format(Date())} $tag · $msg"
+ lines.add(line)
+ while (lines.size > MAX) lines.removeAt(0)
+ Log.d("Consola/$tag", msg)
+ }
+
+ @Synchronized
+ fun clear() = lines.clear()
+
+ /** Volcado de texto plano, para copiar/compartir. */
+ fun dump(): String = lines.joinToString("\n")
+}
diff --git a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
index 8d67968..bfbf7f5 100644
--- a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
+++ b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
@@ -1,6 +1,7 @@
package com.rizoma.consola.data
import android.util.Log
+import com.rizoma.consola.DebugLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -35,8 +36,10 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
/** Round-trip de una Request JSON (cuerpos de [Rpc]) → [RpcResponse]. */
suspend fun rpc(body: String): RpcResponse = withContext(Dispatchers.IO) {
+ val rpcUrl = config.baseUrl.trimEnd('/') + "/rpc"
+ DebugLog.log("RPC", "POST $rpcUrl (token ${if (config.token.isBlank()) "ausente" else "presente"}) ← ${body.take(80)}")
val req = Request.Builder()
- .url(config.baseUrl.trimEnd('/') + "/rpc")
+ .url(rpcUrl)
.post(body.toRequestBody(json))
.apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
.build()
@@ -44,12 +47,15 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
client.newCall(req).execute().use { resp: Response ->
val text = resp.body?.string().orEmpty()
if (text.isEmpty()) {
+ DebugLog.log("RPC", "HTTP ${resp.code} con cuerpo vacío")
RpcResponse.Failure("respuesta vacía (HTTP ${resp.code})")
} else {
+ DebugLog.log("RPC", "HTTP ${resp.code} ← ${text.take(120)}")
RpcParser.parse(text)
}
}
}.getOrElse { e ->
+ DebugLog.log("RPC", "FALLO ${e.javaClass.simpleName}: ${e.message ?: "(sin mensaje)"}")
RpcResponse.Failure(e.message ?: "fallo de red")
}
}
@@ -85,6 +91,11 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
}
// URL sin query: para logs y para el mensaje de error sin filtrar el token.
val safeUrl = url.substringBefore('?')
+ DebugLog.log(
+ "WS",
+ "abriendo $safeUrl (${if (session != null) "attach $session" else "spawn $program"}, ${rows}x$cols, " +
+ "token ${if (config.token.isBlank()) "ausente" else "presente"})",
+ )
val req = Request.Builder()
.url(url)
.apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
@@ -103,15 +114,18 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
val ws = client.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
+ DebugLog.log("WS", "handshake OK (HTTP ${response.code}) → enviando open: ${open.toString().take(120)}")
webSocket.send(open.toString())
listener.onOpen()
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+ DebugLog.log("WS", "← ${bytes.size} bytes")
listener.onBytes(bytes.toByteArray())
}
override fun onMessage(webSocket: WebSocket, text: String) {
+ DebugLog.log("WS", "← texto: ${text.take(160)}")
val o = runCatching { JSONObject(text) }.getOrNull() ?: return
when (o.optString("t")) {
"session" -> listener.onSessionId(o.optString("id"))
@@ -121,10 +135,12 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+ DebugLog.log("WS", "cerrando: code=$code reason=${reason.ifBlank { "—" }}")
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ DebugLog.log("WS", "cerrado: code=$code reason=${reason.ifBlank { "—" }}")
listener.onClosed(reason.ifBlank { null })
}
@@ -153,6 +169,29 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
append(" · sin respuesta al abrir "); append(safeUrl)
}
}
+
+ // Pista interpretada: un EOF/conexión cerrada sin respuesta HTTP
+ // casi siempre es que el server cerró el TCP sin completar el
+ // upgrade. Las causas típicas, para no adivinar a ciegas.
+ val hint = when {
+ response == null && (t is java.io.EOFException ||
+ t.javaClass.simpleName == "EOFException") ->
+ "el server cerró la conexión sin responder al upgrade. Revisa: " +
+ "(1) ¿es ws:// vs wss:// correcto para el puerto? " +
+ "(2) ¿la ruta /ws/pty existe en el gateway? " +
+ "(3) ¿hay un proxy/reverse-proxy que no pasa el Upgrade? " +
+ "(4) ¿el token es válido?"
+ response?.code == 401 || response?.code == 403 ->
+ "rechazo de auth: el token del query/header no es válido para el WS"
+ t is java.net.ConnectException ->
+ "no se pudo conectar al host:puerto — ¿gateway caído o URL/puerto mal?"
+ else -> null
+ }
+
+ DebugLog.log("WS", "FALLO contra $safeUrl → $detail")
+ if (!httpBody.isNullOrBlank()) DebugLog.log("WS", "cuerpo: ${httpBody.take(300)}")
+ hint?.let { DebugLog.log("WS", "pista: $it") }
+
listener.onError(detail)
listener.onClosed(detail)
}
diff --git a/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt b/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
index 4d0d761..d4d42e6 100644
--- a/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
+++ b/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
@@ -28,6 +28,8 @@ fun ConsolaApp(vm: ConsolaViewModel = viewModel()) {
status = vm.termStatus,
message = vm.termMessage,
frame = vm.frame,
+ debug = vm.debug,
+ onClearDebug = vm::clearDebug,
onSized = vm::onTerminalSized,
onText = vm::sendText,
onBytes = vm::sendBytes,
diff --git a/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
index d5afa24..a25ff0b 100644
--- a/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
+++ b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
@@ -11,14 +11,17 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -65,12 +68,17 @@ fun TerminalScreen(
status: TermStatus,
message: String?,
frame: TerminalFrame?,
+ debug: List<String>,
+ onClearDebug: () -> Unit,
onSized: (rows: Int, cols: Int) -> Unit,
onText: (String) -> Unit,
onBytes: (ByteArray) -> Unit,
onBack: () -> Unit,
) {
val focus = remember { FocusRequester() }
+ var showDebug by remember { mutableStateOf(false) }
+ // Abre la bitácora sola cuando algo falla: ahí es cuando hace falta verla.
+ LaunchedEffect(status) { if (status == TermStatus.Error) showDebug = true }
Column(Modifier.fillMaxSize().background(Vacio)) {
// Barra superior.
@@ -79,10 +87,19 @@ fun TerminalScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text("‹", color = NeonVerde, modifier = Modifier.clickable { onBack() }.padding(end = 12.dp))
- Column(Modifier.fillMaxWidth()) {
+ Column(Modifier.weight(1f)) {
Text(title.ifBlank { "terminal" }, color = Tenue, maxLines = 1)
Text(statusLine(status, message), color = statusColor(status), maxLines = 1)
}
+ Text(
+ if (showDebug) "log ▴" else "log ▾",
+ color = if (status == TermStatus.Error) NeonRosa else NeonCian,
+ modifier = Modifier.clickable { showDebug = !showDebug }.padding(start = 12.dp),
+ )
+ }
+
+ if (showDebug) {
+ DebugPanel(debug = debug, onClear = onClearDebug)
}
// Pantalla del terminal — tap para enfocar el teclado.
@@ -104,6 +121,52 @@ fun TerminalScreen(
}
}
+/**
+ * Panel de diagnóstico en pantalla: las líneas de [DebugLog], más recientes
+ * arriba. Evita tener que abrir `adb logcat` para ver por qué no conecta.
+ */
+@Composable
+private fun DebugPanel(debug: List<String>, onClear: () -> Unit) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .heightIn(max = 220.dp)
+ .background(SuperficieAlta)
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("diagnóstico (${debug.size})", color = NeonCian, fontSize = 11.sp, modifier = Modifier.weight(1f))
+ Text("limpiar", color = Tenue, fontSize = 11.sp, modifier = Modifier.clickable { onClear() })
+ }
+ val scroll = rememberScrollState()
+ Column(Modifier.fillMaxWidth().verticalScroll(scroll)) {
+ if (debug.isEmpty()) {
+ Text("(sin eventos todavía)", color = MuyTenue, fontSize = 11.sp)
+ } else {
+ // Más reciente primero.
+ for (line in debug.asReversed()) {
+ Text(
+ line,
+ color = lineColor(line),
+ fontSize = 11.sp,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ modifier = Modifier.padding(vertical = 1.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun lineColor(line: String): Color = when {
+ "FALLO" in line || "pista:" in line -> NeonRosa
+ "handshake OK" in line || "← texto" in line || "← " in line -> NeonVerde
+ else -> Tenue
+}
+
@Composable
private fun TerminalCanvas(frame: TerminalFrame?, onSized: (Int, Int) -> Unit) {
val density = LocalDensity.current