Restructure, beginning of CLI client

This commit is contained in:
2026-08-04 19:11:32 +02:00
parent 2a1487037b
commit a10423b015
36 changed files with 695 additions and 27 deletions
+1
View File
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.serialization) apply false
alias(libs.plugins.jvm) apply false
alias(libs.plugins.ktor) apply false
alias(libs.plugins.shadow) apply false
}
repositories {
+43
View File
@@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.jvm)
application
alias(libs.plugins.serialization)
alias(libs.plugins.shadow)
}
group = "com.jaytux.phoebench"
version = rootProject.version.toString()
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.clikt)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization)
implementation(project(":common"))
implementation(kotlin("reflect"))
implementation(libs.ktor.client.okhttp)
implementation(libs.slf4j.simple)
implementation(libs.java.keystore)
implementation(libs.ktor.serialization.kotlinx.json)
}
application {
mainClass = "com.jaytux.phoebench.clients.cli.MainKt"
}
tasks.withType<Jar> {
manifest {
attributes["Main-Class"] = application.mainClass
}
}
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi")
}
}
@@ -0,0 +1,91 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.*
import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess
import kotlin.uuid.Uuid
object AuthHandlers {
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
val useServer = server.maybePrompt("server") { it }
Client.onSelectServer(useServer)
return runBlocking {
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
if (handshake.version != ProtocolVersion.VERSION) {
Client.onClearServer()
ErrorResponse("Mismatched protocol version (server: ${handshake.version}, client: ${ProtocolVersion.VERSION})").error()
} else Unit.value()
}
}
}
fun loginPrompt(server: String?, user: String?, pass: String?): Either<ErrorResponse, Unit> {
return runBlocking {
serverPrompt(server).bind {
val useUser = user.maybePrompt("username") { it }
val usePassword = pass.maybePrompt("password", isPassword = true) { it }
Client.callRoute(Routes.Auth.login, LoginRequest(useUser, usePassword))
}.bind {
Client.onLogin(it)
Unit.value()
}
}
}
private fun fmtHome(it: HomeResponse) {
println("Logged in as ${it.username}${if (it.isAdmin) " (admin)" else ""}.")
println("Your projects (${it.ownProjects.size}/${if (it.projectLimit == -1) "∞" else it.projectLimit}):")
it.ownProjects.forEach { p ->
println(" [${p.id}] ${p.name} (${if (p.isPublic) "public" else "private"} project)")
}
if (it.publicProjects.isNotEmpty()) {
println("\nPublicly accessible projects:")
it.publicProjects.forEach { p ->
println(" [${p.id}] ${p.name} by ${p.owner.name}")
}
} else {
println("\nNo publicly accessible projects.")
}
}
fun tryLogin(server: String?, user: String?, pass: String?) {
runBlocking {
loginPrompt(server, user, pass).bind {
Client.callRoute(Routes.home, EmptyRequest())
}.fold({
System.err.println("Failed to log in: ${it.msg}")
exitProcess(-1)
}) {
fmtHome(it)
}
exitProcess(0)
}
}
fun tryRegister(server: String?, invite: Uuid?, user: String?, pass: String?) {
runBlocking {
serverPrompt(server).bind {
val useInvite = invite.maybePrompt("invite code") {
Uuid.parseOrNull(it) ?: run {
System.err.println("Invalid invite code format (should be UUID format).")
exitProcess(-1)
}
}
val useUser = user.maybePrompt("username") { it }
val usePassword = pass.maybePrompt("password", isPassword = true) { it }
Client.callRoute(Routes.Auth.signup, SignupRequest(useInvite, useUser, usePassword))
}.bind {
System.err.println("Account created. Loading home...")
Client.onLogin(it)
Client.callRoute(Routes.home, EmptyRequest())
}.fold({
System.err.println("Registration pipeline failed: ${it.msg}")
}) {
fmtHome(it)
}
}
}
}
@@ -0,0 +1,195 @@
package com.jaytux.phoebench.clients.cli
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.float
import com.github.ajalt.clikt.parameters.types.inputStream
import com.jaytux.phoebench.common.TimeUnit
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import java.io.InputStream
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.primaryConstructor
import kotlin.system.exitProcess
import kotlin.uuid.Uuid
object CLI {
object Root : CliktCommand("phoebench-cli") {
override val invokeWithoutSubcommand: Boolean = true
val batchMode by option("--batch-mode", "--script-mode",
help = "Disable all interactive input (makes arguments marked with (*) mandatory)"
).flag(default = false)
override fun run() {
val sub = currentContext.invokedSubcommand
if(sub == null) {
System.err.println(getFormattedHelp())
System.err.println("\nNo subcommand given.")
exitProcess(-1)
}
}
}
interface ICommandContainer {
fun nestedCommands(): Array<CliktCommand>
}
annotation class IgnoreNestedCommand
internal fun ICommandContainer.fromReflection(): Array<CliktCommand> {
val nested = this::class.nestedClasses.filter {
it.isSubclassOf(CliktCommand::class) && it.annotations.none { a -> a.instanceOf(IgnoreNestedCommand::class) }
}
return nested.mapNotNull {
val ctor = it.primaryConstructor
if(ctor == null) {
System.err.println("Nested command ${it.simpleName} (for ${this::class.simpleName}) has no primary constructor.")
null
}
else if(ctor.parameters.isNotEmpty()) {
System.err.println("Primary constructor of nested command ${it.simpleName} (for ${this::class.simpleName}) requires arguments.")
null
}
else {
ctor.call() as CliktCommand
}
}.toTypedArray()
}
internal fun ICommandContainer.buildSubcommands(): Array<CliktCommand> {
val nested = nestedCommands()
nested.forEach {
if(it is ICommandContainer) {
it.subcommands(*it.buildSubcommands())
}
}
return nested
}
object Commands : ICommandContainer {
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
@Suppress("unused")
class Login : CliktCommand(name = "login") {
val server by option("--server", help = "The server to connect to (*)")
val user by option("--user", help = "The username to log in with (*)")
val pass by option("--pass", help = "The password to log in with (*)")
override fun run() = AuthHandlers.tryLogin(server, user, pass)
}
@Suppress("unused")
class Register : CliktCommand(name = "register"){
val server by option("--server", help = "The server to connect to (*)")
val invite by option("--invite", help = "The invite code to register with (*)").convert { Uuid.parse(it) }
val user by option("--user", help = "The username for the new account (*)")
val pass by option("--pass", help = "The password for the new account (*)")
override fun run() = AuthHandlers.tryRegister(server, invite, user, pass)
}
class Project : CliktCommand(name = "project"), ICommandContainer {
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
sealed interface IProjectIdentification
sealed interface ILabelIdentification
data class ProjectName(val user: String, val project: String) : IProjectIdentification
data class LabelName(val name: String): ILabelIdentification
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
sealed interface IData<T>
data class DirectData<T>(val data: List<T>) : IData<T>
data class FileData<T>(val file: InputStream, val parse: (String) -> T) : IData<T> {
companion object {
fun floatFile(file: InputStream) = FileData<Float>(file) { it.toFloat() }
}
}
val finder by mutuallyExclusiveOptions<IProjectIdentification>(
option("--id", help = "Find a project by UUID.").convert { ID(Uuid.parse(it)) },
option("--name", help = "Find a project by name (formatted [user]/[project])").convert {
val split = it.split('/')
if(split.size != 2) throw IllegalArgumentException("Invalid format (expected [user]/[project])")
ProjectName(split[0], split[1])
}
).single()
val project by findOrSetObject { this }
override fun run() {}
@Suppress("unused")
class ProjectList : CliktCommand(name = "list") {
override fun run() = ProjectHandlers.list()
}
@Suppress("unused")
class Details : CliktCommand(name = "details") {
val parent by requireObject<Project>()
override fun run() = ProjectHandlers.details(parent.finder)
}
@Suppress("unused")
class Create : CliktCommand(name = "create") {
val name by option("--name", help = "Set the project's name (*)")
val isPublic by option("--public", help = "Make the project publicly visible").flag(default = false)
override fun run() = ProjectHandlers.create(name, isPublic)
}
@Suppress("unused")
class AddLabel : CliktCommand(name = "add-label") {
val parent by requireObject<Project>()
val name by option("--name", help = "Set the label's name (*)")
val color by option("--color", help = "Set the label's color (*)").check("Color must be specified in RGB-hex-format (#ABCDEF)") {
it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
}
override fun run() = ProjectHandlers.newLabel(name, color, parent.finder)
}
@Suppress("unused")
class AddData : CliktCommand(name = "add-data") {
val parent by requireObject<Project>()
val label by mutuallyExclusiveOptions<ILabelIdentification>(
option("--label-id", help = "Set the label by UUID.").convert { ID(Uuid.parse(it)) },
option("--label", help = "Set the label by name.").convert { LabelName(it) }
).single()
val warmup by mutuallyExclusiveOptions<IData<Float>>(
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
option("--warmup-file", help = "Read warmup data from file.").inputStream().convert { FileData.floatFile(it) }
).single()
val measurement by mutuallyExclusiveOptions<IData<Float>>(
option("--measure", help = "Set measurement data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
option("--measure-file", help = "Read measurement data from file.").inputStream().convert { FileData.floatFile(it) }
).single()
val unit by mutuallyExclusiveOptions<TimeUnit>(
option("--ns", "--nano", "--nanosec", help = "Set the time unit to nanoseconds").flag().convert { TimeUnit.NANOS },
option("--us", "--micro", "--μs", "--microsec", help = "Set the time unit to microseconds").flag().convert { TimeUnit.MICROS },
option("--ms", "--milli", "--millis", "--millisec", help = "Set the time unit to milliseconds").flag().convert { TimeUnit.MILLIS },
option("--s", "--sec", "--second", help = "Set the time unit to seconds").flag().convert{ TimeUnit.SECONDS },
option("--min", "--m", "--minutes", help = "Set the time unit to minutes").flag().convert { TimeUnit.MINUTES },
option("--h", "--hour", help = "Set the time unit to hours").flag().convert { TimeUnit.HOURS }
).single()
override fun run() = ProjectHandlers.newData(parent.finder, label, warmup, measurement, unit)
}
}
}
fun main(args: Array<String>) {
try {
Root.subcommands(*Commands.buildSubcommands()).main(args)
}
catch(e: CancellationException) {}
catch(e: PromptException) {
System.err.println(e.message)
exitProcess(-1)
}
}
}
@@ -0,0 +1,125 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient
import com.jaytux.phoebench.common.RefreshRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TokenResponse
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.CancellationException
import kotlinx.coroutines.asExecutor
import kotlin.uuid.Uuid
object Client {
private var _refAcc = PersistentStorage.refreshToken()
private var _serverAcc = PersistentStorage.server()
private var _server: String? = _serverAcc.load()
private var _refreshToken: Uuid? = _server?.let { _refAcc.load() }
private var _accessToken: String? = null
private val _bearer: BearerTokens?
get() = _accessToken?.let { acc ->
_refreshToken?.let { ref ->
BearerTokens(
accessToken = acc,
refreshToken = ref.toString()
)
}
}
private val _authClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json() }
}
private val _client = HttpClient(OkHttp) {
install(ContentNegotiation) { json() }
install(Auth) {
bearer {
loadTokens {
val res = _bearer
res
}
refreshTokens {
val ref = _refreshToken ?: return@refreshTokens null
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
System.err.println("ERROR while re-authenticating: ${it.msg}")
onLogout()
null
}) {
onLogin(it)
val res = _bearer
res
}
res
}
}
}
}
init {
ApiRoute.disablePrinting()
}
private suspend fun <TReq : Any, TRes : Any> callRoute(using: HttpClient, route: ApiRoute<TReq, TRes>, body: TReq, wasInternal: Boolean = false): Either<ErrorResponse, TRes> {
return try {
val client = IClient.Default(using, _server ?: throw IllegalStateException("No server URL set."))
val res = route.call(client, body)
res
} catch (e: CancellationException) {
ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error()
} catch (e: Exception) {
// println("Call to ${route.pattern} ran into an exception")
ErrorResponse(e.message ?: "Unknown error.").error()
}
}
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
callRoute(_client, route, body)
fun onLogin(tokens: TokenResponse) {
_refreshToken = tokens.refresh
_accessToken = tokens.access
_refAcc.save(tokens.refresh)
}
fun onSelectServer(server: String) {
_server = server
_serverAcc.save(server)
}
fun onLogout() {
_refAcc.erase()
_refreshToken = null
_accessToken = null
}
fun onClearServer() {
_server = null
_serverAcc.erase()
}
fun isAuthenticated() = _bearer != null
fun getServer() = _server
fun forceGloballyInitialized() {}
fun shutdown() {
_authClient.close()
_client.close()
}
}
@@ -0,0 +1,7 @@
package com.jaytux.phoebench.clients.cli
fun main(args: Array<String>) {
Client.forceGloballyInitialized()
CLI.main(args)
Client.shutdown()
}
@@ -0,0 +1,44 @@
package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.Keyring
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid
object PersistentStorage {
private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli"
class StoredProperty<T>(
private val _key: String,
private val _toString: (T) -> String, private val _fromString: (String) -> T
) {
constructor(key: String, serializer: KSerializer<T>) : this(key,
{ json.encodeToString(serializer, it) },
{ json.decodeFromString(serializer, it) }
)
fun load(): T? = runCatching {
Keyring.create().use {
it.getPassword(SERVICE, _key)
}
}.getOrNull()?.let { _fromString(it) }
fun save(value: T) = runCatching {
val keyring = Keyring.create().use {
it.setPassword(SERVICE, _key, _toString(value))
}
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
fun erase() = runCatching {
val keyring = Keyring.create().use {
it.deletePassword(SERVICE, _key)
}
}.ignore()
}
fun refreshToken() = StoredProperty("refresh_token", serializer<Uuid>())
fun server() = StoredProperty("server_url", {it}, {it})
}
@@ -0,0 +1,37 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.fold
object ProjectHandlers {
fun list() {
tryAuthenticated {
Client.callRoute(Routes.home, EmptyRequest())
}.fold({ err ->
System.err.println("Could not get projects list: ${err.msg}")
}) {
System.err.println("Logged in as ${it.username}")
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
println("[${project.id}] ${project.owner.name}/${project.name} (${if(project.isPublic) "public" else "private"})")
}
}
}
fun details(find: CLI.Commands.Project.IProjectIdentification?) {
//
}
fun create(name: String?, isPublic: Boolean) {}
fun newLabel(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?) {}
fun newData(
project: CLI.Commands.Project.IProjectIdentification?,
label: CLI.Commands.Project.ILabelIdentification?,
warmup: CLI.Commands.Project.IData<Float>?,
measurement: CLI.Commands.Project.IData<Float>?,
unit: TimeUnit?
) {}
}
@@ -0,0 +1,41 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.clients.cli.CLI.Root
import com.jaytux.phoebench.common.*
import kotlinx.coroutines.runBlocking
import java.util.Locale.getDefault
fun <T> T.ignore(): Unit {}
class PromptException(val what: String) : Exception("Missing $what (perhaps you forgot to disable batch-mode?)")
fun <T> T?.maybePrompt(what: String, isPassword: Boolean = false, converter: (String) -> T): T {
if(this != null) return this
if(Root.batchMode) throw PromptException(what)
print("${what.replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() }}: ")
val got = if(isPassword) {
String(System.console().readPassword())
}
else readln()
return converter(got)
}
inline fun <reified V> tryAuthenticated(crossinline body: suspend () -> Either<ErrorResponse, V>) = runBlocking {
body().fold({
if(!Client.isAuthenticated()) {
System.err.println("Authentication failed. Please log in${Client.getServer()?.let { s -> " to $s" } ?: ""} again.")
if(!Root.batchMode) {
AuthHandlers.loginPrompt(Client.getServer(), null, null).fold({ err ->
err.error()
}) {
body()
}
}
else it.error()
} else it.error()
}) {
it.value()
}
}
@@ -0,0 +1 @@
org.slf4j.simpleLogger.defaultLogLevel = off
@@ -64,6 +64,7 @@ class AuthProvider private constructor() {
fun onLogout() {
_lock.withLock {
println("Erasing refresh token ${_refresh.value}")
_refresh.value = null
_access.value = null
_refreshAccessor.erase()
@@ -1,31 +1,14 @@
package com.jaytux.phoebench.clients
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient
import com.jaytux.phoebench.common.LogoutRequest
import com.jaytux.phoebench.common.RefreshRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend
import io.ktor.client.HttpClient
import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.authProvider
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.CancellationException
import com.jaytux.phoebench.common.*
import io.ktor.client.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.*
import kotlinx.atomicfu.locks.ReentrantLock
import kotlinx.atomicfu.locks.withLock
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.TimeZone
import kotlin.uuid.Uuid
class Client private constructor(private val _auth: AuthProvider) {
private val _authClient = platformClient {
@@ -36,6 +19,8 @@ class Client private constructor(private val _auth: AuthProvider) {
install(ContentNegotiation) { json() }
install(Auth) {
bearer {
cacheTokens = false
loadTokens {
val res = _auth.asBearer()
println("Client requested bearer tokens and got $res")
@@ -50,6 +35,7 @@ class Client private constructor(private val _auth: AuthProvider) {
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")
@@ -0,0 +1,85 @@
package com.jaytux.phoebench.clients
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.map
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.Padding
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Clock
import kotlin.time.Instant
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() }
}
val formatter = LocalDateTime.Format {
val noPad = Padding.NONE
day(noPad); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED); char(' '); year(noPad); char(' ')
hour(); char(':'); minute(); char(':'); second()
}
fun Instant.fmt(): String = this.toLocalDateTime(systemTz).format(formatter)
val systemTz = TimeZone.currentSystemDefault()
fun Instant.isPast() = compareTo(Clock.System.now()) <= 0
infix fun <T> T.nonEq(other: T) = if(this == other) null else this
inline fun <reified E, reified T> Either<E, T>.ignoreValue(): Either<E, Unit> = map {}
private const val HEX_ARRAY = "0123456789abcdef"
fun Color.hexString() = "#${(this.toArgb() and 0xFFFFFF).toHexString(HexFormat.UpperCase).takeLast(6)}"
fun randomColor() = Color(
red = Random.nextInt(0..255),
green = Random.nextInt(0..255),
blue = Random.nextInt(0..255)
)
fun Color.darken(factor: Float): Color {
val inv = 1.0f - factor
return copy(red = red * inv, green = green * inv, blue = blue * inv)
}
fun dualLerp(min: Instant, max: Instant, low: Float, high: Float): Pair<Instant, Instant> {
if(max == min || (low - high).absoluteValue < 1e-6f) return min to max
val delta = if(max > min) (max - min) else (min - max)
val lowTime = min + delta * low.toDouble()
val highTime = min + delta * high.toDouble()
return if(lowTime < highTime) (lowTime to highTime) else (highTime to lowTime)
}
fun Pair<Instant, Instant>.fmtRange() = "Between ${first.fmt()} and ${second.fmt()}"
infix fun Instant.inRange(range: Pair<Instant, Instant>) = range.first <= this && this <= range.second
fun Float.fmt(): String {
val integer = toInt()
val decimals = this - integer
val decInt = (decimals * 1000).roundToInt().toFloat() / 1000f
return (integer + decInt).toString()
}
@@ -147,6 +147,7 @@ class HomeVM(
_snack.send("No refresh token.")
}
}
reset()
_auth.onLogout()
}
}
@@ -290,7 +290,7 @@ fun AuthenticatedRoot() {
fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) -> Unit) {
Dialog(onDismissRequest = onCancel) {
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(8.dp).widthIn(min = 600.dp).width(IntrinsicSize.Min)) {
Column(Modifier.padding(8.dp).widthIn(min = 800.dp).width(IntrinsicSize.Max)) {
Text("Confirm logout", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp))
Text("Are you sure you want to log out?")
@@ -30,7 +30,8 @@ sealed class ApiRoute<TReq, TRes: Any>(val verb: String, val path: String, val e
open val bodySource = ReqBodySource.BODY
suspend fun extract(body: HttpResponse, meta: Any? = null): Either<ErrorResponse, TRes> = try {
println("Extracting response for $verb $pattern [${body.status.value}] as ${_resType.type.simpleName} (${_resType.kotlinType}${meta?.let { "; meta = $it" } ?: ""})")
if(_printExtraction)
println("Extracting response for $verb $pattern [${body.status.value}] as ${_resType.type.simpleName} (${_resType.kotlinType}${meta?.let { "; meta = $it" } ?: ""})")
if(body.status.isSuccess()) body.body<TRes>(_resType).value()
else body.body<ErrorResponse>().error()
} catch(e: Exception) {
@@ -131,6 +132,10 @@ sealed class ApiRoute<TReq, TRes: Any>(val verb: String, val path: String, val e
}
companion object {
private var _printExtraction = true
fun disablePrinting() { _printExtraction = false }
fun parseUuid(str: String?) = str?.let {
try {
Uuid.parse(it)
+5
View File
@@ -20,6 +20,8 @@ java-keystore = "1.0.4"
lucide = "2.2.1"
koala-plot = "0.12.0"
kolor-picker = "2.1.0"
shadow = "9.3.0"
clikt = "5.0.3"
[libraries]
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
@@ -82,10 +84,13 @@ kolor = { module = "com.kborowy:kolor-picker", version.ref = "kolor-picker" }
java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" }
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
+1 -1
View File
@@ -31,4 +31,4 @@ dependencyResolutionManagement {
}
rootProject.name = "PhoeBench"
include("server", "clients", "common")
include("server", "clients:compose", "clients:cli", "common")