⎇ e9d35ce8dd82f89a1fa32a0f72d69942766f80ca
consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
Historia
crudo
Consola: diagnóstico en pantalla — bitácora en vivo del handshake WS/RPC
1package com.rizoma.consola
2
3import android.app.Application
4import android.content.ComponentName
5import android.content.Context
6import android.content.Intent
7import android.content.ServiceConnection
8import android.os.IBinder
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableStateOf
11import androidx.compose.runtime.setValue
12import androidx.core.content.ContextCompat
13import androidx.lifecycle.AndroidViewModel
14import androidx.lifecycle.viewModelScope
15import com.rizoma.consola.data.GatewayClient
16import com.rizoma.consola.data.GatewayConfig
17import com.rizoma.consola.data.PtySession
18import com.rizoma.consola.data.Rpc
19import com.rizoma.consola.data.RpcResponse
20import com.rizoma.consola.data.Settings
21import com.rizoma.consola.service.ConsolaService
22import com.rizoma.consola.term.TerminalFrame
23import kotlinx.coroutines.launch
24
25enum class Screen { List, Terminal, Settings }
26enum class TermStatus { Connecting, Live, Exited, Error, Closed }
27
28/**
29 * Orquesta la consola. La **lista** de sesiones (vía `POST /rpc`) y la
30 * navegación viven aquí; el **terminal en vivo** (WebSocket) lo posee el
31 * [ConsolaService] de primer plano, para sobrevivir a que la app pierda el
32 * foco. El ViewModel se une al servicio, lee su estado y le delega las
33 * teclas/resize; el átomo de estado del terminal se expone tal cual a la UI.
34 */
35class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
36
37 private val settings = Settings(app)
38 private val listClient = GatewayClient(GatewayConfig())
39
40 var config by mutableStateOf(GatewayConfig()); private set
41 var screen by mutableStateOf(Screen.List); private set
42
43 // Lista de sesiones.
44 var sessions by mutableStateOf<List<PtySession>>(emptyList()); private set
45 var listLoading by mutableStateOf(false); private set
46 var listError by mutableStateOf<String?>(null); private set
47
48 // Servicio de primer plano (dueño del WS). Estado Compose para que la UI
49 // recomponga cuando el bind se completa.
50 private var service by mutableStateOf<ConsolaService?>(null)
51 private var pendingOpen: (() -> Unit)? = null
52
53 // Estado del terminal, delegado al servicio.
54 val frame: TerminalFrame? get() = service?.terminal?.frame
55 val termStatus: TermStatus get() = service?.terminal?.status ?: TermStatus.Connecting
56 val termMessage: String? get() = service?.terminal?.message
57 val termTitle: String get() = service?.terminal?.title ?: ""
58 val activeSessionId: String? get() = service?.terminal?.activeSessionId
59
60 // Bitácora de diagnóstico en pantalla (handshake WS, RPC, errores).
61 val debug: List<String> get() = DebugLog.lines
62 fun clearDebug() = DebugLog.clear()
63
64 private val connection = object : ServiceConnection {
65 override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
66 val svc = (binder as? ConsolaService.LocalBinder)?.service ?: return
67 svc.updateConfig(config)
68 service = svc
69 // Si quedó un terminal vivo (app reabierta), restáuralo.
70 if (svc.terminal.live) screen = Screen.Terminal
71 pendingOpen?.let { it(); pendingOpen = null }
72 }
73
74 override fun onServiceDisconnected(name: ComponentName?) {
75 service = null
76 }
77 }
78
79 init {
80 val app = getApplication<Application>()
81 app.bindService(Intent(app, ConsolaService::class.java), connection, Context.BIND_AUTO_CREATE)
82 viewModelScope.launch {
83 settings.config.collect {
84 config = it
85 listClient.config = it
86 service?.updateConfig(it)
87 }
88 }
89 refresh()
90 }
91
92 // ---- Lista --------------------------------------------------------
93
94 fun refresh() {
95 if (listLoading) return
96 listLoading = true
97 viewModelScope.launch {
98 when (val r = listClient.rpc(Rpc.ptyList())) {
99 is RpcResponse.Sessions -> { sessions = r.items.sortedByDescending { it.createdUnixMs }; listError = null }
100 is RpcResponse.Failure -> listError = r.message
101 else -> listError = "respuesta inesperada del gateway"
102 }
103 listLoading = false
104 }
105 }
106
107 fun kill(id: String) {
108 viewModelScope.launch {
109 listClient.rpc(Rpc.ptyKill(id))
110 refresh()
111 }
112 }
113
114 // ---- Navegación ---------------------------------------------------
115
116 fun goList() {
117 service?.detachTerminal()
118 screen = Screen.List
119 refresh()
120 }
121
122 fun goSettings() { screen = Screen.Settings }
123
124 fun saveSettings(url: String, token: String) {
125 viewModelScope.launch { settings.setConfig(url, token) }
126 screen = Screen.List
127 }
128
129 // ---- Terminal -----------------------------------------------------
130
131 /** Crea una sesión nueva (spawn) y se adjunta. */
132 fun spawn(command: String, cwd: String, label: String) {
133 val parts = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
134 if (parts.isEmpty()) return
135 startTerminal { svc ->
136 svc.openTerminal(
137 session = null,
138 program = parts.first(),
139 args = parts.drop(1),
140 cwd = cwd.ifBlank { "." },
141 label = label,
142 title = label.ifBlank { command },
143 )
144 }
145 }
146
147 /** Se adjunta a una sesión existente. */
148 fun attach(session: PtySession) {
149 startTerminal { svc ->
150 svc.openTerminal(
151 session = session.id,
152 program = session.program,
153 args = session.args,
154 cwd = session.cwd,
155 label = session.label,
156 title = session.label.ifBlank { session.commandLine },
157 )
158 }
159 }
160
161 /**
162 * Pasa el servicio a primer plano y abre el terminal. Si el bind aún no
163 * terminó, encola la apertura para cuando conecte.
164 */
165 private fun startTerminal(open: (ConsolaService) -> Unit) {
166 screen = Screen.Terminal
167 val app = getApplication<Application>()
168 ContextCompat.startForegroundService(
169 app,
170 Intent(app, ConsolaService::class.java),
171 )
172 val svc = service
173 if (svc != null) open(svc) else pendingOpen = { service?.let(open) }
174 }
175
176 fun onTerminalSized(rows: Int, cols: Int) { service?.onSized(rows, cols) }
177
178 fun sendText(text: String) { service?.sendText(text) }
179
180 fun sendBytes(bytes: ByteArray) { service?.sendBytes(bytes) }
181
182 override fun onCleared() {
183 // No matamos el terminal: el servicio queda en primer plano si hay
184 // sesión viva. Solo soltamos el binding.
185 runCatching { getApplication<Application>().unbindService(connection) }
186 super.onCleared()
187 }
188}