Skip to content
This repository was archived by the owner on Jul 25, 2025. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dependencies {
}
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.koin.compose)

implementation(idofrontLibs.kotlinx.serialization.json)
implementation(idofrontLibs.kotlinx.serialization.kaml)
implementation(libs.ktor.core)
Expand All @@ -38,6 +41,13 @@ dependencies {
implementation(libs.minecraftAuth)
implementation(libs.jmccc.mcdownloader)
implementation(libs.jmccc)

implementation(libs.multiplatform.settings)
// implementation(libs.multiplatform.settings.no.arg)
implementation(libs.multiplatform.settings.serialization)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
implementation("io.github.irgaly.kfswatch:kfswatch:1.0.0")
}

idofront {
Expand Down
11 changes: 11 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ jmccc = "3.1.4"
ktor = "2.3.11"
minecraftAuth = "4.0.2"
mpfilepicker = "3.1.0"
lifecycleViewmodelCompose = "2.8.0-beta02"
koinCompose = "3.6.0-wasm-alpha2"
multiplatformSettings = "1.1.1"

[libraries]
jarchivelib = { module = "org.rauschig:jarchivelib", version.ref = "jarchivelib" }
Expand All @@ -12,5 +15,13 @@ jmccc-mcdownloader = { module = "dev.3-3:jmccc-mcdownloader", version.ref = "jmc
ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-cio-jvm = { module = "io.ktor:ktor-client-cio-jvm", version.ref = "ktor" }
ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
minecraftAuth = { module = "net.raphimc:MinecraftAuth", version.ref = "minecraftAuth" }
mpfilepicker = { module = "com.darkrockstudios:mpfilepicker", version.ref = "mpfilepicker" }
lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koinCompose" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatformSettings" }
multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatformSettings" }

75 changes: 39 additions & 36 deletions src/main/kotlin/com/mineinabyss/launchy/Main.kt
Original file line number Diff line number Diff line change
@@ -1,50 +1,46 @@
package com.mineinabyss.launchy

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.mineinabyss.launchy.data.Dirs
import com.mineinabyss.launchy.data.config.Config
import com.mineinabyss.launchy.data.config.GameInstance
import com.mineinabyss.launchy.state.LaunchyState
import com.mineinabyss.launchy.ui.colors.AppTheme
import com.mineinabyss.launchy.ui.screens.Screens
import com.mineinabyss.launchy.ui.state.TopBarProvider
import com.mineinabyss.launchy.ui.state.TopBarState
import com.mineinabyss.launchy.util.OS
import com.mineinabyss.launchy.config.data.configModule
import com.mineinabyss.launchy.core.di.coreModule
import com.mineinabyss.launchy.core.ui.*
import com.mineinabyss.launchy.core.ui.screens.Screens
import com.mineinabyss.launchy.core.ui.theme.AppTheme
import com.mineinabyss.launchy.util.koinViewModel
import org.koin.compose.KoinApplication
import java.awt.Dimension

private val LaunchyStateProvider = compositionLocalOf<LaunchyState> { error("No local versions provided") }

val LocalLaunchyState: LaunchyState
@Composable get() = LaunchyStateProvider.current

fun main() {
application {
fun main() = application {
KoinApplication(application = {
modules(
coreModule(),
configModule(),
)
}) {
val windowState = rememberWindowState(placement = WindowPlacement.Floating)
val icon = painterResource("icon.png")
val launchyState by produceState<LaunchyState?>(null) {
Dirs.createDirs()
Dirs.createConfigFiles()
val config = Config.read().getOrElse { Config() }
val instances = GameInstance.readAll(Dirs.modpackConfigsDir)
value = LaunchyState(config, instances)
}
val viewModel = koinViewModel<LaunchyViewModel>()
val onClose: () -> Unit = {
exitApplication()
launchyState?.saveToConfig()
// viewModel.saveToConfig() TODO
}

Window(
Expand All @@ -56,21 +52,28 @@ fun main() {
) {
window.minimumSize = Dimension(600, 400)
val topBarState = remember { TopBarState(onClose, windowState, this) }
val ready = launchyState != null
val uiState by viewModel.uiState.collectAsState()
AppTheme {
CompositionLocalProvider(TopBarProvider provides topBarState) {
Scaffold {
AnimatedVisibility(!ready, exit = fadeOut()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Reading launchy config...")
AnimatedContent(uiState) {
when (val state = uiState) {
LaunchyUiState.Loading ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}

is LaunchyUiState.Ready ->
CompositionLocalProvider(
LocalUiState provides state.ui
) {
Screens()
}
}
}
AnimatedVisibility(ready, enter = fadeIn()) {
CompositionLocalProvider(
LaunchyStateProvider provides launchyState!!,
) {
Screens()
}
AnimatedVisibility(uiState is LaunchyUiState.Loading, exit = fadeOut()) {
}
AnimatedVisibility(uiState is LaunchyUiState.Ready, enter = fadeIn()) {
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/kotlin/com/mineinabyss/launchy/auth/data/ProfileModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.mineinabyss.launchy.auth.data

import com.mineinabyss.launchy.util.serializers.UUIDSerializer
import kotlinx.serialization.Serializable
import java.util.*

@Serializable
data class ProfileModel(
val name: String,
val uuid: @Serializable(with = UUIDSerializer::class) UUID,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.mineinabyss.launchy.auth.data

import com.mineinabyss.launchy.auth.data.identity.IdentityDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import net.raphimc.minecraftauth.step.java.session.StepFullJavaSession.FullJavaSession

class ProfileRepository(
val identity: IdentityDataSource,
) {
private val _authRequest = MutableStateFlow<IdentityDataSource.VerificationRequired?>(null)

private val _currentSession = MutableStateFlow<FullJavaSession?>(null)
private val _currentProfile = MutableStateFlow<ProfileModel?>(null)

val authRequest = _authRequest.asStateFlow()
val currentProfile = _currentProfile.asStateFlow()

fun useProfile(config: ProfileModel) {
_currentProfile.update { config }
_currentSession.update { null }
}

fun logout() {
val uuid = _currentProfile.value?.uuid ?: return
identity.forgetSession(uuid)
_currentProfile.update { null }
_currentSession.update { null }
}

suspend fun authenticateCurrentProfile() = withContext(Dispatchers.IO) {
val profile = _currentProfile.value
val session = identity.authFlow(
profile,
onVerificationRequired = { verification ->
_authRequest.update { verification }
}
)
_currentProfile.update { ProfileModel(session.mcProfile.name, session.mcProfile.id) }
_currentSession.update { session }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.mineinabyss.launchy.auth.data.identity

import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import com.mineinabyss.launchy.auth.data.ProfileModel
import com.mineinabyss.launchy.core.data.TasksRepository
import com.russhwolf.settings.Settings
import com.russhwolf.settings.set
import net.raphimc.minecraftauth.MinecraftAuth
import net.raphimc.minecraftauth.step.java.session.StepFullJavaSession.FullJavaSession
import net.raphimc.minecraftauth.step.msa.StepMsaDeviceCode.MsaDeviceCode
import net.raphimc.minecraftauth.step.msa.StepMsaDeviceCode.MsaDeviceCodeCallback
import java.util.*


class IdentityDataSource(
val settings: Settings,
val tasks: TasksRepository,
) {
private val httpClient = MinecraftAuth.createHttpClient()
private val deviceCodeLogin = MinecraftAuth.ALT_JAVA_DEVICE_CODE_LOGIN
//TODO override with our own oauth app
// .builder()
// .withClientId("00000000402b5328")
// .withScope("service::user.auth.xboxlive.com::MBI_SSL")
// .deviceCode()
// .withDeviceToken("Win32")
// .sisuTitleAuthentication("rp://api.minecraftservices.com/")
// .buildMinecraftJavaProfileStep(true)

val gson = GsonBuilder().setPrettyPrinting().create()

fun load(uuid: UUID): Result<FullJavaSession?> {
val saved = settings.getStringOrNull("session-$uuid") ?: return Result.success(null)
return runCatching {
deviceCodeLogin.fromJson(JsonParser.parseString(saved).asJsonObject)
}
}

fun forgetSession(uuid: UUID) {
settings.remove("session-$uuid")
}

fun save(session: FullJavaSession) {
val json = deviceCodeLogin.toJson(session)
settings["session-${session.mcProfile.id}"] = gson.toJson(json)
}

class VerificationRequired(
val code: String,
val redirectTo: String,
)

fun authFlow(
profile: ProfileModel?,
onVerificationRequired: (VerificationRequired) -> Unit,
): FullJavaSession {
// Attempt existing session refresh
val previousSession = profile?.uuid?.let { load(it) }?.getOrNull()
if (previousSession != null) {
println("Refreshing token")
runCatching { deviceCodeLogin.refresh(httpClient, previousSession) }
.onSuccess {
return it
}
}
// Prompt user to log in with device code
val javaSession = deviceCodeLogin.getFromInput(
httpClient,
MsaDeviceCodeCallback { msaDeviceCode: MsaDeviceCode ->
onVerificationRequired(
VerificationRequired(
msaDeviceCode.userCode,
msaDeviceCode.directVerificationUri
)
)
})
save(javaSession)
return javaSession
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.mineinabyss.launchy.auth.data.identity

import kotlinx.serialization.Serializable

@Serializable
data class SessionStorage(
val microsoftAccessToken: String,
val microsoftRefreshToken: String,
val minecraftAccessToken: String,
val xboxUserId: String,
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.mineinabyss.launchy.ui.dialogs
package com.mineinabyss.launchy.auth.ui

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.ClickableText
Expand All @@ -14,18 +14,17 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.mineinabyss.launchy.LocalLaunchyState
import com.mineinabyss.launchy.logic.DesktopHelpers
import com.mineinabyss.launchy.ui.elements.LaunchyDialog
import com.mineinabyss.launchy.ui.screens.Dialog
import com.mineinabyss.launchy.ui.screens.dialog
import com.mineinabyss.launchy.core.ui.Dialog
import com.mineinabyss.launchy.core.ui.components.LaunchyDialog
import com.mineinabyss.launchy.core.ui.screens.dialog
import com.mineinabyss.launchy.util.DesktopHelpers

@OptIn(ExperimentalTextApi::class)
@Composable
fun AuthDialog(
state: Dialog.Auth,
onDismissRequest: () -> Unit
) {
val state = LocalLaunchyState
LaunchyDialog(
title = {
Text("Authenticate with Microsoft", style = LocalTextStyle.current)
Expand All @@ -37,7 +36,7 @@ fun AuthDialog(
declineText = null,
) {
when {
state.profile.authCode != null -> {
state.verification.code != null -> {
val clipboard = LocalClipboardManager.current
val annotatedText = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onBackground)) {
Expand All @@ -55,7 +54,7 @@ fun AuthDialog(
}
pop()

append(" and enter the code ${state.profile.authCode}")
append(" and enter the code ${state.verification.code}")
}
}
val inlineContent = mapOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.mineinabyss.launchy.auth.ui

import androidx.compose.ui.graphics.painter.BitmapPainter

data class ProfileUiState(
val username: String,
val avatar: BitmapPainter?,
)
Loading