tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Definitions

⎇ e9d35ce8dd82f89a1fa32a0f72d69942766f80ca consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt 188 lines · 6.7 KB History raw

Consola: diagnóstico en pantalla — bitácora en vivo del handshake WS/RPC e9d35ce8d · Sergio ·

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}