initial commit - source

This commit is contained in:
Hartmut Bauer committed 2026-02-03 22:36:35 +01:00
1 parent 003b5501f0
commit e89bb29070
45 files changed
+5997

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.koin.compiler)
alias(masterLibs.plugins.versionPlugin)
}
group = "net.keinekekse.app"
version = libs.versions.composeAppVersion.get()
description = "Compose Multiplatform Test-Client"
extra["displayname"] = "Books App"
extra["copyright"] = "Copyright (c) 2026 Hartmut Bauer"
kotlin {
jvm()
// @OptIn(ExperimentalWasmDsl::class)
// wasmJs {
// browser()
// binaries.executable()
// }
sourceSets {
commonMain.dependencies {
implementation(masterLibs.utilLibrary)
implementation(libs.bundles.kotlin.compose)
implementation(libs.ktor.client)
implementation(libs.ktor.clientCio)
implementation(libs.ktor.clientAuth)
implementation(libs.ktor.clientLogging)
implementation(libs.ktor.clientContentNegotiation)
implementation(libs.ktor.serializationJson)
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverCio)
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.annotations)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// implementation(libs.koin.compose.viewmodel.navigation)
implementation(projects.shared)
}
// commonTest.dependencies {
// implementation(libs.kotlin.test)
// }
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.ktor.clientJava) // ??
// implementation(libs.ktor.serverCio)
// implementation(libs.ktor.serverCore)
// implementation(libs.ktor.serverNetty)
implementation(libs.ktor.serverAuthJwt) // nicht f. js, wasmJs
}
// wasmJsMain.dependencies {
// implementation(libs.ktor.clientJs)
// }
}
jvmToolchain(masterLibs.versions.javaVersion.get().toInt())
}
compose.desktop {
application {
mainClass = "net.keinekekse.app.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = project.name
packageVersion = project.version.toString()
}
}
}
+3
View File
@@ -0,0 +1,3 @@
#auto generated
#Tue Feb 03 00:28:14 CET 2026
build.number=174
@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
@@ -0,0 +1,86 @@
package net.keinekekse.app
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.keinekekse.app.books.generated.resources.Res
import net.keinekekse.app.books.generated.resources.compose_multiplatform
import net.keinekekse.app.books.ui.theme.AppTheme
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.koinInject
import org.koin.core.annotation.Single
@Composable
@Preview
fun App() {
/*MaterialTheme*/
/*
eigenes Theme -> https://material-foundation.github.io/material-theme-builder/
den Download in den source code übernehmen (in Theme.kt sind Anpassungen nötig)
per default hat die generierte Klasse den Namen AppTheme.
*/
AppTheme {
val test = koinInject<TestAuth>()
var showContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
Button(onClick = {
CoroutineScope(Dispatchers.Default).launch {
println("requesting '/test'")
test.request("/test")
}
}) {
Text("Test Token Refresh")
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
}
}
}
}
}
@Single
class TestAuth(val httpClient: HttpClient) {
private val srv = "https://kas.keinekekse.net/"
suspend fun request(url: String) {
println("connecting")
val response = httpClient.get(srv + url)
println("call to $url")
println(response.status)
println(response.bodyAsText())
}
}
@@ -0,0 +1,18 @@
package net.keinekekse.app.books
import net.keinekekse.version.Version
object VERSION: Version {
override val version = "1.0.0"
override val fullVersion = "1.0.0+build.174"
override val major = 1
override val minor = 0
override val patch = 0
override val preRelease = ""
override val buildMetadata = "build.174"
override val buildDate = "2026-02-02T23:28:14.786916Z"
override val displayName = "Books App"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "books"
override val description = "Compose Multiplatform Test-Client"
}
@@ -0,0 +1,6 @@
package net.keinekekse.app.books.di
import org.koin.core.annotation.KoinApplication
@KoinApplication
object KoinApp
@@ -0,0 +1,13 @@
package net.keinekekse.app.books.di
import org.koin.core.annotation.*
@Module
@Configuration
@ComponentScan("net.keinekekse.app")
class KoinModules {
@Named("AppString")
@Factory
fun provideSomeString() = "Injected App string"
}
@@ -0,0 +1,219 @@
package net.keinekekse.app.books.ui.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF1C6585)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFC3E8FF)
val onPrimaryContainerLight = Color(0xFF004C69)
val secondaryLight = Color(0xFF4E616D)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFD1E5F4)
val onSecondaryContainerLight = Color(0xFF364955)
val tertiaryLight = Color(0xFF605A7D)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFE6DEFF)
val onTertiaryContainerLight = Color(0xFF484264)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFF6FAFE)
val onBackgroundLight = Color(0xFF181C1F)
val surfaceLight = Color(0xFFF6FAFE)
val onSurfaceLight = Color(0xFF181C1F)
val surfaceVariantLight = Color(0xFFDCE3E9)
val onSurfaceVariantLight = Color(0xFF41484D)
val outlineLight = Color(0xFF71787D)
val outlineVariantLight = Color(0xFFC0C7CD)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2C3134)
val inverseOnSurfaceLight = Color(0xFFEDF1F5)
val inversePrimaryLight = Color(0xFF8FCEF3)
val surfaceDimLight = Color(0xFFD6DADF)
val surfaceBrightLight = Color(0xFFF6FAFE)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF0F4F8)
val surfaceContainerLight = Color(0xFFEAEEF2)
val surfaceContainerHighLight = Color(0xFFE5E9ED)
val surfaceContainerHighestLight = Color(0xFFDFE3E7)
val primaryLightMediumContrast = Color(0xFF003B51)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val primaryContainerLightMediumContrast = Color(0xFF307495)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF263844)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF5D707C)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF373252)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF6F688C)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF740006)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFF6FAFE)
val onBackgroundLightMediumContrast = Color(0xFF181C1F)
val surfaceLightMediumContrast = Color(0xFFF6FAFE)
val onSurfaceLightMediumContrast = Color(0xFF0D1215)
val surfaceVariantLightMediumContrast = Color(0xFFDCE3E9)
val onSurfaceVariantLightMediumContrast = Color(0xFF30373C)
val outlineLightMediumContrast = Color(0xFF4C5359)
val outlineVariantLightMediumContrast = Color(0xFF676E73)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF2C3134)
val inverseOnSurfaceLightMediumContrast = Color(0xFFEDF1F5)
val inversePrimaryLightMediumContrast = Color(0xFF8FCEF3)
val surfaceDimLightMediumContrast = Color(0xFFC3C7CB)
val surfaceBrightLightMediumContrast = Color(0xFFF6FAFE)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF0F4F8)
val surfaceContainerLightMediumContrast = Color(0xFFE5E9ED)
val surfaceContainerHighLightMediumContrast = Color(0xFFD9DDE1)
val surfaceContainerHighestLightMediumContrast = Color(0xFFCED2D6)
val primaryLightHighContrast = Color(0xFF003043)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF004F6C)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF1C2E39)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF394C57)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF2D2847)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF4B4566)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF600004)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF98000A)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFF6FAFE)
val onBackgroundLightHighContrast = Color(0xFF181C1F)
val surfaceLightHighContrast = Color(0xFFF6FAFE)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFDCE3E9)
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
val outlineLightHighContrast = Color(0xFF262D32)
val outlineVariantLightHighContrast = Color(0xFF434A4F)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF2C3134)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFF8FCEF3)
val surfaceDimLightHighContrast = Color(0xFFB5B9BD)
val surfaceBrightLightHighContrast = Color(0xFFF6FAFE)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFEDF1F5)
val surfaceContainerLightHighContrast = Color(0xFFDFE3E7)
val surfaceContainerHighLightHighContrast = Color(0xFFD1D5D9)
val surfaceContainerHighestLightHighContrast = Color(0xFFC3C7CB)
val primaryDark = Color(0xFF8FCEF3)
val onPrimaryDark = Color(0xFF003549)
val primaryContainerDark = Color(0xFF004C69)
val onPrimaryContainerDark = Color(0xFFC3E8FF)
val secondaryDark = Color(0xFFB5C9D7)
val onSecondaryDark = Color(0xFF20333E)
val secondaryContainerDark = Color(0xFF364955)
val onSecondaryContainerDark = Color(0xFFD1E5F4)
val tertiaryDark = Color(0xFFCAC1EA)
val onTertiaryDark = Color(0xFF322C4C)
val tertiaryContainerDark = Color(0xFF484264)
val onTertiaryContainerDark = Color(0xFFE6DEFF)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF0F1417)
val onBackgroundDark = Color(0xFFDFE3E7)
val surfaceDark = Color(0xFF0F1417)
val onSurfaceDark = Color(0xFFDFE3E7)
val surfaceVariantDark = Color(0xFF41484D)
val onSurfaceVariantDark = Color(0xFFC0C7CD)
val outlineDark = Color(0xFF8A9297)
val outlineVariantDark = Color(0xFF41484D)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFDFE3E7)
val inverseOnSurfaceDark = Color(0xFF2C3134)
val inversePrimaryDark = Color(0xFF1C6585)
val surfaceDimDark = Color(0xFF0F1417)
val surfaceBrightDark = Color(0xFF353A3D)
val surfaceContainerLowestDark = Color(0xFF0A0F12)
val surfaceContainerLowDark = Color(0xFF181C1F)
val surfaceContainerDark = Color(0xFF1C2023)
val surfaceContainerHighDark = Color(0xFF262B2E)
val surfaceContainerHighestDark = Color(0xFF313539)
val primaryDarkMediumContrast = Color(0xFFB5E2FF)
val onPrimaryDarkMediumContrast = Color(0xFF00293A)
val primaryContainerDarkMediumContrast = Color(0xFF5898BB)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFCBDFED)
val onSecondaryDarkMediumContrast = Color(0xFF152832)
val secondaryContainerDarkMediumContrast = Color(0xFF8093A0)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFE0D7FF)
val onTertiaryDarkMediumContrast = Color(0xFF272140)
val tertiaryContainerDarkMediumContrast = Color(0xFF938CB2)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFD2CC)
val onErrorDarkMediumContrast = Color(0xFF540003)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF0F1417)
val onBackgroundDarkMediumContrast = Color(0xFFDFE3E7)
val surfaceDarkMediumContrast = Color(0xFF0F1417)
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkMediumContrast = Color(0xFF41484D)
val onSurfaceVariantDarkMediumContrast = Color(0xFFD6DDE3)
val outlineDarkMediumContrast = Color(0xFFACB3B9)
val outlineVariantDarkMediumContrast = Color(0xFF8A9197)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFDFE3E7)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF262B2E)
val inversePrimaryDarkMediumContrast = Color(0xFF004E6A)
val surfaceDimDarkMediumContrast = Color(0xFF0F1417)
val surfaceBrightDarkMediumContrast = Color(0xFF404549)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF04080B)
val surfaceContainerLowDarkMediumContrast = Color(0xFF1A1E21)
val surfaceContainerDarkMediumContrast = Color(0xFF24282C)
val surfaceContainerHighDarkMediumContrast = Color(0xFF2E3337)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3A3E42)
val primaryDarkHighContrast = Color(0xFFE1F2FF)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFF8BCAEF)
val onPrimaryContainerDarkHighContrast = Color(0xFF000D15)
val secondaryDarkHighContrast = Color(0xFFE1F2FF)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFB1C5D3)
val onSecondaryContainerDarkHighContrast = Color(0xFF000D15)
val tertiaryDarkHighContrast = Color(0xFFF3EDFF)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFFC6BDE5)
val onTertiaryContainerDarkHighContrast = Color(0xFF0C0625)
val errorDarkHighContrast = Color(0xFFFFECE9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
val onErrorContainerDarkHighContrast = Color(0xFF220001)
val backgroundDarkHighContrast = Color(0xFF0F1417)
val onBackgroundDarkHighContrast = Color(0xFFDFE3E7)
val surfaceDarkHighContrast = Color(0xFF0F1417)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF41484D)
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
val outlineDarkHighContrast = Color(0xFFEAF1F7)
val outlineVariantDarkHighContrast = Color(0xFFBCC3C9)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFDFE3E7)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF004E6A)
val surfaceDimDarkHighContrast = Color(0xFF0F1417)
val surfaceBrightDarkHighContrast = Color(0xFF4C5154)
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
val surfaceContainerLowDarkHighContrast = Color(0xFF1C2023)
val surfaceContainerDarkHighContrast = Color(0xFF2C3134)
val surfaceContainerHighDarkHighContrast = Color(0xFF373C3F)
val surfaceContainerHighestDarkHighContrast = Color(0xFF43474B)
@@ -0,0 +1,278 @@
package net.keinekekse.app.books.ui.theme
//import android.os.Build
//import androidx.compose.material3.dynamicDarkColorScheme
//import androidx.compose.material3.dynamicLightColorScheme
//import androidx.compose.ui.platform.LocalContext
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
private val mediumContrastLightColorScheme = lightColorScheme(
primary = primaryLightMediumContrast,
onPrimary = onPrimaryLightMediumContrast,
primaryContainer = primaryContainerLightMediumContrast,
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
secondary = secondaryLightMediumContrast,
onSecondary = onSecondaryLightMediumContrast,
secondaryContainer = secondaryContainerLightMediumContrast,
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
tertiary = tertiaryLightMediumContrast,
onTertiary = onTertiaryLightMediumContrast,
tertiaryContainer = tertiaryContainerLightMediumContrast,
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
error = errorLightMediumContrast,
onError = onErrorLightMediumContrast,
errorContainer = errorContainerLightMediumContrast,
onErrorContainer = onErrorContainerLightMediumContrast,
background = backgroundLightMediumContrast,
onBackground = onBackgroundLightMediumContrast,
surface = surfaceLightMediumContrast,
onSurface = onSurfaceLightMediumContrast,
surfaceVariant = surfaceVariantLightMediumContrast,
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
outline = outlineLightMediumContrast,
outlineVariant = outlineVariantLightMediumContrast,
scrim = scrimLightMediumContrast,
inverseSurface = inverseSurfaceLightMediumContrast,
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
inversePrimary = inversePrimaryLightMediumContrast,
surfaceDim = surfaceDimLightMediumContrast,
surfaceBright = surfaceBrightLightMediumContrast,
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
surfaceContainer = surfaceContainerLightMediumContrast,
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
)
private val highContrastLightColorScheme = lightColorScheme(
primary = primaryLightHighContrast,
onPrimary = onPrimaryLightHighContrast,
primaryContainer = primaryContainerLightHighContrast,
onPrimaryContainer = onPrimaryContainerLightHighContrast,
secondary = secondaryLightHighContrast,
onSecondary = onSecondaryLightHighContrast,
secondaryContainer = secondaryContainerLightHighContrast,
onSecondaryContainer = onSecondaryContainerLightHighContrast,
tertiary = tertiaryLightHighContrast,
onTertiary = onTertiaryLightHighContrast,
tertiaryContainer = tertiaryContainerLightHighContrast,
onTertiaryContainer = onTertiaryContainerLightHighContrast,
error = errorLightHighContrast,
onError = onErrorLightHighContrast,
errorContainer = errorContainerLightHighContrast,
onErrorContainer = onErrorContainerLightHighContrast,
background = backgroundLightHighContrast,
onBackground = onBackgroundLightHighContrast,
surface = surfaceLightHighContrast,
onSurface = onSurfaceLightHighContrast,
surfaceVariant = surfaceVariantLightHighContrast,
onSurfaceVariant = onSurfaceVariantLightHighContrast,
outline = outlineLightHighContrast,
outlineVariant = outlineVariantLightHighContrast,
scrim = scrimLightHighContrast,
inverseSurface = inverseSurfaceLightHighContrast,
inverseOnSurface = inverseOnSurfaceLightHighContrast,
inversePrimary = inversePrimaryLightHighContrast,
surfaceDim = surfaceDimLightHighContrast,
surfaceBright = surfaceBrightLightHighContrast,
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
surfaceContainerLow = surfaceContainerLowLightHighContrast,
surfaceContainer = surfaceContainerLightHighContrast,
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
)
private val mediumContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkMediumContrast,
onPrimary = onPrimaryDarkMediumContrast,
primaryContainer = primaryContainerDarkMediumContrast,
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
secondary = secondaryDarkMediumContrast,
onSecondary = onSecondaryDarkMediumContrast,
secondaryContainer = secondaryContainerDarkMediumContrast,
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
tertiary = tertiaryDarkMediumContrast,
onTertiary = onTertiaryDarkMediumContrast,
tertiaryContainer = tertiaryContainerDarkMediumContrast,
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
error = errorDarkMediumContrast,
onError = onErrorDarkMediumContrast,
errorContainer = errorContainerDarkMediumContrast,
onErrorContainer = onErrorContainerDarkMediumContrast,
background = backgroundDarkMediumContrast,
onBackground = onBackgroundDarkMediumContrast,
surface = surfaceDarkMediumContrast,
onSurface = onSurfaceDarkMediumContrast,
surfaceVariant = surfaceVariantDarkMediumContrast,
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
outline = outlineDarkMediumContrast,
outlineVariant = outlineVariantDarkMediumContrast,
scrim = scrimDarkMediumContrast,
inverseSurface = inverseSurfaceDarkMediumContrast,
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
inversePrimary = inversePrimaryDarkMediumContrast,
surfaceDim = surfaceDimDarkMediumContrast,
surfaceBright = surfaceBrightDarkMediumContrast,
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
surfaceContainer = surfaceContainerDarkMediumContrast,
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
)
private val highContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkHighContrast,
onPrimary = onPrimaryDarkHighContrast,
primaryContainer = primaryContainerDarkHighContrast,
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
secondary = secondaryDarkHighContrast,
onSecondary = onSecondaryDarkHighContrast,
secondaryContainer = secondaryContainerDarkHighContrast,
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
tertiary = tertiaryDarkHighContrast,
onTertiary = onTertiaryDarkHighContrast,
tertiaryContainer = tertiaryContainerDarkHighContrast,
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
error = errorDarkHighContrast,
onError = onErrorDarkHighContrast,
errorContainer = errorContainerDarkHighContrast,
onErrorContainer = onErrorContainerDarkHighContrast,
background = backgroundDarkHighContrast,
onBackground = onBackgroundDarkHighContrast,
surface = surfaceDarkHighContrast,
onSurface = onSurfaceDarkHighContrast,
surfaceVariant = surfaceVariantDarkHighContrast,
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
outline = outlineDarkHighContrast,
outlineVariant = outlineVariantDarkHighContrast,
scrim = scrimDarkHighContrast,
inverseSurface = inverseSurfaceDarkHighContrast,
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
inversePrimary = inversePrimaryDarkHighContrast,
surfaceDim = surfaceDimDarkHighContrast,
surfaceBright = surfaceBrightDarkHighContrast,
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
surfaceContainer = surfaceContainerDarkHighContrast,
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
@Immutable
data class ColorFamily(
val color: Color,
val onColor: Color,
val colorContainer: Color,
val onColorContainer: Color,
)
val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable() () -> Unit,
) {
val colorScheme = when {
// *hb* das gibt Probleme außerhalb von Android
// dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
// val context = LocalContext.current
// if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
// }
darkTheme -> darkScheme
else -> lightScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
@@ -0,0 +1,5 @@
package net.keinekekse.app.books.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()
@@ -0,0 +1,507 @@
package net.keinekekse.app.auth
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.network.tls.extensions.HashAlgorithm
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.Digest
import io.ktor.util.sha1
import io.ktor.utils.io.charsets.Charsets
import io.ktor.utils.io.core.Closeable
import io.ktor.utils.io.core.toByteArray
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.awt.Desktop
import java.net.Socket
import kotlin.io.encoding.Base64
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
import kotlin.use
class Authenticator2(
keycloakServerHostname: String,
keycloakRealm: String,
keycloakClient: String,
) : Closeable {
private val httpClient = HttpClient {
install(ContentNegotiation) { json() }
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
private val keyc =
KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
override fun close() = httpClient.close()
suspend fun authenticate(): AuthResult {
val config = AuthRun(keyc)
if (!testServerConnection(config)) {
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
}
// var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
try {
server = createCallbackServer(config).start(wait = false)
val port = server.engine.resolvedConnectors().first().port
config.buildRedirectUri(port)
if (!waitForServer(port)) {
return AuthResult.Fatal(
"ERR008",
"Schwerer Fehler",
"Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich."
)
}
openAuthorizationInBrowser(config)
return withTimeout(5.minutes) {
val resolvedTokens = config.resultDeferred.await()
resolvedTokens
}
} catch (e: Exception) {
return AuthResult.Error(
"ERR002",
"Bei der Anmeldung ist ein Fehler aufgetreten",
e.message ?: "Unbekannter Fehler"
)
} finally {
server?.stop(1000, 2000)
}
}
suspend fun requestRefreshToken(tokens: BearerTokens): AuthResult {
val config = AuthRun(keyc)
val response = httpClient.submitForm(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.tokenEndpoint
}.toString(),
formParameters = parameters {
append("grant_type", "refresh_token")
append("client_id", config.kc.keycloakClient)
append("refresh_token", tokens.refreshToken ?: "")
}
)
// {
// headers { append("Authorization", "Bearer ${tokens.accessToken}") }
// }
return validateRequestTokenResponse(response)
}
private fun isPortOpen(port: Int): Boolean =
try {
// TODO Socket ist nicht portabel (JVM only)
Socket(KeycloakConfig.LOCALHOST, port).use { true }
} catch (e: Exception) {
// Port is closed or unreachable
false
}
private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
repeat(timeoutSeconds * 10) {
if (isPortOpen(port)) return true
delay(100)
}
return false
}
private fun openAuthorizationInBrowser(config: AuthRun) {
val authorizationUrl = buildAuthorizationUrl(config)
// TODO momentan nur für JVM, Rest ist noch zu eruieren
if (!Desktop.isDesktopSupported()) {
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
}
val desktop = Desktop.getDesktop()
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
}
desktop.browse(authorizationUrl.toURI())
}
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.authorizationEndpoint
parameters.append("response_type", "code")
parameters.append("client_id", config.kc.keycloakClient)
parameters.append("redirect_uri", config.redirectUri)
parameters.append("scope", KeycloakConfig.SCOPE)
parameters.append("state", config.csrfProtectionState)
parameters.append("code_challenge", config.codeChallenge)
parameters.append("code_challenge_method", KeycloakConfig.PKCE_METHOD)
}
private fun createCallbackServer(config: AuthRun) =
embeddedServer(CIO, port = 0, host = KeycloakConfig.LOCALHOST) {
routing {
get(KeycloakConfig.CALLBACK_PATH) {
// val serverPort = engine.resolvedConnectors().first().port
val code = call.request.queryParameters["code"]
var authResult =
validateAuthorizationRequest(call.request, config.csrfProtectionState)
when (authResult) {
is AuthResult.Success -> {
if (code == null) {
authResult = AuthResult.Fatal(
"ERR003",
"Schwerer Fehler",
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
)
} else {
authResult = requestTokens(config, code)
}
}
else -> {}
}
var text = ""
var status = HttpStatusCode.BadRequest
when (authResult) {
is AuthResult.Success -> {
text =
"<html><body><h1>Login erfolgreich! Du kannst das Fenster schließen.</h1></body></html>"
status = HttpStatusCode.OK
}
is AuthResult.Error -> {
text = "<html><body><h1>Fehler bei der Anmeldung.</h1><h2>${
htmlEscape(authResult.message)
}</h2><h3>${htmlEscape(authResult.details ?: "")}</h3></body></html>"
status = HttpStatusCode.BadRequest
}
is AuthResult.Fatal -> {
text =
"<html><body><h1>Schwerer Fehler.</h1><h2>${htmlEscape(authResult.message)}</h2><h3>${
htmlEscape(authResult.details ?: "")
}</h3></body></html>"
status = HttpStatusCode.InternalServerError
}
}
call.respondText(text, contentType = ContentType.Text.Html, status = status)
if (!config.resultDeferred.isCompleted)
config.resultDeferred.complete(authResult)
}
}
}
private fun htmlEscape(value: String): String =
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
private fun validateAuthorizationRequest(
request: RoutingRequest,
csrfProtectionState: String,
): AuthResult {
/*
- state != csrfProt -> Fehler: Kommunikation mit dem Anmeldeserver hat nicht geklappt,
könnte auf Malware hindeuten, Abbruch
throw Fatal error authenticating user -> exit app
- wenn error =! null -> error + error_description anzeigen
rc NULL == keine auth -> app muss entscheiden
- code == null -> Fehler: Server liefert ungültige Daten,
könnte auf Probleme bei der Netzwerkverbindung oder beim Anmeldeserver hindeuten
rc NULL -> app
*/
if (request.queryParameters["error"] != null) {
return AuthResult.Error(
"ERR004",
"Fehler bei der Anmeldung",
"${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}"
)
}
if (request.queryParameters["state"] != csrfProtectionState) {
return AuthResult.Fatal(
"ERR005",
"Schwerer Fehler",
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
)
}
return AuthResult.Success(BearerTokens("", "")) // hier ist nur Success wichtig
}
/* Antwort von Keycloak:
{
"access_token": "eyJhbG ...
"expires_in": 300,
"refresh_expires_in": 0,
"refresh_token": "eyJhb ...
"token_type": "Bearer",
"id_token": "eyJ ...
"not-before-policy": 1769817270,
"session_state": "YhlNjIuW-lk1aQwwD9xm4edJ",
"scope": "openid profile email"
}
*/
@Serializable
data class TokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("expires_in") val expiresIn: Int,
@SerialName("refresh_expires_in") val refreshExpiresIn: Int,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("token_type") val tokenType: String,
@SerialName("id_token") val idToken: String?,
@SerialName("not-before-policy") val notBeforePolicy: Int?,
@SerialName("session_state") val sessionState: String?,
val scope: String,
)
@Serializable
data class TokenErrorResponse(
@SerialName("error") val error: String,
@SerialName("error_description") val errorDescription: String?,
@SerialName("session_state") val sessionState: String?,
)
private suspend fun requestTokens(config: AuthRun, authCode: String): AuthResult {
val response: HttpResponse = httpClient.submitForm(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.tokenEndpoint
}.toString(),
formParameters = parameters {
append("grant_type", "authorization_code")
append("client_id", config.kc.keycloakClient)
append("code", authCode)
append("redirect_uri", config.redirectUri)
append("code_verifier", config.codeVerifier)
}
)
return validateRequestTokenResponse(response)
}
suspend fun validateRequestTokenResponse(response: HttpResponse): AuthResult {
if (response.status != HttpStatusCode.OK) {
try {
val errorResponse = response.body<TokenErrorResponse>()
return AuthResult.Error(
"ERR006",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}"
)
} catch (e: Exception) {
// Fehler beim JSON-Konvertieren
val errorResponse = response.bodyAsText()
return AuthResult.Error(
"ERR006",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
errorResponse
)
}
}
val tokenResponse: TokenResponse = try {
response.body<TokenResponse>()
} catch (e: Exception) {
// Fehler beim JSON-Konvertieren
return AuthResult.Fatal(
"ERR007",
"Schwerer Fehler",
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
)
}
return AuthResult.Success(
BearerTokens(
tokenResponse.accessToken,
tokenResponse.refreshToken
)
)
}
// suspend fun requestUserInfo(
// accessToken: String,
// ) {
// val config = AuthenticationConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
//
// val response = httpClient.get(
// url = buildUrl {
// protocol = URLProtocol.HTTPS
// host = config.keycloakServerHostname
// path(config.userinfoEndpoint)
// },
//// formParameters = parameters {
//// append("grant_type", "refresh_token")
//// append("client_id", config.keycloakClient)
//// append("refresh_token", refreshToken)
//// }
// ) {
// headers { append("Authorization", "Bearer $accessToken") }
// }
//
// println("Status: ${response.status}")
// println("Body: ${response.bodyAsText()}")
//
// }
private suspend fun testServerConnection(config: AuthRun): Boolean {
val response: HttpResponse = httpClient.request(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.certificatesEndpoint
}
)
return response.status.value < 400
}
/*
access token:
{
"exp": 1769839803,
"iat": 1769839503,
"auth_time": 1769839502,
"jti": "onrtac:f0ea9436-470d-7d1d-fd9d-b43d2f075c46",
"iss": "https://kec.keinekekse.net/realms/app-server-realm",
"aud": "account",
"sub": "b5b40fa3-468f-443c-8eb0-e220bdd353bc",
"typ": "Bearer",
"azp": "pkce-book-app",
"sid": "n_TeXWIUSV66OkYwoc0D1og9",
"acr": "1",
"allowed-origins": [
"http://127.0.0.1",
"https://kas.keinekekse.net"
],
"realm_access": {
"roles": [
"offline_access",
"default-roles-app-server-realm",
"uma_authorization",
"book-admin"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid profile email",
"email_verified": true,
"name": "Hartmut Bauer",
"preferred_username": "hb42",
"given_name": "Hartmut",
"family_name": "Bauer",
"email": "hb242@pm.me"
}
-> realm_access:
implementation(libs.ktor.serverAuthJwt)
val decod = JWT().decodeJwt(tokenResponse.accessToken!!)
// claims, die nur String enthalten: getClaim("claim-name")
// für andere: "{" -> asMap<String, Any> => cast!
// "[" -> asList(Class<T>)
val roles: List<String> = decod.getClaim("realm_access").asMap()["roles"] as List<String>
roles.forEach { println(it) }
refreshTokens {
val refreshTokenInfo: TokenInfo = client.submitForm(
url = "https://accounts.google.com/o/oauth2/token",
formParameters = parameters {
append("grant_type", "refresh_token")
append("client_id", System.getenv("GOOGLE_CLIENT_ID"))
append("refresh_token", oldTokens?.refreshToken ?: "")
}
) { markAsRefreshTokenRequest() }.body()
bearerTokenStorage.add(BearerTokens(refreshTokenInfo.accessToken, oldTokens?.refreshToken!!))
bearerTokenStorage.last()
}
*/
}
sealed interface AuthResult {
data class Success(val tokens: BearerTokens) : AuthResult
data class Error(val code: String, val message: String, val details: String? = null) :
AuthResult
data class Fatal(val code: String, val message: String, val details: String? = null) :
AuthResult
}
internal class KeycloakConfig(
val keycloakServerHostname: String,
keycloakRealm: String,
val keycloakClient: String,
) {
companion object {
const val LOCALHOST = "127.0.0.1"
const val CALLBACK_PATH = "/callback"
const val SCOPE = "openid"
const val PKCE_METHOD = "S256"
fun generateRandomString(): String {
val bytes = ByteArray(64)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
fun generateCodeChallenge(codeVerifier: String): String {
val bytes = codeVerifier.toByteArray(kotlin.text.Charsets.US_ASCII)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(diges
}
}
private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect"
val authorizationEndpoint = "$keycloakBaseUrl/auth"
val tokenEndpoint = "$keycloakBaseUrl/token"
val certificatesEndpoint = "$keycloakBaseUrl/certs"
val userinfoEndpoint = "$keycloakBaseUrl/userinfo"
}
internal class AuthRun(val kc: KeycloakConfig) {
val codeVerifier = KeycloakConfig.generateRandomString()
val codeChallenge = KeycloakConfig.generateCodeChallenge(codeVerifier)
val csrfProtectionState = KeycloakConfig.generateRandomString()
val resultDeferred = CompletableDeferred<AuthResult>()
private var redirectUri_: String? = null
val redirectUri: String
get() = redirectUri_
?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running")
fun buildRedirectUri(port: Int) {
redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}"
}
}
@@ -0,0 +1,80 @@
package net.keinekekse.app.auth
import kotlinx.coroutines.CompletableDeferred
import org.koin.core.annotation.Single
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
sealed interface AuthResult {
class Success : AuthResult
// data class Success(val tokens: BearerTokens) : AuthResult
data class Error(val code: String, val message: String, val details: String? = null) :
AuthResult
data class Fatal(val code: String, val message: String, val details: String? = null) :
AuthResult
}
@Single
fun createKeycloakConfig(): KeycloakConfig {
// TODO muss aus config geholt werden (<--> DI??)
val keycloakServerHostname = "kec.keinekekse.net"
val keycloakRealm = "app-server-realm"
val keycloakClient = "pkce-book-app"
return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
}
class KeycloakConfig(
val keycloakServerHostname: String,
keycloakRealm: String,
val keycloakClient: String,
) {
companion object {
const val LOCALHOST = "127.0.0.1"
const val CALLBACK_PATH = "/callback"
const val SCOPE = "openid"
const val PKCE_METHOD = "S256"
// TODO die Hash-Berechnung ist nicht portabel, die beiden fn müssen je Platform implementiert werden
fun generateRandomString(): String {
val bytes = ByteArray(64)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
fun generateCodeChallenge(codeVerifier: String): String {
val bytes = codeVerifier.toByteArray(Charsets.US_ASCII)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
}
}
private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect"
val authorizationEndpoint = "$keycloakBaseUrl/auth"
val tokenEndpoint = "$keycloakBaseUrl/token"
val certificatesEndpoint = "$keycloakBaseUrl/certs"
val userinfoEndpoint = "$keycloakBaseUrl/userinfo"
}
internal class AuthRun(val kc: KeycloakConfig) {
val codeVerifier = KeycloakConfig.generateRandomString()
val codeChallenge = KeycloakConfig.generateCodeChallenge(codeVerifier)
val csrfProtectionState = KeycloakConfig.generateRandomString()
val resultDeferred = CompletableDeferred<AuthResult>()
private var redirectUri_: String? = null
val redirectUri: String
get() = redirectUri_
?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running")
fun buildRedirectUri(port: Int) {
redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}"
}
}
@@ -0,0 +1,453 @@
package net.keinekekse.app.auth
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.utils.io.core.Closeable
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.keinekekse.app.service.TokenService
import org.koin.core.annotation.Single
import java.awt.Desktop
import java.net.Socket
import kotlin.use
@Single
class Authenticator(
// @Property("kc_server") keycloakServerHostname: String,
// @Property("kc_realm") keycloakRealm: String,
// @Property("kc_client") keycloakClient: String,
val tokenService: TokenService,
private val keycloakConfig: KeycloakConfig,
) : Closeable {
private val httpClient = HttpClient {
install(ContentNegotiation) { json() }
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
// private val keycloakConfig =
// KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
override fun close() = httpClient.close()
suspend fun authenticate(): AuthResult {
val config = AuthRun(keycloakConfig)
if (!testServerConnection(config)) {
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
}
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
try {
server = createCallbackServer(config).start(wait = false)
val port = server.engine.resolvedConnectors().first().port
config.buildRedirectUri(port)
if (!waitForServer(port)) {
return AuthResult.Fatal(
"ERR008",
"Schwerer Fehler",
"Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich."
)
}
openAuthorizationInBrowser(config)
return config.resultDeferred.await()
} catch (e: Exception) {
return AuthResult.Error(
"ERR002",
"Bei der Anmeldung ist ein Fehler aufgetreten",
e.message ?: "Unbekannter Fehler"
)
} finally {
server?.stop(1000, 2000)
}
}
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
val config = AuthRun(keycloakConfig)
val response = httpClient.submitForm(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.tokenEndpoint
}.toString(),
formParameters = parameters {
append("grant_type", "refresh_token")
append("client_id", config.kc.keycloakClient)
append("refresh_token", tokens?.refreshToken ?: "")
}
)
val result = validateRequestTokenResponse(response)
when (result.first) {
is AuthResult.Success -> {
tokenService.setTokens(result.second)
return result.second
}
else -> {
tokenService.setTokens(null)
return null
}
}
}
private fun isPortOpen(port: Int): Boolean =
try {
// TODO Socket ist nicht portabel (JVM only)
Socket(KeycloakConfig.LOCALHOST, port).use { true }
} catch (e: Exception) {
// Port is closed or unreachable
println("${e.javaClass.simpleName}: ${e.message}")
false
}
private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
repeat(timeoutSeconds * 10) {
if (isPortOpen(port)) return true
delay(100)
}
return false
}
private fun openAuthorizationInBrowser(config: AuthRun) {
val authorizationUrl = buildAuthorizationUrl(config)
// TODO momentan nur für JVM, Rest ist noch zu eruieren
if (!Desktop.isDesktopSupported()) {
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
}
val desktop = Desktop.getDesktop()
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
}
desktop.browse(authorizationUrl.toURI())
}
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.authorizationEndpoint
parameters.append("response_type", "code")
parameters.append("client_id", config.kc.keycloakClient)
parameters.append("redirect_uri", config.redirectUri)
parameters.append("scope", KeycloakConfig.SCOPE)
parameters.append("state", config.csrfProtectionState)
parameters.append("code_challenge", config.codeChallenge)
parameters.append("code_challenge_method", KeycloakConfig.PKCE_METHOD)
}
private fun createCallbackServer(config: AuthRun) =
embeddedServer(CIO, port = 0, host = KeycloakConfig.LOCALHOST) {
routing {
get(KeycloakConfig.CALLBACK_PATH) {
val code = call.request.queryParameters["code"]
var authResult =
validateAuthorizationRequest(call.request, config.csrfProtectionState)
if (authResult is AuthResult.Success) {
authResult = requestTokens(config, code)
}
val (text, status) = createGoodByeMessage(authResult)
call.respondText(text, contentType = ContentType.Text.Html, status = status)
if (!config.resultDeferred.isCompleted)
config.resultDeferred.complete(authResult)
}
}
}
private fun htmlEscape(value: String): String =
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
var text = ""
var status = HttpStatusCode.BadRequest
when (authResult) {
is AuthResult.Success -> {
text =
"<html><body><h1>Login erfolgreich! Du kannst das Fenster schließen.</h1></body></html>"
status = HttpStatusCode.OK
}
is AuthResult.Error -> {
text = "<html><body><h1>Fehler bei der Anmeldung.</h1><h2>${
htmlEscape(authResult.message)
}</h2><h3>${htmlEscape(authResult.details ?: "")}</h3></body></html>"
status = HttpStatusCode.BadRequest
}
is AuthResult.Fatal -> {
text =
"<html><body><h1>Schwerer Fehler.</h1><h2>${htmlEscape(authResult.message)}</h2><h3>${
htmlEscape(authResult.details ?: "")
}</h3></body></html>"
status = HttpStatusCode.InternalServerError
}
}
return Pair(text, status)
}
private fun validateAuthorizationRequest(
request: RoutingRequest,
csrfProtectionState: String,
): AuthResult {
/*
- state != csrfProt -> Fehler: Kommunikation mit dem Anmeldeserver hat nicht geklappt,
könnte auf Malware hindeuten, Abbruch
throw Fatal error authenticating user -> exit app
- wenn error =! null -> error + error_description anzeigen
rc NULL == keine auth -> app muss entscheiden
- code == null -> Fehler: Server liefert ungültige Daten,
könnte auf Probleme bei der Netzwerkverbindung oder beim Anmeldeserver hindeuten
rc NULL -> app
*/
if (request.queryParameters["error"] != null) {
return AuthResult.Error(
"ERR004",
"Fehler bei der Anmeldung",
"${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}"
)
}
if (request.queryParameters["state"] != csrfProtectionState) {
return AuthResult.Fatal(
"ERR005",
"Schwerer Fehler",
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
)
}
return AuthResult.Success()
}
/* Antwort von Keycloak:
{
"access_token": "eyJhbG ...
"expires_in": 300,
"refresh_expires_in": 0,
"refresh_token": "eyJhb ...
"token_type": "Bearer",
"id_token": "eyJ ...
"not-before-policy": 1769817270,
"session_state": "YhlNjIuW-lk1aQwwD9xm4edJ",
"scope": "openid profile email"
}
*/
@Serializable
data class TokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("expires_in") val expiresIn: Int,
@SerialName("refresh_expires_in") val refreshExpiresIn: Int,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("token_type") val tokenType: String,
@SerialName("id_token") val idToken: String?,
@SerialName("not-before-policy") val notBeforePolicy: Int?,
@SerialName("session_state") val sessionState: String?,
val scope: String,
)
@Serializable
data class TokenErrorResponse(
@SerialName("error") val error: String,
@SerialName("error_description") val errorDescription: String?,
@SerialName("session_state") val sessionState: String?,
)
private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult {
if (authCode == null) {
return AuthResult.Fatal(
"ERR003",
"Schwerer Fehler",
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
)
}
val response: HttpResponse = httpClient.submitForm(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.tokenEndpoint
}.toString(),
formParameters = parameters {
append("grant_type", "authorization_code")
append("client_id", config.kc.keycloakClient)
append("code", authCode)
append("redirect_uri", config.redirectUri)
append("code_verifier", config.codeVerifier)
}
)
val result = validateRequestTokenResponse(response)
when (result.first) {
is AuthResult.Success -> {
tokenService.setTokens(result.second)
}
else -> {
tokenService.setTokens(null)
}
}
return result.first
}
suspend fun validateRequestTokenResponse(response: HttpResponse): Pair<AuthResult, BearerTokens?> {
if (response.status != HttpStatusCode.OK) {
try {
val errorResponse = response.body<TokenErrorResponse>()
return Pair(
AuthResult.Error(
"ERR006",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}"
), null
)
} catch (e: Exception) {
// Fehler beim JSON-Konvertieren
val errorResponse = response.bodyAsText()
return Pair(
AuthResult.Error(
"ERR006",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
errorResponse
), null
)
}
}
val tokenResponse: TokenResponse = try {
response.body<TokenResponse>()
} catch (e: Exception) {
// Fehler beim JSON-Konvertieren
return Pair(
AuthResult.Fatal(
"ERR007",
"Schwerer Fehler",
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
), null
)
}
return Pair(
AuthResult.Success(),
BearerTokens(
tokenResponse.accessToken,
tokenResponse.refreshToken
)
)
}
// suspend fun requestUserInfo(
// accessToken: String,
// ) {
// val config = AuthenticationConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
//
// val response = httpClient.get(
// url = buildUrl {
// protocol = URLProtocol.HTTPS
// host = config.keycloakServerHostname
// path(config.userinfoEndpoint)
// },
//// formParameters = parameters {
//// append("grant_type", "refresh_token")
//// append("client_id", config.keycloakClient)
//// append("refresh_token", refreshToken)
//// }
// ) {
// headers { append("Authorization", "Bearer $accessToken") }
// }
//
// println("Status: ${response.status}")
// println("Body: ${response.bodyAsText()}")
//
// }
private suspend fun testServerConnection(config: AuthRun): Boolean {
val response: HttpResponse = httpClient.request(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.certificatesEndpoint
}
)
return response.status.value < 400
}
/*
access token:
{
"exp": 1769839803,
"iat": 1769839503,
"auth_time": 1769839502,
"jti": "onrtac:f0ea9436-470d-7d1d-fd9d-b43d2f075c46",
"iss": "https://kec.keinekekse.net/realms/app-server-realm",
"aud": "account",
"sub": "b5b40fa3-468f-443c-8eb0-e220bdd353bc",
"typ": "Bearer",
"azp": "pkce-book-app",
"sid": "n_TeXWIUSV66OkYwoc0D1og9",
"acr": "1",
"allowed-origins": [
"http://127.0.0.1",
"https://kas.keinekekse.net"
],
"realm_access": {
"roles": [
"offline_access",
"default-roles-app-server-realm",
"uma_authorization",
"book-admin"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid profile email",
"email_verified": true,
"name": "Hartmut Bauer",
"preferred_username": "hb42",
"given_name": "Hartmut",
"family_name": "Bauer",
"email": "hb242@pm.me"
}
-> realm_access:
implementation(libs.ktor.serverAuthJwt)
val decod = JWT().decodeJwt(tokenResponse.accessToken!!)
// claims, die nur String enthalten: getClaim("claim-name")
// für andere: "{" -> asMap<String, Any> => cast!
// "[" -> asList(Class<T>)
val roles: List<String> = decod.getClaim("realm_access").asMap()["roles"] as List<String>
roles.forEach { println(it) }
*/
}
@@ -0,0 +1,166 @@
package net.keinekekse.app
//import org.koin.plugin.module.dsl.startKoin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.keinekekse.app.auth.AuthResult
import net.keinekekse.app.auth.Authenticator
import net.keinekekse.app.books.di.KoinApp
import net.keinekekse.app.ui.ProgressIndicator
import org.koin.compose.koinInject
import org.koin.plugin.module.dsl.startKoin
//@PropertyValue("kc_server")
//var keycloakServer: String = ""
//
//@PropertyValue("kc_realm")
//var keycloakRealm: String = ""
//
//@PropertyValue("kc_client")
//var keycloakClient: String = ""
fun main() = application {
startKoin<KoinApp>()
// keycloakServer = "kec.keinekekse.net"
// keycloakRealm = "app-server-realm"
// keycloakClient = "pkce-book-app"
// val inj = koinInject<String>(named("AppString"))
val authResultState = remember { mutableStateOf<AuthResult?>(null) }
val authenticator: Authenticator = koinInject()
LaunchedEffect(Unit) {
authResultState.value = withContext(Dispatchers.IO) {
// TODO - (~) Verständnis für App-Aufbau und DI
// - (x) das object muss global angelegt werden -> DI singleton
// - (x) globale Speicherung der Tokens
// - Tokens persistieren? Multiplatform! (evtl. library)
// - (x) globaler httpClient mit Konfig für Tokens -> DI
// - (x) Error-Handling: 401 -> refresh -> 401 -> reauth [implizit]
// Authenticator(
// "kec.keinekekse.net",
// "app-server-realm",
// "pkce-book-app",
// ).use {
val result = authenticator.authenticate()
when (result) {
is AuthResult.Success -> {
// println("Access Token: ${result.tokens?.accessToken}")
// println("Refresh Token: ${result.tokens?.refreshToken}")
// delay(3000)
/*
// klappt, gibt aber nicht mehr her, als ohnehin im Token steht
it.requestUserInfo(result.tokens!!.accessToken)
*/
/*
// funktioniert -> jetzt fehlt die DI-Mimik für die compose app
when (val res = it.requestRefreshToken(result.tokens)) {
is AuthResult.Success -> {
println("Access Token: ${res.tokens?.accessToken}")
println("Refresh Token: ${res.tokens?.refreshToken}")
}
is AuthResult.Error -> {
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
}
is AuthResult.Fatal -> {
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
}
}
*/
}
else -> {}
}
result
// }
}
}
Window(
onCloseRequest = ::exitApplication,
title = "app",
) {
when (val result = authResultState.value) {
null -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Warten auf Anmeldung...")
// Icon(
// imageVector = Icons.Default.HourglassTop,
// contentDescription = "Home",
// modifier = Modifier.padding(top = 16.dp).size(32.dp)
// )
ProgressIndicator()
}
}
is AuthResult.Error -> {
// TODO show an error UI (and maybe a retry button)
Text("Fehler bei der Anmeldung: ${result.message} (${result.code})")
Text("Fehlerbeschreibung: ${result.details ?: ""}")
// App()
}
is AuthResult.Fatal -> {
Text("Fataler Fehler bei der Anmeldung: ${result.message} (${result.code})")
Text("Fehlerbeschreibung: ${result.details ?: ""}")
}
is AuthResult.Success -> {
val test = koinInject<TestAuth>()
CoroutineScope(Dispatchers.Default).launch {
println("requesting '/'")
test.request("/")
}
App()
}
}
}
}
//
//@Composable
//fun ProgressIndicator(animationSize: Int = 48, paddingTop: Int = 16, strokeWidth: Int = 5) {
// val primaryColor = MaterialTheme.colorScheme.primary
// val backgroundColor = MaterialTheme.colorScheme.background
// val strokewidth = strokeWidth.dp
// CircularProgressIndicator(
// modifier =
// Modifier.padding(top = paddingTop.dp).size(animationSize.dp).drawBehind {
// drawCircle(
// primaryColor,
// radius = size.width / 2 - strokewidth.toPx() / 2,
// style = Stroke(strokewidth.toPx()),
//
// )
// },
// color = backgroundColor,
// strokeWidth = strokewidth
// )
//}
@@ -0,0 +1,22 @@
package net.keinekekse.app.service
import io.ktor.client.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import net.keinekekse.app.auth.Authenticator
import org.koin.core.annotation.Single
@Single
fun createHttpClient(authenticator: Authenticator) = HttpClient {
install(Auth) {
bearer {
loadTokens {
authenticator.tokenService.getTokens()
}
refreshTokens {
authenticator.requestRefreshToken(oldTokens)
authenticator.tokenService.getTokens()
}
}
}
}
@@ -0,0 +1,20 @@
package net.keinekekse.app.service
import io.ktor.client.plugins.auth.providers.*
import org.koin.core.annotation.Single
@Single
class TokenService {
private var tokens: BearerTokens? = null
// TODO persist tokens
fun getTokens(): BearerTokens? = tokens
fun setTokens(tokens: BearerTokens? = null) {
this.tokens = tokens
println("set tokens")
println("Access Token: ${tokens?.accessToken}")
println("Refresh Token: ${tokens?.refreshToken}")
}
}
@@ -0,0 +1,11 @@
package net.keinekekse.app
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport {
App()
}
}
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>app</title>
<link href="styles.css" rel="stylesheet" type="text/css">
</head>
<body style="text-align: center; align-content: center">
<svg height="40" role="presentation" viewBox="0 0 50 50" width="40"
xmlns="http://www.w3.org/2000/svg">
<circle cx="25" cy="25" fill="none" r="20" stroke="#ccc" stroke-width="4"/>
<circle cx="25" cy="25" fill="none" r="20" stroke="#333" stroke-dasharray="90 125" stroke-linecap="round"
stroke-width="4">
<animateTransform attributeName="transform" dur="1s" from="0 25 25" repeatCount="indefinite" to="360 25 25"
type="rotate"/>
</circle>
</svg>
<script src="composeApp.js" type="application/javascript"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}