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
|
*.aab
|
||||||
*.keystore
|
*.keystore
|
||||||
!debug.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 termTitle: String get() = service?.terminal?.title ?: ""
|
||||||
val activeSessionId: String? get() = service?.terminal?.activeSessionId
|
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 {
|
private val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||||
val svc = (binder as? ConsolaService.LocalBinder)?.service ?: return
|
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
|
package com.rizoma.consola.data
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.rizoma.consola.DebugLog
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
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]. */
|
/** Round-trip de una Request JSON (cuerpos de [Rpc]) → [RpcResponse]. */
|
||||||
suspend fun rpc(body: String): RpcResponse = withContext(Dispatchers.IO) {
|
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()
|
val req = Request.Builder()
|
||||||
.url(config.baseUrl.trimEnd('/') + "/rpc")
|
.url(rpcUrl)
|
||||||
.post(body.toRequestBody(json))
|
.post(body.toRequestBody(json))
|
||||||
.apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
|
.apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
|
||||||
.build()
|
.build()
|
||||||
@@ -44,12 +47,15 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
|
|||||||
client.newCall(req).execute().use { resp: Response ->
|
client.newCall(req).execute().use { resp: Response ->
|
||||||
val text = resp.body?.string().orEmpty()
|
val text = resp.body?.string().orEmpty()
|
||||||
if (text.isEmpty()) {
|
if (text.isEmpty()) {
|
||||||
|
DebugLog.log("RPC", "HTTP ${resp.code} con cuerpo vacío")
|
||||||
RpcResponse.Failure("respuesta vacía (HTTP ${resp.code})")
|
RpcResponse.Failure("respuesta vacía (HTTP ${resp.code})")
|
||||||
} else {
|
} else {
|
||||||
|
DebugLog.log("RPC", "HTTP ${resp.code} ← ${text.take(120)}")
|
||||||
RpcParser.parse(text)
|
RpcParser.parse(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
|
DebugLog.log("RPC", "FALLO ${e.javaClass.simpleName}: ${e.message ?: "(sin mensaje)"}")
|
||||||
RpcResponse.Failure(e.message ?: "fallo de red")
|
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.
|
// URL sin query: para logs y para el mensaje de error sin filtrar el token.
|
||||||
val safeUrl = url.substringBefore('?')
|
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()
|
val req = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
|
.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() {
|
val ws = client.newWebSocket(req, object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
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())
|
webSocket.send(open.toString())
|
||||||
listener.onOpen()
|
listener.onOpen()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||||
|
DebugLog.log("WS", "← ${bytes.size} bytes")
|
||||||
listener.onBytes(bytes.toByteArray())
|
listener.onBytes(bytes.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
DebugLog.log("WS", "← texto: ${text.take(160)}")
|
||||||
val o = runCatching { JSONObject(text) }.getOrNull() ?: return
|
val o = runCatching { JSONObject(text) }.getOrNull() ?: return
|
||||||
when (o.optString("t")) {
|
when (o.optString("t")) {
|
||||||
"session" -> listener.onSessionId(o.optString("id"))
|
"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) {
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
DebugLog.log("WS", "cerrando: code=$code reason=${reason.ifBlank { "—" }}")
|
||||||
webSocket.close(1000, null)
|
webSocket.close(1000, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
DebugLog.log("WS", "cerrado: code=$code reason=${reason.ifBlank { "—" }}")
|
||||||
listener.onClosed(reason.ifBlank { null })
|
listener.onClosed(reason.ifBlank { null })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +169,29 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
|
|||||||
append(" · sin respuesta al abrir "); append(safeUrl)
|
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.onError(detail)
|
||||||
listener.onClosed(detail)
|
listener.onClosed(detail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ fun ConsolaApp(vm: ConsolaViewModel = viewModel()) {
|
|||||||
status = vm.termStatus,
|
status = vm.termStatus,
|
||||||
message = vm.termMessage,
|
message = vm.termMessage,
|
||||||
frame = vm.frame,
|
frame = vm.frame,
|
||||||
|
debug = vm.debug,
|
||||||
|
onClearDebug = vm::clearDebug,
|
||||||
onSized = vm::onTerminalSized,
|
onSized = vm::onTerminalSized,
|
||||||
onText = vm::sendText,
|
onText = vm::sendText,
|
||||||
onBytes = vm::sendBytes,
|
onBytes = vm::sendBytes,
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -65,12 +68,17 @@ fun TerminalScreen(
|
|||||||
status: TermStatus,
|
status: TermStatus,
|
||||||
message: String?,
|
message: String?,
|
||||||
frame: TerminalFrame?,
|
frame: TerminalFrame?,
|
||||||
|
debug: List<String>,
|
||||||
|
onClearDebug: () -> Unit,
|
||||||
onSized: (rows: Int, cols: Int) -> Unit,
|
onSized: (rows: Int, cols: Int) -> Unit,
|
||||||
onText: (String) -> Unit,
|
onText: (String) -> Unit,
|
||||||
onBytes: (ByteArray) -> Unit,
|
onBytes: (ByteArray) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val focus = remember { FocusRequester() }
|
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)) {
|
Column(Modifier.fillMaxSize().background(Vacio)) {
|
||||||
// Barra superior.
|
// Barra superior.
|
||||||
@@ -79,10 +87,19 @@ fun TerminalScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text("‹", color = NeonVerde, modifier = Modifier.clickable { onBack() }.padding(end = 12.dp))
|
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(title.ifBlank { "terminal" }, color = Tenue, maxLines = 1)
|
||||||
Text(statusLine(status, message), color = statusColor(status), 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.
|
// 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
|
@Composable
|
||||||
private fun TerminalCanvas(frame: TerminalFrame?, onSized: (Int, Int) -> Unit) {
|
private fun TerminalCanvas(frame: TerminalFrame?, onSized: (Int, Int) -> Unit) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|||||||
Reference in New Issue
Block a user