Restructure, beginning of CLI client
This commit is contained in:
36 files changed
+694
-26
No files matched your search
@@ -5,6 +5,7 @@ plugins {
|
|||||||
alias(libs.plugins.serialization) apply false
|
alias(libs.plugins.serialization) apply false
|
||||||
alias(libs.plugins.jvm) apply false
|
alias(libs.plugins.jvm) apply false
|
||||||
alias(libs.plugins.ktor) apply false
|
alias(libs.plugins.ktor) apply false
|
||||||
|
alias(libs.plugins.shadow) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
@@ -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
|
||||||
File renamed without changes.
File renamed without changes.
+1
@@ -64,6 +64,7 @@ class AuthProvider private constructor() {
|
|||||||
|
|
||||||
fun onLogout() {
|
fun onLogout() {
|
||||||
_lock.withLock {
|
_lock.withLock {
|
||||||
|
println("Erasing refresh token ${_refresh.value}")
|
||||||
_refresh.value = null
|
_refresh.value = null
|
||||||
_access.value = null
|
_access.value = null
|
||||||
_refreshAccessor.erase()
|
_refreshAccessor.erase()
|
||||||
+10
-24
@@ -1,31 +1,14 @@
|
|||||||
package com.jaytux.phoebench.clients
|
package com.jaytux.phoebench.clients
|
||||||
|
|
||||||
import com.jaytux.phoebench.common.ApiRoute
|
import com.jaytux.phoebench.common.*
|
||||||
import com.jaytux.phoebench.common.Either
|
import io.ktor.client.*
|
||||||
import com.jaytux.phoebench.common.EmptyRequest
|
import io.ktor.client.plugins.auth.*
|
||||||
import com.jaytux.phoebench.common.ErrorResponse
|
import io.ktor.client.plugins.auth.providers.*
|
||||||
import com.jaytux.phoebench.common.IClient
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import com.jaytux.phoebench.common.LogoutRequest
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import com.jaytux.phoebench.common.RefreshRequest
|
import io.ktor.utils.io.*
|
||||||
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 kotlinx.atomicfu.locks.ReentrantLock
|
import kotlinx.atomicfu.locks.ReentrantLock
|
||||||
import kotlinx.atomicfu.locks.withLock
|
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) {
|
class Client private constructor(private val _auth: AuthProvider) {
|
||||||
private val _authClient = platformClient {
|
private val _authClient = platformClient {
|
||||||
@@ -36,6 +19,8 @@ class Client private constructor(private val _auth: AuthProvider) {
|
|||||||
install(ContentNegotiation) { json() }
|
install(ContentNegotiation) { json() }
|
||||||
install(Auth) {
|
install(Auth) {
|
||||||
bearer {
|
bearer {
|
||||||
|
cacheTokens = false
|
||||||
|
|
||||||
loadTokens {
|
loadTokens {
|
||||||
val res = _auth.asBearer()
|
val res = _auth.asBearer()
|
||||||
println("Client requested bearer tokens and got $res")
|
println("Client requested bearer tokens and got $res")
|
||||||
@@ -50,6 +35,7 @@ class Client private constructor(private val _auth: AuthProvider) {
|
|||||||
else {
|
else {
|
||||||
tryingRefresh = true
|
tryingRefresh = true
|
||||||
val ref = _auth.refresh.value ?: return@refreshTokens null
|
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({
|
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
|
||||||
if(it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
|
if(it.msg.startsWith(COROUTINE_CANCELLED)) println("Coro calling refresh was cancelled")
|
||||||
File renamed without changes.
File renamed without changes.
@@ -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()
|
||||||
|
}
|
||||||
+1
@@ -147,6 +147,7 @@ class HomeVM(
|
|||||||
_snack.send("No refresh token.")
|
_snack.send("No refresh token.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reset()
|
||||||
_auth.onLogout()
|
_auth.onLogout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -290,7 +290,7 @@ fun AuthenticatedRoot() {
|
|||||||
fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) -> Unit) {
|
fun ConfirmLogoutDialog(onCancel: () -> Unit, onLogout: (everywhere: Boolean) -> Unit) {
|
||||||
Dialog(onDismissRequest = onCancel) {
|
Dialog(onDismissRequest = onCancel) {
|
||||||
Surface(Modifier.padding(15.dp), shape = MaterialTheme.shapes.medium) {
|
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)
|
Text("Confirm logout", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text("Are you sure you want to log out?")
|
Text("Are you sure you want to log out?")
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -30,6 +30,7 @@ sealed class ApiRoute<TReq, TRes: Any>(val verb: String, val path: String, val e
|
|||||||
open val bodySource = ReqBodySource.BODY
|
open val bodySource = ReqBodySource.BODY
|
||||||
|
|
||||||
suspend fun extract(body: HttpResponse, meta: Any? = null): Either<ErrorResponse, TRes> = try {
|
suspend fun extract(body: HttpResponse, meta: Any? = null): Either<ErrorResponse, TRes> = try {
|
||||||
|
if(_printExtraction)
|
||||||
println("Extracting response for $verb $pattern [${body.status.value}] as ${_resType.type.simpleName} (${_resType.kotlinType}${meta?.let { "; meta = $it" } ?: ""})")
|
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()
|
if(body.status.isSuccess()) body.body<TRes>(_resType).value()
|
||||||
else body.body<ErrorResponse>().error()
|
else body.body<ErrorResponse>().error()
|
||||||
@@ -131,6 +132,10 @@ sealed class ApiRoute<TReq, TRes: Any>(val verb: String, val path: String, val e
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private var _printExtraction = true
|
||||||
|
|
||||||
|
fun disablePrinting() { _printExtraction = false }
|
||||||
|
|
||||||
fun parseUuid(str: String?) = str?.let {
|
fun parseUuid(str: String?) = str?.let {
|
||||||
try {
|
try {
|
||||||
Uuid.parse(it)
|
Uuid.parse(it)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ java-keystore = "1.0.4"
|
|||||||
lucide = "2.2.1"
|
lucide = "2.2.1"
|
||||||
koala-plot = "0.12.0"
|
koala-plot = "0.12.0"
|
||||||
kolor-picker = "2.1.0"
|
kolor-picker = "2.1.0"
|
||||||
|
shadow = "9.3.0"
|
||||||
|
clikt = "5.0.3"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
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" }
|
java-keystore = { module = "com.github.javakeyring:java-keyring", version.ref = "java-keystore" }
|
||||||
|
|
||||||
|
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", 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" }
|
jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
|
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
|
||||||
+1
-1
@@ -31,4 +31,4 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "PhoeBench"
|
rootProject.name = "PhoeBench"
|
||||||
include("server", "clients", "common")
|
include("server", "clients:compose", "clients:cli", "common")
|
||||||
Reference in new issue
Block a user