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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3ae5fa52dc
commit
e9d35ce8dd
@@ -11,3 +11,6 @@ captures/
|
||||
*.aab
|
||||
*.keystore
|
||||
!debug.keystore
|
||||
|
||||
# screenshots personales sueltos en la raíz
|
||||
/Screenshot_*.jpg
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user