Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt
T
iris 2a1bc84c1e Expand a leading ~, and say what the failing command said
Three things, two of which are the same failure seen from opposite ends.

**A working directory of `~/repos/ai-app` never worked.** Everything
crossing to the remote side is single-quoted, which is right for paths,
model names and prompts alike -- unquoted they would be shell syntax
rather than data. It is wrong for exactly one character: `~` means "expand
me", and quoting is what stops expansion. So the remote shell was handed
the literal four-character directory `~` and correctly said it did not
exist, which reads as the path being wrong rather than the quoting.

Paths now go through `quote_path`, which emits `"$HOME"` for a leading
`~/` and single-quotes the rest. The variable expands, the expansion is
not re-split or globbed because it is double-quoted, and nothing after it
gains a meaning -- there is a test that pushes a quote-and-semicolon
injection through the tilde branch and gets back one absurd path rather
than three commands. `$HOME` is set by every shell this can land in, so
this does not depend on the remote side being POSIX; verified by running
the generated script under both sh and fish, which is what the dev VM
actually uses.

**The phone could not have told you any of that.** The exit report kept
the last line of stderr, and a shell's error message ends with a blank
line -- so the last line was empty, the report was a bare exit status, and
the seven lines of fish complaining sat in the server's log where nobody
holding a phone is looking. It now keeps the last 50 lines in a ring and
reports them with blank lines trimmed from both ends. The tests use the
real fish `cd` failure as their fixture.

**The status bar was unreadable.** `isAppearanceLightStatusBars` was
hardcoded to `true` -- dark icons -- which was right against the default
light surface and wrong the moment the app wore Mocha. It now asks the
scheme's own background for its luminance, so changing the palette cannot
reintroduce it.

**And the address field takes `user@host:port`.** One field rather than
two, because that is how an address is written everywhere else and a port
that is nearly always 22 does not deserve its own box on a phone keyboard.
Absent means absent rather than 22: the backend already decides that
default, and writing it here would be a second answer in a second place.
A colon only means "port" when it can -- brackets for IPv6 as ssh writes
them, otherwise exactly one colon followed by digits.

Looked at on the emulator: the status bar, and the form, whose label I
then shortened because it wrapped onto a second line and made that field
taller than the two beside it.
2026-08-28 21:05:17 -04:00

397 lines
16 KiB
Kotlin

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 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 { typed ->
val (host, typedPort) = splitHostAndPort(typed)
SshDetails(
address = host,
port = typedPort,
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 },
// Just the shape. What a blank one means is said once, in the text above
// this form -- repeating it here wrapped the label onto a second line and
// made this field taller than the two beside it for no information.
label = { Text("user@host[:port]") },
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") } },
)
}
/**
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
*
* One field rather than two because that is how an address is written and read everywhere else --
* and because a port that is almost always 22 does not deserve a box of its own on a phone
* keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would
* put a second answer to that question in a second place.
*
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
* `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an
* address and a port. So the rule is: brackets, or exactly one colon followed by digits.
*/
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
if (typed.startsWith("[")) {
val close = typed.indexOf(']')
if (close > 0) {
val host = typed.substring(1, close)
val rest = typed.substring(close + 1)
val port = rest.removePrefix(":").toIntOrNull().takeIf { rest.startsWith(":") }
return host to port
}
}
if (typed.count { it == ':' } == 1) {
val host = typed.substringBeforeLast(':')
val port = typed.substringAfterLast(':').toIntOrNull()
if (port != null && host.isNotEmpty()) return host to port
}
return typed to null
}