CLI client, SSE

This commit is contained in:
2026-08-08 20:56:32 +02:00
parent 0e57de60b0
commit 8f4c6cc630
22 changed files with 611 additions and 220 deletions
@@ -28,14 +28,11 @@ class AuthProvider private constructor() {
init {
_refresh.value = _refreshAccessor.load()
println("Refresh token: ${_refresh.value}")
_server.value = _serverAccessor.load()
println("Server: ${_server.value}")
if(_server.value == null) onLogout()
}
fun setServer(server: String, protocol: String) {
println("Setting server to $server, protocol version $protocol")
_server.value = server
_protocolVersion.value = protocol
_serverAccessor.save(server)
@@ -45,7 +42,6 @@ class AuthProvider private constructor() {
fun onLogin(tokens: TokenResponse) {
_lock.withLock {
if (server.value == null) throw IllegalStateException("Server is null, cannot log in.")
println("Saving new refresh token (${tokens.refresh})")
_refresh.value = tokens.refresh
_access.value = tokens.access
_refreshAccessor.save(tokens.refresh)
@@ -55,7 +51,6 @@ class AuthProvider private constructor() {
fun onRefresh(tokens: TokenResponse) {
_lock.withLock {
println("Saving new refresh token (${tokens.refresh})")
_refresh.value = tokens.refresh
_access.value = tokens.access
_refreshAccessor.save(tokens.refresh)
@@ -64,7 +59,6 @@ class AuthProvider private constructor() {
fun onLogout() {
_lock.withLock {
println("Erasing refresh token ${_refresh.value}")
_refresh.value = null
_access.value = null
_refreshAccessor.erase()
@@ -73,7 +67,6 @@ class AuthProvider private constructor() {
}
fun asBearer(): BearerTokens? = _lock.withLock {
println("Bearer tokens requested (access: ${access.value}, refresh: ${_refresh.value})")
BearerTokens(
accessToken = _access.value ?: return null,
refreshToken = _refresh.value?.toString() ?: return null
@@ -2,6 +2,7 @@ package com.jaytux.phoebench.clients
import com.jaytux.phoebench.common.*
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
@@ -10,8 +11,6 @@ import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.*
import kotlinx.atomicfu.locks.ReentrantLock
import kotlinx.atomicfu.locks.withLock
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
class Client private constructor(private val _auth: AuthProvider) {
@@ -19,7 +18,6 @@ class Client private constructor(private val _auth: AuthProvider) {
install(ContentNegotiation) { json() }
}
private val _client = platformClient {
var tryingRefresh = false
install(ContentNegotiation) { json() }
install(SSE) {
@@ -28,38 +26,33 @@ class Client private constructor(private val _auth: AuthProvider) {
bufferPolicy = SSEBufferPolicy.LastEvents(5)
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
socketTimeoutMillis = 10000
}
install(Auth) {
bearer {
cacheTokens = false
loadTokens {
val res = _auth.asBearer()
println("Client requested bearer tokens and got $res")
res
}
refreshTokens {
if (tryingRefresh) {
val ref = _auth.refresh.value ?: return@refreshTokens null
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
if (it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
_auth.onLogout()
null
} else {
tryingRefresh = true
val ref = _auth.refresh.value ?: return@refreshTokens null
println("Trying to re-authenticate using $ref")
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
if (it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
_auth.onLogout()
null
}) {
_auth.onRefresh(it)
val res = _auth.asBearer()
println("Client requested bearer tokens (from refresh) and got $res")
res
}
tryingRefresh = false
}) {
_auth.onRefresh(it)
val res = _auth.asBearer()
res
}
res
}
}
}
@@ -94,14 +87,19 @@ class Client private constructor(private val _auth: AuthProvider) {
): Either<ErrorResponse, TRes> =
callRoute(_client, route, body)
suspend fun <T> connectSSE(route: String, serializer: KSerializer<T>, onEvent: suspend (T) -> Unit) {
println("Client tries to set up SSE to $route")
val server = _auth.server.value ?: throw IllegalStateException("No server URL set.")
_client.sse(urlString = "$server$route", showCommentEvents = true, showRetryEvents = true) {
incoming.collect {
println("RECEIVE: $it")
if(it.data != null) onEvent(Json.decodeFromString(serializer, it.data!!))
}
suspend fun <TParams : Any, TEvent : Any> callSSE(
route: SSERoute<TParams, TEvent>,
params: TParams,
handler: suspend (Either<ErrorResponse, TEvent>) -> Unit
): Either<ErrorResponse, Unit> {
try {
println("SSE connection to ${route.path} using server ${_auth.server.value}")
val client =
IClient.Default(_client, _auth.server.value ?: throw IllegalStateException("No server URL set."))
return route.call(client, params, handler)
} catch (e: CancellationException) {
handler(ErrorResponse("Event stream connected to ${route.path} was cancelled.").error())
throw e
}
}
@@ -29,7 +29,7 @@ fun <T> MutableState<T>.immutable(): State<T> = this
fun <T> T.ignore() {}
inline fun <reified R> ViewModel.withScope(crossinline block: suspend () -> R) = viewModelScope.launch {
withContext(Dispatchers.Unconfined) { block() }
withContext(Dispatchers.Default) { block() }
}
val formatter = LocalDateTime.Format {
@@ -83,3 +83,11 @@ fun Float.fmt(): String {
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
return (integer + decInt).toString()
}
inline fun <T, X : Comparable<X>> List<T>.insort(elem: T, crossinline sortBy: (T) -> X): List<T> {
val insertionPoint = binarySearchBy(sortBy(elem), selector = sortBy)
if(insertionPoint >= 0) return this
val index = -insertionPoint - 1
return toMutableList().apply { add(index, elem) }
}
@@ -9,11 +9,17 @@ import com.jaytux.phoebench.clients.SnackProvider
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
import com.jaytux.phoebench.clients.immutable
import com.jaytux.phoebench.clients.insort
import com.jaytux.phoebench.clients.toClipEntry
import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.AdminEvent
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.HomeResponse
import com.jaytux.phoebench.common.InviteListResponse
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.UserListResponse
import com.jaytux.phoebench.common.value
import kotlinx.coroutines.Job
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid
@@ -22,7 +28,8 @@ class HomeVM(
private val _client: Client = Client.get(),
private val _auth: AuthProvider = AuthProvider.get(),
private val _snack: SnackProvider = SnackProvider.get(),
private val _repo: IHomeRepo = IHomeRepo.default(_client)
private val _repo: IHomeRepo = IHomeRepo.default(_client),
private val _sseRepo: ISSERepo = ISSERepo.default(_client)
) : ViewModel() {
private val _username = mutableStateOf<String?>(null)
private val _isAdmin = mutableStateOf(false)
@@ -33,8 +40,6 @@ class HomeVM(
private val _users = mutableStateOf(listOf<UserListResponse.UserData>())
private val _invites = mutableStateOf(listOf<InviteListResponse.Invite>())
private var _tokenWasNull = _auth.refresh.value == null
val username = _username.immutable()
val isAdmin = _isAdmin.immutable()
val projectLimit = _projectLimit.immutable()
@@ -45,8 +50,15 @@ class HomeVM(
val users = _users.immutable()
private var _listenJob: Job? = null
private var _adminJob: Job? = null
init {
_listenJob = withScope {
_sseRepo.connectHome {
it.snackOr(this::homeEventHandler)
}.snackOnError()
}
refresh()
}
@@ -65,24 +77,7 @@ class HomeVM(
_publicProjects.value = listOf()
}
fun refreshIfNeeded() {
println("Check for refresh: $_tokenWasNull && ${_auth.refresh.value != null}")
if(_tokenWasNull && _auth.refresh.value != null) refresh()
_tokenWasNull = _auth.refresh.value == null
}
fun refresh() {
_listenJob = withScope {
_client.connectSSE("/rt/project", serializer<HomeResponse.ProjectSummary>()) {
if(it.owner.name == _username.value) {
if(_ownProjects.value.none { p -> p.id == it.id }) _ownProjects.value += it
}
if(it.isPublic) {
if(_publicProjects.value.none { p -> p.id == it.id }) _publicProjects.value += it
}
}
}
withScope {
resetAdmin()
_repo.getHome().snackOr {
@@ -90,24 +85,30 @@ class HomeVM(
_username.value = it.username
_isAdmin.value = it.isAdmin
_projectLimit.value = it.projectLimit
_ownProjects.value = it.ownProjects
_publicProjects.value = it.publicProjects
_ownProjects.value = it.ownProjects.sortedBy { p -> p.name }
_publicProjects.value = it.publicProjects.sortedBy { p -> p.name }
refreshAdmin()
}
}
}
fun refreshAdmin() {
if(_isAdmin.value) {
_adminJob = withScope {
_sseRepo.connectAdmin {
it.snackOr(this::adminEventHandler)
}.snackOnError()
}
}
withScope {
if(_isAdmin.value) {
_repo.inviteList().snackOr { r ->
_invites.value = r.uuids.map {
it.copy(expires = it.expires)
}
_invites.value = r.uuids
}
_repo.userList().snackOr { r ->
_users.value = r.users
_users.value = r.users.sortedBy { it.name }
}
}
else {
@@ -117,6 +118,42 @@ class HomeVM(
}
}
private fun homeEventHandler(event: HomeEvent) {
when (event) {
is HomeEvent.Changed -> {
arrayOf(_ownProjects, _publicProjects).forEach { state ->
state.value = state.value.filter { p -> p.id != event.summary.id }
.insort(event.summary, HomeResponse.ProjectSummary::name)
}
}
is HomeEvent.Created -> {
if (event.summary.owner.name == username.value) {
_ownProjects.value = _ownProjects.value.insort(event.summary, HomeResponse.ProjectSummary::name)
}
if (event.summary.isPublic) {
_publicProjects.value = _publicProjects.value.insort(event.summary, HomeResponse.ProjectSummary::name)
}
}
is HomeEvent.Deleted -> {
arrayOf(_ownProjects, _publicProjects).forEach { state ->
state.value = state.value.filter { p -> p.id != event.id }
}
}
}
}
private fun adminEventHandler(event: AdminEvent) {
when(event) {
is AdminEvent.InviteDeleted -> _invites.value = _invites.value.filter { it.code != event.id }
is AdminEvent.NewInvite -> _invites.value += event.invite
is AdminEvent.NewUser -> _users.value = _users.value.insort(event.user, UserListResponse.UserData::name)
is AdminEvent.UserChanged -> _users.value = _users.value.filter { it.id != event.user.id }.insort(event.user, UserListResponse.UserData::name)
is AdminEvent.UserDeleted -> _users.value = _users.value.filter { it.id != event.id }
}
}
fun mkInvite(clipboard: Clipboard, asAdmin: Boolean) {
withScope {
_repo.newInvite(asAdmin).snackOr {
@@ -171,7 +208,7 @@ class HomeVM(
fun mkProject(name: String, isPublic: Boolean) {
withScope {
_repo.newProject(name, isPublic).snackOr {
refresh()
if(_listenJob == null) refresh()
}
}
}
@@ -31,8 +31,6 @@ interface IProjectRepo {
suspend fun newEntry(label: Uuid, timestamp: Instant, warmups: List<Float>, measurements: List<Float>,
unit: TimeUnit): Either<ErrorResponse, EntryResponse>
suspend fun updateEntry(id: Uuid, label: Uuid? = null, timestamp: Instant? = null, warmups: List<Float>? = null,
measurements: List<Float>? = null, unit: TimeUnit? = null): Either<ErrorResponse, Unit>
suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit>
companion object {
@@ -60,14 +58,6 @@ interface IProjectRepo {
): Either<ErrorResponse, EntryResponse> =
_client.callRoute(Routes.Entry.new, EntryRequest(label, timestamp, _projectId, warmups, measurements, unit))
override suspend fun updateEntry(id: Uuid, label: Uuid?, timestamp: Instant?,
warmups: List<Float>?, measurements: List<Float>?,
unit: TimeUnit?
): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.update,
id to PartialEntryRequest(label, timestamp, null, warmups, measurements, unit)
).ignoreValue()
override suspend fun deleteEntry(id: Uuid): Either<ErrorResponse, Unit> =
_client.callRoute(Routes.Entry.delete, id).ignoreValue()
}
@@ -0,0 +1,31 @@
package com.jaytux.phoebench.clients.data
import com.jaytux.phoebench.clients.Client
import com.jaytux.phoebench.common.AdminEvent
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.HomeEvent
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.Routes
import kotlin.uuid.Uuid
interface ISSERepo {
suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit>
suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit>
companion object {
class Default(private val _client: Client) : ISSERepo {
override suspend fun connectHome(onEvent: suspend (Either<ErrorResponse, HomeEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.home, Unit, onEvent)
override suspend fun connectAdmin(onEvent: suspend (Either<ErrorResponse, AdminEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.admin, Unit, onEvent)
override suspend fun connectProject(id: Uuid, onEvent: suspend (Either<ErrorResponse, ProjectEvent>) -> Unit): Either<ErrorResponse, Unit> =
_client.callSSE(Routes.SSE.projectSpecific, id, onEvent)
}
fun default(client: Client) = Default(client)
}
}
@@ -6,14 +6,19 @@ import androidx.lifecycle.ViewModel
import com.jaytux.phoebench.clients.AuthProvider
import com.jaytux.phoebench.clients.Client
import com.jaytux.phoebench.clients.SnackProvider
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOnError
import com.jaytux.phoebench.clients.SnackProvider.Companion.snackOr
import com.jaytux.phoebench.clients.hexString
import com.jaytux.phoebench.clients.immutable
import com.jaytux.phoebench.clients.insort
import com.jaytux.phoebench.clients.systemTz
import com.jaytux.phoebench.clients.withScope
import com.jaytux.phoebench.common.EntryResponse
import com.jaytux.phoebench.common.LabelResponse
import com.jaytux.phoebench.common.ProjectEvent
import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.foldSuspend
import kotlinx.coroutines.Job
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
@@ -24,6 +29,7 @@ class ProjectVM(
private val _snack: SnackProvider = SnackProvider.get(),
private val _client: Client = Client.get(),
private val _repo: IProjectRepo = IProjectRepo.default(_client, _id),
private val _sseRepo: ISSERepo = ISSERepo.default(_client),
private val _forceBack: () -> Unit
) : ViewModel() {
data class Label(val id: Uuid, val name: String, val colorStr: String, val uiColor: Color = parseColor(colorStr)) {
@@ -74,7 +80,16 @@ class ProjectVM(
val labels = _labels.immutable()
val entries = _entries.immutable()
private var _job: Job? = null
init {
_job = withScope {
_sseRepo.connectProject(_id) {
it.snackOr(this::handleProjectEvent)
}.snackOnError()
}
refresh()
}
@@ -91,6 +106,22 @@ class ProjectVM(
}
}
private fun handleProjectEvent(event: ProjectEvent) {
when(event) {
ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _entries.value = _entries.value.filter { it.id != event.id }
is ProjectEvent.LabelChanged -> _labels.value += (event.label.id to Label.fromResponse(event.label))
is ProjectEvent.LabelDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id }
is ProjectEvent.NewEntry -> _entries.value = _entries.value.insort(Entry.fromResponse(event.entry, _labels.value), Entry::timeStamp)
is ProjectEvent.NewLabel -> _labels.value += (event.label.id to Label.fromResponse(event.label))
is ProjectEvent.Updated -> {
_name.value = event.changes.name
_owner.value = event.changes.owner.name
_public.value = event.changes.isPublic
}
}
}
fun update(name: String?, isPublic: Boolean?) {
withScope {
_repo.update(name, isPublic).snackOr { refresh() }
@@ -99,10 +130,15 @@ class ProjectVM(
fun delete() {
withScope {
_repo.delete().snackOr { _forceBack() }
_repo.delete().snackOr { back() }
}
}
fun back() {
_job?.cancel()
_forceBack()
}
fun mkLabel(name: String, color: Color) {
withScope {
_repo.newLabel(name, color).snackOr {
@@ -136,14 +172,6 @@ class ProjectVM(
}
}
fun updateEntry(id: Uuid, label: Label?, warmups: List<Float>?, measurements: List<Float>?, unit: TimeUnit?) {
withScope {
_repo.updateEntry(id, label?.id, Clock.System.now(), warmups, measurements, unit).snackOr {
refresh()
}
}
}
fun deleteEntry(id: Uuid) {
withScope {
_repo.deleteEntry(id).snackOr {
@@ -275,7 +275,6 @@ fun AuthenticatedRoot() {
) { insets ->
Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) {
currentProject?.let {
BackHandler { leaveProject() }
ProjectView(it, ::leaveProject)
} ?: run {
HomeView { currentProject = it }
@@ -9,7 +9,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
@@ -54,9 +56,11 @@ import io.github.koalaplot.core.xygraph.rememberGridStyle
import kotlin.time.Instant
import kotlin.uuid.Uuid
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ProjectView(id: Uuid, forceBack: () -> Unit) {
val vm = viewModel(key = id.toString()) { ProjectVM(id, _forceBack = forceBack) }
BackHandler { vm.back() }
val name by vm.name
val owner by vm.owner
@@ -489,7 +493,7 @@ fun ProjectPlotArea(vm: ProjectVM){
}
else {
LazyColumn(Modifier.padding(start = 5.dp)) {
items(labels.toList()) { (_, lbl) ->
items(labels.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable { labelFilter.toggle(lbl) }) {
Box {
QuickLabel(lbl)