diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 49630e7..50cc292 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -140,28 +140,110 @@ fun fetchSessions(settings: ServerSettings): List = // would offer pairs that cannot work. data class Provider(val name: String, val kind: String, val models: List) -/** A machine, and what it can run. [address] is absent for the backend itself. */ -data class Setup(val name: String, val address: String?, val providers: List) +/** + * A machine, and what it can run. [address] is absent for the backend itself. + * + * [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that + * refers to a setup uses the id and everything a person reads uses the name. + */ +data class Setup( + val id: String, + val name: String, + val address: String?, + val providers: List, +) + +private fun parseProvider(provider: JSONObject) = + Provider( + name = provider.getString("name"), + kind = provider.getString("kind"), + // Omitted entirely when the provider offers none. + models = provider.optJSONArray("models")?.strings().orEmpty(), + ) + +private fun parseSetup(setup: JSONObject) = + Setup( + id = setup.getString("id"), + name = setup.getString("name"), + address = setup.optString("address").ifEmpty { null }, + providers = setup.getJSONArray("providers").mapObjects(::parseProvider), + ) fun fetchSetups(settings: ServerSettings): List = - requestFromServer(settings, "/setups") { connection -> - connection.jsonObjects { setup -> - Setup( - name = setup.getString("name"), - address = setup.optString("address").ifEmpty { null }, - providers = - setup.getJSONArray("providers").mapObjects { provider -> - Provider( - name = provider.getString("name"), - kind = provider.getString("kind"), - // Omitted entirely when the provider offers none. - models = provider.optJSONArray("models")?.strings().orEmpty(), - ) - }, - ) - } + requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) } + +/** + * How to reach a machine. Deliberately carries no command: the server discovers what a machine can + * run by asking it, so this app has no way to introduce something to run. + * + * [identityFile] is a path on the *backend*, not a key -- private keys do not travel. + */ +data class SshDetails( + val address: String, + val port: Int? = null, + val identityFile: String? = null, +) + +private fun SshDetails.toJson() = + JSONObject().put("address", address).apply { + if (port != null) put("port", port) + if (!identityFile.isNullOrBlank()) put("identityFile", identityFile) } +/** What a machine turns out to have, without saving anything. */ +fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List = + requestFromServer( + settings, + "/setups/probe", + method = "POST", + jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(), + readTimeoutMs = 40000, + ) { + it.jsonObjects(::parseProvider) + } + +fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup = + requestFromServer( + settings, + "/setups", + method = "POST", + jsonBody = + JSONObject() + .put("name", name) + .apply { if (ssh != null) put("ssh", ssh.toJson()) } + .toString(), + readTimeoutMs = 40000, + ) { + parseSetup(it.jsonObject()) + } + +/** Renames a machine, and optionally asks it again what it has. */ +fun updateSetup( + settings: ServerSettings, + id: String, + name: String? = null, + rediscover: Boolean = false, +): Setup = + requestFromServer( + settings, + "/setups/${id.urlEncoded()}", + method = "PUT", + jsonBody = + JSONObject() + .apply { + if (name != null) put("name", name) + if (rediscover) put("rediscover", true) + } + .toString(), + readTimeoutMs = 40000, + ) { + parseSetup(it.jsonObject()) + } + +fun deleteSetup(settings: ServerSettings, id: String) { + requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {} +} + /** * Spawns a session and returns it as the list would show it. [setup] names the machine and * [provider] one of the things that machine offers. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index db94eb7..e91cef7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -24,6 +24,8 @@ private sealed class Screen { data object Models : Screen() + data object Setups : Screen() + data object Settings : Screen() } @@ -76,6 +78,7 @@ fun AppRoot(settingsVersion: Int) { onSpawn = { screen = Screen.Spawn }, onUsage = { screen = Screen.Usage }, onModels = { screen = Screen.Models }, + onSetups = { screen = Screen.Setups }, onSettings = { screen = Screen.Settings }, ) is Screen.Session -> @@ -95,6 +98,7 @@ fun AppRoot(settingsVersion: Int) { ) is Screen.Usage -> UsageScreen(settings = current, onBack = goToList) is Screen.Models -> ModelsScreen(settings = current, onBack = goToList) + is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList) is Screen.Settings -> SettingsScreen( existing = current, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 341a6ed..48b026b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -52,6 +52,7 @@ fun SessionListScreen( onSpawn: () -> Unit, onUsage: () -> Unit, onModels: () -> Unit, + onSetups: () -> Unit, onSettings: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -100,6 +101,7 @@ fun SessionListScreen( ) { TextButton(onClick = onUsage) { Text("Usage") } TextButton(onClick = onModels) { Text("Models") } + TextButton(onClick = onSetups) { Text("Setups") } TextButton(onClick = onSettings) { Text("Settings") } Spacer(Modifier.weight(1f)) TextButton(onClick = { refresh() }) { Text("Refresh") } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt new file mode 100644 index 0000000..b569627 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt @@ -0,0 +1,369 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The machines this backend can run things on. + * + * Note what this screen cannot do: name a program. Providers are what the server found when it + * asked the machine, so adding one is "here is how to reach it" and never "here is what to run" -- + * which is what keeps the enrolled token from being able to introduce commands. + */ +@Composable +fun SetupsScreen(settings: ServerSettings, onBack: () -> Unit) { + val scope = rememberCoroutineScope() + var state by remember { mutableStateOf>>(LoadState.Loading) } + var adding by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(null) } + var confirmingDelete by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(null) } + var actionError by remember { mutableStateOf(null) } + + suspend fun reload() { + state = + try { + withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) } + } catch (e: ApiException) { + LoadState.failed(e) + } + } + + LaunchedEffect(Unit) { reload() } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text("Setups", style = MaterialTheme.typography.headlineSmall) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = { adding = true }) { Text("Add machine") } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onBack) { Text("Back") } + } + Spacer(Modifier.height(8.dp)) + + actionError?.let { + Text(it, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + } + busy?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp)) + Text(it, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(8.dp)) + } + + when (val current = state) { + is LoadState.Loading -> CircularProgressIndicator() + is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) + is LoadState.Loaded -> + LazyColumn(Modifier.fillMaxSize()) { + items(current.value, key = { it.id }) { setup -> + SetupCard( + setup = setup, + onRename = { renaming = setup }, + onRediscover = { + scope.launch { + busy = "Asking ${setup.name} what it has…" + actionError = + runCatching { + withContext(Dispatchers.IO) { + updateSetup( + settings, + setup.id, + rediscover = true, + ) + } + } + .exceptionOrNull() + ?.message + busy = null + reload() + } + }, + onDelete = { confirmingDelete = setup }, + ) + } + } + } + } + + if (adding) { + AddSetupDialog( + onDismiss = { adding = false }, + onAdd = { name, ssh -> + adding = false + scope.launch { + busy = "Asking $name what it has…" + actionError = + runCatching { + withContext(Dispatchers.IO) { addSetup(settings, name, ssh) } + } + .exceptionOrNull() + ?.message + busy = null + reload() + } + }, + onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } }, + ) + } + + renaming?.let { setup -> + RenameDialog( + setup = setup, + onDismiss = { renaming = null }, + onRename = { name -> + renaming = null + scope.launch { + actionError = + runCatching { + withContext(Dispatchers.IO) { + updateSetup(settings, setup.id, name = name) + } + } + .exceptionOrNull() + ?.message + reload() + } + }, + ) + } + + confirmingDelete?.let { setup -> + AlertDialog( + onDismissRequest = { confirmingDelete = null }, + title = { Text("Remove \"${setup.name}\"?") }, + text = { + Text( + "The machine is left alone -- this only stops this app offering it. " + + "Sessions still running on it must be deleted first." + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmingDelete = null + scope.launch { + actionError = + runCatching { + withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) } + } + .exceptionOrNull() + ?.message + reload() + } + } + ) { + Text("Remove") + } + }, + dismissButton = { + TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun SetupCard( + setup: Setup, + onRename: () -> Unit, + onRediscover: () -> Unit, + onDelete: () -> Unit, +) { + Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Column(Modifier.padding(12.dp)) { + Text(setup.name, style = MaterialTheme.typography.titleSmall) + Text( + // Not "this machine": the seeded setup is *called* that, + // and the card read "this machine / this machine". The + // line has to say something the name cannot also be. + setup.address ?: "runs where the backend does", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + if (setup.providers.isEmpty()) { + "Nothing found on it. Install something and rediscover." + } else { + setup.providers.joinToString(" · ") { it.name } + }, + style = MaterialTheme.typography.bodySmall, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onRename) { Text("Rename") } + TextButton(onClick = onRediscover) { Text("Rediscover") } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDelete) { Text("Remove") } + } + } + } +} + +@Composable +private fun AddSetupDialog( + onDismiss: () -> Unit, + onAdd: (String, SshDetails?) -> Unit, + onTest: suspend (SshDetails?) -> List, +) { + val scope = rememberCoroutineScope() + var name by remember { mutableStateOf("") } + var address by remember { mutableStateOf("") } + var port by remember { mutableStateOf("") } + var identity by remember { mutableStateOf("") } + var tested by remember { mutableStateOf(null) } + var testing by remember { mutableStateOf(false) } + + fun details(): SshDetails? = + address + .trim() + .takeIf { it.isNotEmpty() } + ?.let { + SshDetails( + address = it, + port = port.trim().toIntOrNull(), + identityFile = identity.trim().ifEmpty { null }, + ) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add a machine") }, + text = { + Column { + Text( + "Leave the address blank for the machine the backend runs on. " + + "What it can run is discovered, not typed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + ) + OutlinedTextField( + value = address, + onValueChange = { address = it }, + label = { Text("user@host (blank = this machine)") }, + singleLine = true, + ) + OutlinedTextField( + value = port, + onValueChange = { port = it }, + label = { Text("Port (blank = 22)") }, + singleLine = true, + ) + OutlinedTextField( + value = identity, + onValueChange = { identity = it }, + label = { Text("Key path on the backend") }, + singleLine = true, + ) + tested?.let { + Spacer(Modifier.height(8.dp)) + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { + TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) { + Text("Add") + } + }, + dismissButton = { + Row { + // Tried before saving, so a wrong address or an + // unauthorised key is caught while this form is still on + // screen rather than at the first spawn. + TextButton( + enabled = !testing, + onClick = { + testing = true + tested = "Asking…" + scope.launch { + tested = + runCatching { onTest(details()) } + .fold( + onSuccess = { found -> + if (found.isEmpty()) { + "Reached it, but found nothing it can run." + } else { + "Found ${found.joinToString(", ") { it.name }}" + } + }, + onFailure = { it.message ?: "Couldn't reach it" }, + ) + testing = false + } + }, + ) { + Text("Test") + } + TextButton(onClick = onDismiss) { Text("Cancel") } + } + }, + ) +} + +@Composable +private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) { + var name by remember { mutableStateOf(setup.name) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename") }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Sessions already running on it keep working -- they refer to the machine, " + + "not to what it is called.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) { + Text("Rename") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/server/src/setups.rs b/server/src/setups.rs index 951eb1d..3adc9eb 100644 --- a/server/src/setups.rs +++ b/server/src/setups.rs @@ -52,7 +52,7 @@ pub async fn discover(transport: &Transport) -> Result> { wanted.join(" ") ); let launch = Launch::new("sh", vec!["-c".to_string(), script], None); - let found = transport.capture(&launch).await?; + let found = transport.capture(&launch).await.map_err(explain)?; let mut providers = Vec::new(); // Echo runs inside this server, so it exists exactly where this server @@ -90,6 +90,36 @@ pub async fn discover(transport: &Transport) -> Result> { Ok(providers) } +/// Adds what to do to failures whose own wording does not say. +/// +/// ssh's messages are written for someone at a terminal on the backend, +/// which is exactly who is not reading this one. Host key verification is +/// the case that matters: **every** machine fails it the first time, +/// because its key is not in `known_hosts` yet -- so without this, adding +/// a machine from the phone looks broken rather than unfinished. +/// +/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` +/// stays at its default, so a first connection is a decision somebody +/// makes on the backend with the key in front of them, rather than +/// something this app quietly accepts on their behalf. +fn explain(err: anyhow::Error) -> anyhow::Error { + let message = format!("{err:#}"); + if message.contains("Host key verification failed") { + return anyhow::anyhow!( + "{message} This machine has not been connected to before, so its key is not \ + trusted yet. Ssh to it once from the backend -- that is where the decision to \ + trust a key belongs -- and try again.", + ); + } + if message.contains("Permission denied") { + return anyhow::anyhow!( + "{message} The key named here has to be authorized on that machine, and the path \ + is read on the backend rather than on the phone.", + ); + } + err +} + /// A short, stable, filename-safe id derived from a label. /// /// Derived once when a setup is added and then fixed, so the label stays