diff --git a/build.gradle.kts b/build.gradle.kts index 8c51a0a..b8d1f31 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { diff --git a/clients/cli/build.gradle.kts b/clients/cli/build.gradle.kts new file mode 100644 index 0000000..efa9788 --- /dev/null +++ b/clients/cli/build.gradle.kts @@ -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 { + manifest { + attributes["Main-Class"] = application.mainClass + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + } +} \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/AuthHandlers.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/AuthHandlers.kt new file mode 100644 index 0000000..4d2d957 --- /dev/null +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/AuthHandlers.kt @@ -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 { + 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 { + 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) + } + } + } +} \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt new file mode 100644 index 0000000..4fad811 --- /dev/null +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt @@ -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 + } + + annotation class IgnoreNestedCommand + + internal fun ICommandContainer.fromReflection(): Array { + 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 { + val nested = nestedCommands() + nested.forEach { + if(it is ICommandContainer) { + it.subcommands(*it.buildSubcommands()) + } + } + return nested + } + + object Commands : ICommandContainer { + override fun nestedCommands(): Array = 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 = 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 + data class DirectData(val data: List) : IData + data class FileData(val file: InputStream, val parse: (String) -> T) : IData { + companion object { + fun floatFile(file: InputStream) = FileData(file) { it.toFloat() } + } + } + + val finder by mutuallyExclusiveOptions( + 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() + + 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() + 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() + val label by mutuallyExclusiveOptions( + 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>( + 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>( + 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( + 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) { + try { + Root.subcommands(*Commands.buildSubcommands()).main(args) + } + catch(e: CancellationException) {} + catch(e: PromptException) { + System.err.println(e.message) + exitProcess(-1) + } + } +} \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Client.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Client.kt new file mode 100644 index 0000000..49e78f5 --- /dev/null +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Client.kt @@ -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 callRoute(using: HttpClient, route: ApiRoute, body: TReq, wasInternal: Boolean = false): Either { + 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 callRoute(route: ApiRoute, body: TReq): Either = + 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() + } +} \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Main.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Main.kt new file mode 100644 index 0000000..3470989 --- /dev/null +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Main.kt @@ -0,0 +1,7 @@ +package com.jaytux.phoebench.clients.cli + +fun main(args: Array) { + Client.forceGloballyInitialized() + CLI.main(args) + Client.shutdown() +} \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/PersistentStorage.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/PersistentStorage.kt new file mode 100644 index 0000000..7b7a274 --- /dev/null +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/PersistentStorage.kt @@ -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( + private val _key: String, + private val _toString: (T) -> String, private val _fromString: (String) -> T + ) { + constructor(key: String, serializer: KSerializer) : 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()) + + fun server() = StoredProperty("server_url", {it}, {it}) +} \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt new file mode 100644 index 0000000..00e1bda --- /dev/null +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt @@ -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?, + measurement: CLI.Commands.Project.IData?, + unit: TimeUnit? + ) {} +} \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Util.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Util.kt new file mode 100644 index 0000000..bb3be23 --- /dev/null +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Util.kt @@ -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.ignore(): Unit {} + +class PromptException(val what: String) : Exception("Missing $what (perhaps you forgot to disable batch-mode?)") + +fun 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 tryAuthenticated(crossinline body: suspend () -> Either) = 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() + } +} \ No newline at end of file diff --git a/clients/cli/src/main/resources/simplelogger.properties b/clients/cli/src/main/resources/simplelogger.properties new file mode 100644 index 0000000..6b7d133 --- /dev/null +++ b/clients/cli/src/main/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.defaultLogLevel = off \ No newline at end of file diff --git a/clients/build.gradle.kts b/clients/compose/build.gradle.kts similarity index 100% rename from clients/build.gradle.kts rename to clients/compose/build.gradle.kts diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt similarity index 98% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt index 3101ba5..13a5c36 100644 --- a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/AuthProvider.kt @@ -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() diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt similarity index 76% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt index 0956bd2..a4d087e 100644 --- a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Client.kt @@ -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") diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/SnackProvider.kt diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt new file mode 100644 index 0000000..c0d4261 --- /dev/null +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/Util.kt @@ -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 MutableState.immutable(): State = this +fun T.ignore() {} + +inline fun 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.nonEq(other: T) = if(this == other) null else this + +inline fun Either.ignoreValue(): Either = 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 { + 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.fmtRange() = "Between ${first.fmt()} and ${second.fmt()}" + +infix fun Instant.inRange(range: Pair) = 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() +} diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt similarity index 99% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt index 5e47fdd..7236795 100644 --- a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/HomeVM.kt @@ -147,6 +147,7 @@ class HomeVM( _snack.send("No refresh token.") } } + reset() _auth.onLogout() } } diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IHomeRepo.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/IProjectRepo.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/MutableStateSet.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt similarity index 99% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt index bf054f0..db759ae 100644 --- a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/HomeView.kt @@ -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?") diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NoFeedbackIndication.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt diff --git a/clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt similarity index 100% rename from clients/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt rename to clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/Widgets.kt diff --git a/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt similarity index 100% rename from clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt rename to clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt diff --git a/clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt similarity index 100% rename from clients/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt rename to clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt diff --git a/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt b/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt similarity index 100% rename from clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt rename to clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/Main.kt diff --git a/clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt b/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt similarity index 100% rename from clients/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt rename to clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt diff --git a/clients/src/wasmJsMain/resources/index.html b/clients/compose/src/wasmJsMain/resources/index.html similarity index 100% rename from clients/src/wasmJsMain/resources/index.html rename to clients/compose/src/wasmJsMain/resources/index.html diff --git a/clients/src/wasmJsMain/resources/styles.css b/clients/compose/src/wasmJsMain/resources/styles.css similarity index 100% rename from clients/src/wasmJsMain/resources/styles.css rename to clients/compose/src/wasmJsMain/resources/styles.css diff --git a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt index af3043b..e38c921 100644 --- a/common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt +++ b/common/src/commonMain/kotlin/com/jaytux/phoebench/common/ApiRoute.kt @@ -30,7 +30,8 @@ sealed class ApiRoute(val verb: String, val path: String, val e open val bodySource = ReqBodySource.BODY suspend fun extract(body: HttpResponse, meta: Any? = null): Either = 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(_resType).value() else body.body().error() } catch(e: Exception) { @@ -131,6 +132,10 @@ sealed class ApiRoute(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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9990f0..f167fa7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e5bd4e1..d631970 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,4 +31,4 @@ dependencyResolutionManagement { } rootProject.name = "PhoeBench" -include("server", "clients", "common") \ No newline at end of file +include("server", "clients:compose", "clients:cli", "common") \ No newline at end of file