Merge remote-tracking branch 'origin/main'

# Conflicts:
#	app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt
This commit is contained in:
iris committed 2026-09-04 15:03:37 -04:00
commit e3e02d55f7
29 files changed
+2737 -479

No files matched your search

@@ -17,7 +17,18 @@ import org.json.JSONObject
const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* A request that did not produce what it asked for, carrying the server's own wording where it sent
* some -- those messages are written to be read on the screen that made the call.
*
* [status] is the HTTP status where there was a response at all, and null where the server was
* never reached. Callers that need it need it because the *same* failure is two different things to
* do: a 409 from a write is "somebody else changed this, here are three ways out", where every
* other refusal is a message to show. Nothing should branch on it to decide what to *say* -- the
* message is what says that.
*/
class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) :
Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
@@ -68,7 +79,8 @@ fun <T> requestFromServer(
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
}
},
status = connection.responseCode,
)
}
return readBody(connection)
@@ -82,13 +94,13 @@ fun <T> requestFromServer(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
"this device able to reach that address (WireGuard up)?",
e,
cause = e,
)
} catch (e: Exception) {
throw ApiException(
"Reached ${settings.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
cause = e,
)
} finally {
connection.disconnect()
@@ -589,6 +601,175 @@ fun uploadAttachment(
}
}
/**
* One entry of a directory on the machine a setup names.
*
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
* still says it is one. Neither is worked out here -- the machine answers both, because it is the
* only thing that can.
*/
data class DirEntry(
val name: String,
val kind: String,
val size: Long,
val modified: Long,
val link: Boolean,
) {
val isDirectory: Boolean
get() = kind == "directory"
}
/** A directory's entries, and the path the machine resolved the request to. */
data class Listing(val path: String, val entries: List<DirEntry>)
/**
* What reading a file produced.
*
* Four cases, because they are four different things to draw and none of them is an error the
* screen can shrug off: content, something that is not text, something too big to have sent, and
* (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is
* [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these.
*/
sealed class FileContent {
abstract val path: String
abstract val size: Long
abstract val modified: Long
data class Text(
override val path: String,
override val size: Long,
override val modified: Long,
/** What a write is given back, to prove the file is still the one that was read. */
val sha256: String,
val content: String,
) : FileContent()
data class Binary(
override val path: String,
override val size: Long,
override val modified: Long,
) : FileContent()
data class TooBig(
override val path: String,
override val size: Long,
override val modified: Long,
) : FileContent()
}
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
data class Written(val size: Long, val modified: Long, val sha256: String)
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
readTimeoutMs = 30000,
) { connection ->
val body = connection.jsonObject()
Listing(
path = body.getString("path"),
entries =
body.getJSONArray("entries").mapObjects { entry ->
DirEntry(
name = entry.getString("name"),
kind = entry.getString("kind"),
size = entry.optLong("size"),
modified = entry.optLong("modified"),
link = entry.optBoolean("link", false),
)
},
)
}
/** One file's content, or which of the reasons there is none to show. */
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before
// any of it moves. Well clear of that rather than just above it -- a timeout is for a
// server that has stopped answering.
readTimeoutMs = 60000,
) { connection ->
val body = connection.jsonObject()
val at = body.getString("path")
val size = body.optLong("size")
val modified = body.optLong("modified")
when (val kind = body.getString("kind")) {
"text" ->
FileContent.Text(
at,
size,
modified,
body.getString("sha256"),
body.getString("content"),
)
"binary" -> FileContent.Binary(at, size, modified)
"tooBig" -> FileContent.TooBig(at, size, modified)
// A backend that has learned a fifth answer. Reported rather than guessed at: picking
// the nearest of the four would draw something confident about a state this app has
// never seen.
else ->
throw ApiException(
"The server described this file as \"$kind\", which this app does not know how to show."
)
}
}
/**
* Replaces a file's contents, but only while it still hashes to [ifSha256].
*
* The refusal is a 409 and arrives as an [ApiException] carrying the server's wording, which is
* what the conflict dialog shows -- an agent editing the same file while somebody reads it is the
* ordinary case here, not the exotic one.
*/
fun writeFile(
settings: ServerSettings,
setup: String,
path: String,
content: String,
ifSha256: String,
): Written =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
method = "PUT",
jsonBody =
JSONObject()
.put("path", path)
.put("content", content)
.put("ifSha256", ifSha256)
.toString(),
readTimeoutMs = 60000,
) { connection ->
val body = connection.jsonObject()
Written(body.optLong("size"), body.optLong("modified"), body.getString("sha256"))
}
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
fun createFile(settings: ServerSettings, setup: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
) {}
}
/** Creates a directory, with the same refusal as [createFile]. */
fun createDir(settings: ServerSettings, setup: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
) {}
}
/** Fetches an image the transcript references (produced or uploaded). */
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {