A screen for the machines, and failures a phone can act on

The other half of making setups editable: add, rename, rediscover and
remove, with a Test that tries a machine before anything is saved.

The screen cannot name a program, which is the point rather than an
omission -- providers are what the server found when it asked, so this app
has no way to introduce something to run. The dialog says so, because
"what it can run is discovered, not typed" is the answer to the question a
person will otherwise ask when they look for a command field.

Two things running it changed. The card showed "this machine / this
machine", because the seeded setup is *called* that and my fallback line
for a local setup said the same -- the line now says something the name
cannot also be. And the header row absorbed a fifth action without
complaint, which is the earlier title-and-actions split paying off exactly
as its comment predicted.

**Host key verification is the failure that would have made this look
broken.** Every machine fails it the first time, because its key is not in
known_hosts yet, and ssh's own words -- "Host key verification failed." --
are written for somebody at a terminal on the backend, which is exactly who
is not reading a phone. It now says what to do: ssh to it once from the
backend and try again. Permission denied gets the same treatment.

Deliberately *not* fixed by relaxing StrictHostKeyChecking. Accepting a new
key is a decision somebody should make with the key in front of them, not
something this app does quietly on their behalf while adding a machine.

Verified on the emulator against a running server: the seeded setup renders
with what was discovered on it, the add dialog explains itself, and Test
against an untrusted machine produces the full explanation rather than
ssh's four words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 13:17:08 -04:00
1 parent 19e3531c5d
commit 3c144f8070
5 files changed
+506 -19

No files matched your search

@@ -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<List<Setup>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Setup?>(null) }
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(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<Provider>,
) {
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<String?>(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") } },
)
}