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
81 lines
3.3 KiB
Kotlin
81 lines
3.3 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import java.io.IOException
|
|
import java.net.HttpURLConnection
|
|
import java.net.URL
|
|
|
|
/**
|
|
* The SSE half of the API: one long-lived GET per open session screen,
|
|
* replaying the transcript after a cursor and then following it live.
|
|
*
|
|
* Blocking -- run() occupies its thread until the stream ends. [close]
|
|
* (from any thread) is the cancellation path: it disconnects the socket,
|
|
* which unblocks the read; run() then returns instead of throwing, so a
|
|
* deliberate close doesn't surface as a connection error. The caller owns
|
|
* reconnecting (with the last seq it saw as the new cursor) -- see
|
|
* SessionScreen.
|
|
*/
|
|
class EventStream(private val settings: ServerSettings, private val sessionId: String) {
|
|
@Volatile private var connection: HttpURLConnection? = null
|
|
@Volatile private var closed = false
|
|
|
|
fun close() {
|
|
closed = true
|
|
connection?.disconnect()
|
|
}
|
|
|
|
/** Streams events after [after] into [onEvent] until the stream drops. */
|
|
fun run(after: Long, onEvent: (SeqEvent) -> Unit) {
|
|
val connection =
|
|
URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection()
|
|
as HttpURLConnection
|
|
this.connection = connection
|
|
try {
|
|
connection.applyPinnedTls()
|
|
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.
|
|
connection.readTimeout = 0
|
|
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
|
connection.setRequestProperty("Accept", "text/event-stream")
|
|
if (connection.responseCode != 200) {
|
|
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
|
|
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
|
|
}
|
|
|
|
val reader = connection.inputStream.bufferedReader()
|
|
// SSE framing: `data:` lines accumulate until a blank line ends
|
|
// the event. `id:` (the seq) is also inside the JSON payload,
|
|
// so only data lines matter; comment lines (keep-alives) start
|
|
// with ':' and are skipped.
|
|
val data = StringBuilder()
|
|
while (true) {
|
|
val line = reader.readLine() ?: break
|
|
when {
|
|
line.isEmpty() -> {
|
|
if (data.isNotEmpty()) {
|
|
onEvent(parseSeqEvent(data.toString()))
|
|
data.clear()
|
|
}
|
|
}
|
|
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
|
else -> {} // id:, event:, comments -- nothing to do
|
|
}
|
|
}
|
|
} catch (e: ApiException) {
|
|
throw e
|
|
} catch (e: IOException) {
|
|
if (!closed) {
|
|
throw ApiException(
|
|
"Lost the event stream (${e::class.simpleName}: ${e.message})",
|
|
e,
|
|
)
|
|
}
|
|
} finally {
|
|
connection.disconnect()
|
|
this.connection = null
|
|
}
|
|
}
|
|
}
|