Cleanup pass: one home for duplicated logic, stale comments out
Nothing behavioral except two status codes; mostly removing places where the same rule was written down more than once and could drift. - server/src/private.rs: the owner-only create/write helpers, which config.rs, certs.rs, and the session dirs each had their own copy of (certs.rs even duplicated the explanatory comment). One module owns the modes now, so the "nothing this server writes is readable by anyone else" property is checkable in one place. - server/src/media.rs: the image media-type/extension table, which the four places that have to agree on it each spelled out separately -- storing an upload, serving it back, building a content block, saving a produced image. The differing *defaults* stay at the call sites with the reasoning, since they genuinely differ by direction. - routes.rs: a missing file was a 400 and an unreadable one a 400 with a hand-rolled log line; they are now 404 and Internal respectively. UnknownSession became NotFound, since it was the only 404-with-message. - main.rs: xdg_dir takes the variable's value instead of reading the environment, which drops the unsafe set_var from its test and lets the test actually assert the relative-path rule. - echo.rs had its own 4-byte hex generator beside session::random_hex. - claude.rs: the two impl Translator blocks were one type's methods. - Stale comments: phase-2 markers on shipped work, a permission-mode list that had drifted from the CLI's, "dev-updater" as the leaf certificate's fallback common name, a half-written sentence in build-apk.sh. - App: the JSONArray walk written out in four fetchers, the four near-identical BackHandlers in AppRoot, and SessionScreen's inline fully-qualified names where the file otherwise imports. - server/wg-test.log was committed by accident; *.log is ignored now, and the gitignore comments describe where state actually lives. - PLAN.md's backend layout gains the new modules and drops hosts.rs for the ssh.rs that was built instead. Verified: 35 server tests, clippy clean, app compiles warning-free, and a scratch server driven over curl -- attachment upload/serve round-trip with both a known and an unknown content type, the new 404s, transcript and session-dir deletion, plus a real claude-cli session answering a prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
d4a4ee7808
commit
99bcc341c1
18 files changed
+295
-241
No files matched your search
@@ -12,7 +12,10 @@ import java.net.URL
|
||||
// carrying the server's own explanation where it sent one, since those
|
||||
// messages are written to be read on this screen.
|
||||
|
||||
private const val CONNECT_TIMEOUT_MS = 5000
|
||||
// Shared with EventStream.kt, which connects the same way but then reads
|
||||
// without a deadline.
|
||||
const val CONNECT_TIMEOUT_MS = 5000
|
||||
private const val READ_TIMEOUT_MS = 5000
|
||||
|
||||
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
||||
@@ -32,7 +35,7 @@ fun <T> requestFromServer(
|
||||
jsonBody: String? = null,
|
||||
/** Raw request body as content-type to bytes -- the upload path. */
|
||||
binaryBody: Pair<String, ByteArray>? = null,
|
||||
readTimeoutMs: Int = 5000,
|
||||
readTimeoutMs: Int = READ_TIMEOUT_MS,
|
||||
readBody: (HttpURLConnection) -> T,
|
||||
): T {
|
||||
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
|
||||
@@ -88,6 +91,19 @@ fun <T> requestFromServer(
|
||||
}
|
||||
}
|
||||
|
||||
/** The response body as one JSON object. */
|
||||
private fun HttpURLConnection.jsonObject(): JSONObject =
|
||||
JSONObject(inputStream.bufferedReader().readText())
|
||||
|
||||
/** The response body as a JSON array of objects, each mapped through [parse]. */
|
||||
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
|
||||
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
|
||||
|
||||
private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
|
||||
(0 until length()).map { parse(getJSONObject(it)) }
|
||||
|
||||
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
|
||||
|
||||
// One row of GET /sessions. `provider` is what runs it, `host` where --
|
||||
// the two are independent, so a session names both.
|
||||
data class SessionSummary(
|
||||
@@ -111,10 +127,7 @@ private fun parseSession(session: JSONObject) = SessionSummary(
|
||||
)
|
||||
|
||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
requestFromServer(settings, "/sessions") { connection ->
|
||||
val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||
(0 until sessions.length()).map { parseSession(sessions.getJSONObject(it)) }
|
||||
}
|
||||
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a
|
||||
// provider or host added to the server's config.json appears here with no
|
||||
@@ -125,23 +138,19 @@ data class RemoteHost(val name: String, val address: String)
|
||||
|
||||
fun fetchProviders(settings: ServerSettings): List<Provider> =
|
||||
requestFromServer(settings, "/providers") { connection ->
|
||||
val providers = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||
(0 until providers.length()).map { i ->
|
||||
val provider = providers.getJSONObject(i)
|
||||
val models = provider.optJSONArray("models")
|
||||
connection.jsonObjects { provider ->
|
||||
Provider(
|
||||
name = provider.getString("name"),
|
||||
kind = provider.getString("kind"),
|
||||
models = (0 until (models?.length() ?: 0)).map { models!!.getString(it) },
|
||||
// Omitted entirely when the provider offers none.
|
||||
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchHosts(settings: ServerSettings): List<RemoteHost> =
|
||||
requestFromServer(settings, "/hosts") { connection ->
|
||||
val hosts = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||
(0 until hosts.length()).map { i ->
|
||||
val host = hosts.getJSONObject(i)
|
||||
connection.jsonObjects { host ->
|
||||
RemoteHost(name = host.getString("name"), address = host.getString("address"))
|
||||
}
|
||||
}
|
||||
@@ -171,7 +180,7 @@ fun spawnSession(
|
||||
}.toString(),
|
||||
readTimeoutMs = 30000,
|
||||
) { connection ->
|
||||
parseSession(JSONObject(connection.inputStream.bufferedReader().readText()))
|
||||
parseSession(connection.jsonObject())
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
@@ -212,7 +221,7 @@ fun uploadAttachment(
|
||||
binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail),
|
||||
readTimeoutMs = 60000,
|
||||
) { connection ->
|
||||
JSONObject(connection.inputStream.bufferedReader().readText()).getString("id")
|
||||
connection.jsonObject().getString("id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,16 +249,12 @@ data class UsageSnapshot(
|
||||
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
|
||||
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
||||
requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection ->
|
||||
val snapshots = JSONArray(connection.inputStream.bufferedReader().readText())
|
||||
(0 until snapshots.length()).map { i ->
|
||||
val snapshot = snapshots.getJSONObject(i)
|
||||
val windows = snapshot.getJSONArray("windows")
|
||||
connection.jsonObjects { snapshot ->
|
||||
UsageSnapshot(
|
||||
provider = snapshot.getString("provider"),
|
||||
available = snapshot.getBoolean("available"),
|
||||
error = snapshot.optString("error").ifEmpty { null },
|
||||
windows = (0 until windows.length()).map { j ->
|
||||
val window = windows.getJSONObject(j)
|
||||
windows = snapshot.getJSONArray("windows").mapObjects { window ->
|
||||
UsageWindow(
|
||||
label = window.getString("label"),
|
||||
percent = window.getDouble("percent"),
|
||||
|
||||
@@ -49,6 +49,18 @@ fun AppRoot(settingsVersion: Int) {
|
||||
return
|
||||
}
|
||||
|
||||
// The one way back, whichever screen is showing and whether it was
|
||||
// reached by the system back gesture or a screen's own Back button.
|
||||
// Every leaf screen can have changed something the list shows, so it
|
||||
// always refetches.
|
||||
val goToList = {
|
||||
reloadToken++
|
||||
screen = Screen.SessionList
|
||||
}
|
||||
if (screen !is Screen.SessionList) {
|
||||
BackHandler(onBack = goToList)
|
||||
}
|
||||
|
||||
when (val here = screen) {
|
||||
is Screen.SessionList -> SessionListScreen(
|
||||
settings = current,
|
||||
@@ -58,46 +70,27 @@ fun AppRoot(settingsVersion: Int) {
|
||||
onUsage = { screen = Screen.Usage },
|
||||
onSettings = { screen = Screen.Settings },
|
||||
)
|
||||
is Screen.Session -> {
|
||||
BackHandler {
|
||||
is Screen.Session -> SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToList,
|
||||
)
|
||||
is Screen.Spawn -> SpawnScreen(
|
||||
settings = current,
|
||||
onSpawned = { spawned ->
|
||||
reloadToken++
|
||||
screen = Screen.SessionList
|
||||
}
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = {
|
||||
reloadToken++
|
||||
screen = Screen.SessionList
|
||||
},
|
||||
)
|
||||
}
|
||||
is Screen.Spawn -> {
|
||||
BackHandler { screen = Screen.SessionList }
|
||||
SpawnScreen(
|
||||
settings = current,
|
||||
onSpawned = { spawned ->
|
||||
reloadToken++
|
||||
screen = Screen.Session(spawned)
|
||||
},
|
||||
onBack = { screen = Screen.SessionList },
|
||||
)
|
||||
}
|
||||
is Screen.Usage -> {
|
||||
BackHandler { screen = Screen.SessionList }
|
||||
UsageScreen(settings = current, onBack = { screen = Screen.SessionList })
|
||||
}
|
||||
is Screen.Settings -> {
|
||||
BackHandler { screen = Screen.SessionList }
|
||||
SettingsScreen(
|
||||
existing = current,
|
||||
onSaved = { saved ->
|
||||
settings = saved
|
||||
reloadToken++
|
||||
screen = Screen.SessionList
|
||||
},
|
||||
onBack = { screen = Screen.SessionList },
|
||||
)
|
||||
}
|
||||
screen = Screen.Session(spawned)
|
||||
},
|
||||
onBack = goToList,
|
||||
)
|
||||
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList)
|
||||
is Screen.Settings -> SettingsScreen(
|
||||
existing = current,
|
||||
onSaved = { saved ->
|
||||
settings = saved
|
||||
goToList()
|
||||
},
|
||||
onBack = goToList,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S
|
||||
this.connection = connection
|
||||
try {
|
||||
connection.applyPinnedTls()
|
||||
connection.connectTimeout = 5000
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
// No read timeout: between events there is nothing to read for
|
||||
// as long as the session is idle; the server's keep-alives and
|
||||
// a dead socket erroring out are the liveness story.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,7 +18,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -33,7 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -123,7 +131,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
||||
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
||||
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
// The resume cursor, written from the stream's IO thread.
|
||||
val lastSeq = remember { AtomicLong(0) }
|
||||
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
||||
@@ -192,8 +200,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
|
||||
// The system photo picker; the image uploads as soon as it's chosen,
|
||||
// so Send only has ids to reference.
|
||||
val pickImage = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia(),
|
||||
val pickImage = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.PickVisualMedia(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
scope.launch {
|
||||
@@ -248,10 +256,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
itemsIndexed(items) { _, item ->
|
||||
items(items) { item ->
|
||||
when (item) {
|
||||
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
||||
is TranscriptItem.AssistantMsg -> Text(item.text, style = MaterialTheme.typography.bodyLarge)
|
||||
@@ -291,10 +299,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
pickImage.launch(
|
||||
androidx.activity.result.PickVisualMediaRequest(
|
||||
androidx.activity.result.contract.ActivityResultContracts
|
||||
.PickVisualMedia.ImageOnly,
|
||||
),
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
)
|
||||
}) {
|
||||
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
|
||||
@@ -325,17 +330,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
|
||||
var bitmap by remember(ref) {
|
||||
mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
|
||||
}
|
||||
var bitmap by remember(ref) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var failed by remember(ref) { mutableStateOf(false) }
|
||||
LaunchedEffect(ref) {
|
||||
try {
|
||||
val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
|
||||
bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?.asImageBitmap()
|
||||
bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
failed = bitmap == null
|
||||
} catch (e: ApiException) {
|
||||
} catch (_: ApiException) {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
@@ -345,7 +347,7 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> androidx.compose.foundation.Image(
|
||||
else -> Image(
|
||||
bitmap = image,
|
||||
contentDescription = "session image",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+3
-3
@@ -40,9 +40,9 @@ fi
|
||||
|
||||
CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}"
|
||||
if [ -f "$CA" ]; then
|
||||
# Printed so a wrong or stale certificate is visible here rather than as
|
||||
# a handshake failure on the phone. Compare with the server's own
|
||||
# the CA the server is actually presenting.
|
||||
# Printed so a wrong or stale certificate is visible here rather than
|
||||
# as a handshake failure on the phone -- compare it against the CA the
|
||||
# backend is actually presenting.
|
||||
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
|
||||
| openssl pkey -pubin -outform der 2>/dev/null \
|
||||
| openssl dgst -sha256 -binary 2>/dev/null \
|
||||
|
||||
Reference in new issue
Block a user