diff --git a/build.gradle.kts b/build.gradle.kts index 2f48904..6ae2923 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) @@ -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 { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 265cf86..a2c97d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } @@ -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" } + diff --git a/src/main/kotlin/com/mineinabyss/launchy/Main.kt b/src/main/kotlin/com/mineinabyss/launchy/Main.kt index 7d11b8f..c427e23 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/Main.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/Main.kt @@ -1,13 +1,17 @@ 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 @@ -15,36 +19,28 @@ 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 { 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(null) { - Dirs.createDirs() - Dirs.createConfigFiles() - val config = Config.read().getOrElse { Config() } - val instances = GameInstance.readAll(Dirs.modpackConfigsDir) - value = LaunchyState(config, instances) - } + val viewModel = koinViewModel() val onClose: () -> Unit = { exitApplication() - launchyState?.saveToConfig() +// viewModel.saveToConfig() TODO } Window( @@ -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()) { } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/auth/data/ProfileModel.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/data/ProfileModel.kt new file mode 100644 index 0000000..e65b2ae --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/data/ProfileModel.kt @@ -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, +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/auth/data/ProfileRepository.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/data/ProfileRepository.kt new file mode 100644 index 0000000..b58f9df --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/data/ProfileRepository.kt @@ -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(null) + + private val _currentSession = MutableStateFlow(null) + private val _currentProfile = MutableStateFlow(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 } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/auth/data/identity/IdentityDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/data/identity/IdentityDataSource.kt new file mode 100644 index 0000000..b1afa2a --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/data/identity/IdentityDataSource.kt @@ -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 { + 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 + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/auth/data/identity/SessionStorage.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/data/identity/SessionStorage.kt new file mode 100644 index 0000000..e558d19 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/data/identity/SessionStorage.kt @@ -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, +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/AuthDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/AuthDialog.kt similarity index 88% rename from src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/AuthDialog.kt rename to src/main/kotlin/com/mineinabyss/launchy/auth/ui/AuthDialog.kt index 9bf4291..e23d0ef 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/AuthDialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/AuthDialog.kt @@ -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 @@ -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) @@ -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)) { @@ -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( diff --git a/src/main/kotlin/com/mineinabyss/launchy/auth/ui/ProfileUiState.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/ProfileUiState.kt new file mode 100644 index 0000000..2414aec --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/ProfileUiState.kt @@ -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?, +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/auth/ui/ProfileViewModel.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/ProfileViewModel.kt new file mode 100644 index 0000000..489201b --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/ProfileViewModel.kt @@ -0,0 +1,74 @@ +package com.mineinabyss.launchy.auth.ui + +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.res.loadImageBitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mineinabyss.launchy.auth.data.ProfileRepository +import com.mineinabyss.launchy.core.data.TasksRepository +import com.mineinabyss.launchy.core.ui.Dialog +import com.mineinabyss.launchy.core.ui.screens.dialog +import com.mineinabyss.launchy.downloads.data.Downloader +import com.mineinabyss.launchy.util.AppDispatchers +import com.mineinabyss.launchy.util.DesktopHelpers +import com.mineinabyss.launchy.util.Dirs +import com.mineinabyss.launchy.util.InProgressTask +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.* +import kotlin.io.path.inputStream + +class ProfileViewModel( + private val profileRepository: ProfileRepository, + private val tasks: TasksRepository, + private val downloader: Downloader, +) : ViewModel() { + private val _profile = MutableStateFlow(null) + + val profile = _profile.asStateFlow() + + init { + viewModelScope.launch { + profileRepository.authRequest.collectLatest { verification -> + if (verification == null) return@collectLatest + dialog = Dialog.Auth(verification) + DesktopHelpers.browse(verification.redirectTo) + } + } + viewModelScope.launch { + profileRepository.currentProfile.collectLatest { profile -> + if (profile == null) return@collectLatest + //TODO separate state for loading avatar? + val avatar = getAvatar(profile.uuid).getOrNull() + _profile.value = ProfileUiState(profile.name, avatar) + } + } + } + + fun authOrShowDialog() = viewModelScope.launch { + tasks.run("auth", InProgressTask("Authenticating")) { + val session = profileRepository.authenticateCurrentProfile() + + dialog = Dialog.None + } + } + + fun logout() { + profileRepository.logout() + _profile.value = null + } + + private suspend fun getAvatar(uuid: UUID): Result = withContext(AppDispatchers.IO) { + runCatching { + downloader.downloadAvatar(uuid, Downloader.Options(overwrite = false)) + BitmapPainter( + loadImageBitmap(Dirs.avatar(uuid).inputStream()), + filterQuality = FilterQuality.None + ) + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/AccountsPopup.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/components/AccountsPopup.kt similarity index 69% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/AccountsPopup.kt rename to src/main/kotlin/com/mineinabyss/launchy/auth/ui/components/AccountsPopup.kt index d3b7622..abeb845 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/AccountsPopup.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/components/AccountsPopup.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens +package com.mineinabyss.launchy.auth.ui.components import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -9,24 +9,28 @@ import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.logic.Auth.logout +import com.mineinabyss.launchy.auth.ui.ProfileViewModel +import com.mineinabyss.launchy.util.koinViewModel @Composable -fun AccountsPopup(onLogout: () -> Unit) { - val state = LocalLaunchyState +fun AccountsPopup( + viewModel: ProfileViewModel = koinViewModel(), + onLogout: () -> Unit +) { Surface( tonalElevation = 2.dp, shape = RoundedCornerShape(20.dp), modifier = Modifier.size(48.dp) ) { - val currentProfile = state.profile.currentProfile + val currentProfile by viewModel.profile.collectAsState() if (currentProfile != null) { IconButton( onClick = { - state.profile.logout(currentProfile.uuid) + viewModel.logout() onLogout() }, ) { diff --git a/src/main/kotlin/com/mineinabyss/launchy/auth/ui/components/PlayerAvatar.kt b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/components/PlayerAvatar.kt new file mode 100644 index 0000000..6971ce4 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/auth/ui/components/PlayerAvatar.kt @@ -0,0 +1,37 @@ +package com.mineinabyss.launchy.auth.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.loadImageBitmap +import androidx.compose.ui.res.useResource +import com.mineinabyss.launchy.auth.ui.ProfileUiState + +@Composable +fun PlayerAvatar( + profile: ProfileUiState?, + modifier: Modifier = Modifier +) { + val avatar = profile?.avatar + if (avatar == null) { + val missingSkin = remember { + useResource("missing_skin.png") { + BitmapPainter( + loadImageBitmap(it), + filterQuality = FilterQuality.None + ) + } + } + Image(missingSkin, "Not logged in", Modifier.fillMaxSize()) + } else Image( + painter = avatar, + contentDescription = "Avatar", + contentScale = ContentScale.FillWidth, + modifier = modifier + ) +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/config/data/Config.kt b/src/main/kotlin/com/mineinabyss/launchy/config/data/Config.kt new file mode 100644 index 0000000..a669f0a --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/config/data/Config.kt @@ -0,0 +1,20 @@ +package com.mineinabyss.launchy.config.data + +import com.mineinabyss.launchy.auth.data.ProfileModel +import com.mineinabyss.launchy.util.InstanceKey +import kotlinx.serialization.Serializable + + +@Serializable +data class Config( + val handledImportOptions: Boolean = false, + val onboardingComplete: Boolean = false, + val currentProfile: ProfileModel? = null, + val javaPath: String? = null, + val jvmArguments: String? = null, + val memoryAllocation: Int? = null, + val useRecommendedJvmArguments: Boolean = true, + val preferHue: Float? = null, + val startInFullscreen: Boolean = false, + val lastPlayedMap: Map = mapOf(), +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigDataSource.kt new file mode 100644 index 0000000..1f70f26 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigDataSource.kt @@ -0,0 +1,18 @@ +package com.mineinabyss.launchy.config.data + +import com.charleskorn.kaml.decodeFromStream +import com.mineinabyss.launchy.util.Dirs +import com.mineinabyss.launchy.util.Formats +import kotlinx.serialization.encodeToString +import kotlin.io.path.inputStream +import kotlin.io.path.writeText + +class ConfigDataSource { + fun readConfig(): Result = runCatching { + Formats.yaml.decodeFromStream(Dirs.configFile.inputStream()) + }.onFailure { it.printStackTrace() } + + fun saveConfig(config: Config) { + Dirs.configFile.writeText(Formats.yaml.encodeToString(config)) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigModule.kt b/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigModule.kt new file mode 100644 index 0000000..f458ea6 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigModule.kt @@ -0,0 +1,7 @@ +package com.mineinabyss.launchy.config.data + +import org.koin.dsl.module + +fun configModule() = module { + single { ConfigDataSource() } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigRepository.kt b/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigRepository.kt new file mode 100644 index 0000000..c400fc5 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/config/data/ConfigRepository.kt @@ -0,0 +1,21 @@ +package com.mineinabyss.launchy.config.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ConfigRepository( + val dataSource: ConfigDataSource +) { + private val _config = MutableStateFlow(Config()) + + val config = _config.asStateFlow() + + fun updateConfig(config: Config) { + _config.value = config + dataSource.saveConfig(config) + } + + fun tryLoadConfig() { + _config.value = dataSource.readConfig().getOrElse { return } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/data/FileSystemDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/core/data/FileSystemDataSource.kt new file mode 100644 index 0000000..d793889 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/data/FileSystemDataSource.kt @@ -0,0 +1,26 @@ +package com.mineinabyss.launchy.core.data + +import io.github.irgaly.kfswatch.KfsDirectoryWatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest +import java.nio.file.Path +import kotlin.coroutines.coroutineContext +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.pathString + +class FileSystemDataSource { + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun watchDirectory(path: Path): Flow> { + val watcher = KfsDirectoryWatcher(CoroutineScope(coroutineContext)) + watcher.add(path.pathString) + return watcher.onEventFlow.mapLatest { + path.listDirectoryEntries(glob = "*jar").toList() + } + } + + suspend fun scheduleWrite(path: Path, write: () -> Unit) { + + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/data/TasksRepository.kt b/src/main/kotlin/com/mineinabyss/launchy/core/data/TasksRepository.kt new file mode 100644 index 0000000..c0728a4 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/data/TasksRepository.kt @@ -0,0 +1,28 @@ +package com.mineinabyss.launchy.core.data + +import com.mineinabyss.launchy.util.InProgressTask +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class TasksRepository { + private val _inProgress = MutableStateFlow(mapOf()) + val inProgress = _inProgress.asStateFlow() + + fun start(key: String, task: InProgressTask) { + _inProgress.update { it.plus(key to task) } + } + + fun finish(key: String) { + _inProgress.update { it.minus(key) } + } + + inline fun run(key: String, task: InProgressTask, run: () -> T): T { + try { + start(key, task) + return run() + } finally { + finish(key) + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/di/CoreModule.kt b/src/main/kotlin/com/mineinabyss/launchy/core/di/CoreModule.kt new file mode 100644 index 0000000..a00b2f5 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/di/CoreModule.kt @@ -0,0 +1,10 @@ +package com.mineinabyss.launchy.core.di + +import com.mineinabyss.launchy.core.data.TasksRepository +import com.mineinabyss.launchy.core.ui.LaunchyViewModel +import org.koin.dsl.module + +fun coreModule() = module { + single { TasksRepository() } + single { LaunchyViewModel(get()) } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/Constants.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/Constants.kt similarity index 86% rename from src/main/kotlin/com/mineinabyss/launchy/data/Constants.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/Constants.kt index f4ad58d..ca06584 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/Constants.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/Constants.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.data +package com.mineinabyss.launchy.core.ui import androidx.compose.ui.unit.dp diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Dialog.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/Dialog.kt similarity index 71% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/Dialog.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/Dialog.kt index 9627664..2c686e0 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Dialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/Dialog.kt @@ -1,13 +1,14 @@ -package com.mineinabyss.launchy.ui.screens +package com.mineinabyss.launchy.core.ui -import com.mineinabyss.launchy.data.config.GameInstanceConfig +import com.mineinabyss.launchy.auth.data.identity.IdentityDataSource +import com.mineinabyss.launchy.instance.data.storage.InstanceConfig sealed interface Dialog { object None : Dialog object ChooseJVMPath : Dialog - object Auth : Dialog + data class Auth(val verification: IdentityDataSource.VerificationRequired) : Dialog class Options( val title: String, @@ -21,7 +22,7 @@ sealed interface Dialog { class Error(val title: String, val message: String) : Dialog class ConfirmImportModpackDialog( - val info: GameInstanceConfig + val info: InstanceConfig ) companion object { diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/LaunchyUiState.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/LaunchyUiState.kt new file mode 100644 index 0000000..d648396 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/LaunchyUiState.kt @@ -0,0 +1,8 @@ +package com.mineinabyss.launchy.core.ui + +sealed interface LaunchyUiState { + object Loading : LaunchyUiState + data class Ready( + val ui: UiState, + ) : LaunchyUiState +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/LaunchyViewModel.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/LaunchyViewModel.kt new file mode 100644 index 0000000..3b64ba2 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/LaunchyViewModel.kt @@ -0,0 +1,48 @@ +package com.mineinabyss.launchy.core.ui + +import androidx.lifecycle.ViewModel +import com.mineinabyss.launchy.config.data.ConfigRepository +import com.mineinabyss.launchy.util.Dirs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class LaunchyViewModel( + val configRepo: ConfigRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(LaunchyUiState.Loading) + + val uiState = _uiState.asStateFlow() + + init { +// viewModelScope.launch { +// setupFilesystem() +// configRepo.tryLoadConfig() +// _uiState.emit( +// LaunchyUiState.Ready( +// UiState(config), +// instances, +// ) +// ) +// } + } + +// fun saveToConfig() { +// config.value.copy( +// handledImportOptions = handledImportOptions, +// onboardingComplete = onboardingComplete, +// currentProfile = profile.currentProfile, +// javaPath = jvm.javaPath?.toString(), +// jvmArguments = jvm.userJvmArgs, +// memoryAllocation = jvm.userMemoryAllocation, +// useRecommendedJvmArguments = jvm.useRecommendedJvmArgs, +// preferHue = ui.preferHue, +// startInFullscreen = ui.fullscreen, +// lastPlayedMap = lastPlayed +// ).save() +// } + + private fun setupFilesystem() { + Dirs.createDirs() + Dirs.createConfigFiles() + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/state/TopBarState.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/TopBarState.kt similarity index 98% rename from src/main/kotlin/com/mineinabyss/launchy/ui/state/TopBarState.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/TopBarState.kt index b40e89e..5870a13 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/state/TopBarState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/TopBarState.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.state +package com.mineinabyss.launchy.core.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/UiState.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/UiState.kt new file mode 100644 index 0000000..d0baccb --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/UiState.kt @@ -0,0 +1,15 @@ +package com.mineinabyss.launchy.core.ui + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.mineinabyss.launchy.config.data.Config + +val LocalUiState = compositionLocalOf { error("No UiState provided") } + +//TODO move into viewmodel +class UiState(config: Config) { + var preferHue: Float by mutableStateOf(config.preferHue ?: 0f) + var fullscreen: Boolean by mutableStateOf(config.startInFullscreen) +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/AnimatedTab.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/AnimatedTab.kt similarity index 91% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/AnimatedTab.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/AnimatedTab.kt index 87d1e88..98e07f0 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/AnimatedTab.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/AnimatedTab.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Buttons.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Buttons.kt similarity index 97% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/Buttons.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Buttons.kt index 137f807..764b862 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Buttons.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Buttons.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.material3.* import androidx.compose.runtime.Composable diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/ComfyContent.kt similarity index 93% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/ComfyContent.kt index e6aecff..89e4326 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/ComfyContent.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/ComfyContent.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -12,7 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.ui.screens.screen +import com.mineinabyss.launchy.core.ui.screens.screen @Composable fun ComfyWidth( diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Dialog.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Dialog.kt similarity index 98% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/Dialog.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Dialog.kt index 9aba7dd..7ed1ee8 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Dialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Dialog.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/InProgressTasksIndicator.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/InProgressTasksIndicator.kt new file mode 100644 index 0000000..2a0fd1e --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/InProgressTasksIndicator.kt @@ -0,0 +1,61 @@ +package com.mineinabyss.launchy.core.ui.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.* +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.data.TasksRepository +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.screen +import com.mineinabyss.launchy.instance.ui.components.SlightBackgroundTint +import com.mineinabyss.launchy.instance.ui.components.settings.infobar.InfoBarProperties +import com.mineinabyss.launchy.util.InProgressTask +import org.koin.compose.koinInject + +@Composable +fun InProgressTasksIndicator( + tasks: TasksRepository = koinInject() +) { + val progressBarHeight by animateDpAsState(if (screen == Screen.InstanceSettings) InfoBarProperties.height else 0.dp) + val inProgress by tasks.inProgress.collectAsState() + + if (inProgress.isNotEmpty()) Box(Modifier.fillMaxSize().padding(bottom = progressBarHeight)) { + val task = inProgress.values.first() + val textModifier = Modifier.align(Alignment.BottomStart).padding(start = 10.dp, bottom = 20.dp) + val progressBarModifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) + val progressBarColor = MaterialTheme.colorScheme.primaryContainer + SlightBackgroundTint(Modifier.height(50.dp)) + when (task) { + is InProgressTask.WithPercentage -> { + Text( + "${task.name}... (${task.current}/${task.total}${if (task.measurement != null) " ${task.measurement}" else ""})", + modifier = textModifier + ) + LinearProgressIndicator( + progress = task.current.toFloat() / task.total, + modifier = progressBarModifier, + color = progressBarColor + ) + } + + else -> { + Text( + "${task.name}...", + modifier = textModifier + ) + + LinearProgressIndicator( + modifier = progressBarModifier, + color = progressBarColor + ) + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/LaunchyWindowDraggableArea.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/LaunchyWindowDraggableArea.kt similarity index 81% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/LaunchyWindowDraggableArea.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/LaunchyWindowDraggableArea.kt index 96a84ac..e88927e 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/LaunchyWindowDraggableArea.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/LaunchyWindowDraggableArea.kt @@ -1,13 +1,12 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowScope -import com.mineinabyss.launchy.ui.state.TopBarProvider +import com.mineinabyss.launchy.core.ui.TopBarProvider @Composable fun WindowScope.BetterWindowDraggableArea( diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/LeftSidebar.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/LeftSidebar.kt similarity index 66% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/LeftSidebar.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/LeftSidebar.kt index 514d268..e3ddd07 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/LeftSidebar.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/LeftSidebar.kt @@ -1,7 +1,6 @@ -package com.mineinabyss.launchy.ui.screens +package com.mineinabyss.launchy.core.ui.components import androidx.compose.animation.* -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add @@ -12,24 +11,22 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.res.loadImageBitmap -import androidx.compose.ui.res.useResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.logic.Auth -import com.mineinabyss.launchy.ui.elements.PlayerAvatar -import kotlinx.coroutines.launch +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mineinabyss.launchy.auth.ui.ProfileViewModel +import com.mineinabyss.launchy.auth.ui.components.AccountsPopup +import com.mineinabyss.launchy.auth.ui.components.PlayerAvatar +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.screen @Composable -fun LeftSidebar() { - val state = LocalLaunchyState - val coroutineScope = rememberCoroutineScope() +fun LeftSidebar( + profileViewModel: ProfileViewModel = viewModel(), +) { var showAccountsPopup by remember { mutableStateOf(false) } var accountHeadPosition: LayoutCoordinates? by remember { mutableStateOf(null) } @@ -56,14 +53,12 @@ fun LeftSidebar() { screen = Screen.NewInstance } ) - val profile = state.profile.currentProfile + val profile by profileViewModel.profile.collectAsState() FloatingActionButton( onClick = { - if (state.profile.currentProfile == null) coroutineScope.launch { - if (profile == null) Auth.authOrShowDialog(state, state.profile) - } else { - showAccountsPopup = !showAccountsPopup - } + profileViewModel.authOrShowDialog() + if (profile == null) profileViewModel.authOrShowDialog() + else showAccountsPopup = !showAccountsPopup }, modifier = Modifier.size(48.dp).onGloballyPositioned { accountHeadPosition = it @@ -71,18 +66,7 @@ fun LeftSidebar() { containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.secondary, ) { - profile?.let { PlayerAvatar(profile, Modifier.fillMaxSize()) } - ?: run { - val missingSkin = remember { - useResource("missing_skin.png") { - BitmapPainter( - loadImageBitmap(it), - filterQuality = FilterQuality.None - ) - } - } - Image(missingSkin, "Not logged in", Modifier.fillMaxSize()) - } + PlayerAvatar(profile, Modifier.fillMaxSize()) } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/SingleFileDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/SingleFileDialog.kt similarity index 96% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/SingleFileDialog.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/SingleFileDialog.kt index a68671a..64d94be 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/SingleFileDialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/SingleFileDialog.kt @@ -1,10 +1,10 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.runtime.Composable import androidx.compose.ui.window.AwtWindow import com.darkrockstudios.libraries.mpfilepicker.DirectoryPicker import com.darkrockstudios.libraries.mpfilepicker.FilePicker -import com.mineinabyss.launchy.data.Dirs +import com.mineinabyss.launchy.util.Dirs import com.mineinabyss.launchy.util.OS import java.awt.FileDialog import java.awt.Frame diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Tooltip.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Tooltip.kt similarity index 94% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/Tooltip.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Tooltip.kt index b748fe8..39bc3a9 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Tooltip.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Tooltip.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Typography.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Typography.kt similarity index 96% rename from src/main/kotlin/com/mineinabyss/launchy/ui/elements/Typography.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Typography.kt index d696e58..f87ecef 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Typography.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/Typography.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.elements +package com.mineinabyss.launchy.core.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/AppTopBar.kt similarity index 64% rename from src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/AppTopBar.kt index 487205d..7596522 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/AppTopBar.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui +package com.mineinabyss.launchy.core.ui.components.topbar import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideIn @@ -10,38 +10,18 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.CropSquare import androidx.compose.material.icons.rounded.Minimize -import androidx.compose.material.icons.rounded.RocketLaunch -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.Constants -import com.mineinabyss.launchy.ui.elements.BetterWindowDraggableArea -import com.mineinabyss.launchy.ui.state.TopBarState - -@Composable -fun WindowButton(icon: ImageVector, onClick: () -> Unit) { - Surface( - onClick = onClick, - modifier = Modifier.fillMaxHeight().width(44.dp), - contentColor = Color.White, - color = Color.Transparent - ) { - Icon(icon, "", Modifier.padding(10.dp)) - } -} +import com.mineinabyss.launchy.core.ui.LocalUiState +import com.mineinabyss.launchy.core.ui.TopBarState +import com.mineinabyss.launchy.core.ui.components.BetterWindowDraggableArea @Composable fun AppTopBar( @@ -51,8 +31,8 @@ fun AppTopBar( showBackButton: Boolean, onBackButtonClicked: (() -> Unit), ) { - val appState = LocalLaunchyState - val forceFullscreen = appState.ui.fullscreen + val ui = LocalUiState.current + val forceFullscreen = ui.fullscreen LaunchedEffect(forceFullscreen) { when (forceFullscreen) { true -> state.windowState.placement = WindowPlacement.Fullscreen @@ -120,36 +100,3 @@ fun AppTopBar( } } -@Composable -fun LaunchyTitle() { - Row { - Icon( - Icons.Rounded.RocketLaunch, - contentDescription = "Launchy", - tint = MaterialTheme.colorScheme.primary - ) - Text( - "Launchy - ${Constants.APP_VERSION ?: "dev"}", - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary - ) - } -} - -@Composable -fun LaunchyHeadline() { - Row { - Icon( - Icons.Rounded.RocketLaunch, - contentDescription = "Launchy", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(32.dp) - ) - Text( - "Launchy", - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineLarge - ) - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/LaunchyHeadline.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/LaunchyHeadline.kt new file mode 100644 index 0000000..ad79ad6 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/LaunchyHeadline.kt @@ -0,0 +1,31 @@ +package com.mineinabyss.launchy.core.ui.components.topbar + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.RocketLaunch +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun LaunchyHeadline() { + Row { + Icon( + Icons.Rounded.RocketLaunch, + contentDescription = "Launchy", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Text( + "Launchy", + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineLarge + ) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/LaunchyTitle.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/LaunchyTitle.kt new file mode 100644 index 0000000..c91b20c --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/LaunchyTitle.kt @@ -0,0 +1,27 @@ +package com.mineinabyss.launchy.core.ui.components.topbar + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.RocketLaunch +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import com.mineinabyss.launchy.core.ui.Constants + +@Composable +fun LaunchyTitle() { + Row { + Icon( + Icons.Rounded.RocketLaunch, + contentDescription = "Launchy", + tint = MaterialTheme.colorScheme.primary + ) + Text( + "Launchy - ${Constants.APP_VERSION ?: "dev"}", + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/WindowButton.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/WindowButton.kt new file mode 100644 index 0000000..0d730eb --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/components/topbar/WindowButton.kt @@ -0,0 +1,24 @@ +package com.mineinabyss.launchy.core.ui.components.topbar + +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun WindowButton(icon: ImageVector, onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = Modifier.fillMaxHeight().width(44.dp), + contentColor = Color.White, + color = Color.Transparent + ) { + Icon(icon, "", Modifier.padding(10.dp)) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/dialogs/SelectJVMDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/dialogs/SelectJVMDialog.kt new file mode 100644 index 0000000..08aeeb6 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/dialogs/SelectJVMDialog.kt @@ -0,0 +1,39 @@ +package com.mineinabyss.launchy.core.ui.dialogs + +import androidx.compose.material.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.mineinabyss.launchy.core.ui.Dialog +import com.mineinabyss.launchy.core.ui.components.LaunchyDialog +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.dialog +import com.mineinabyss.launchy.core.ui.screens.screen +import com.mineinabyss.launchy.settings.ui.JVMSettingsViewModel +import com.mineinabyss.launchy.util.koinViewModel + +@Composable +fun SelectJVMDialog( + jvm: JVMSettingsViewModel = koinViewModel() +) { + LaunchyDialog( + title = { Text("Install java", style = LocalTextStyle.current) }, + onAccept = { + dialog = Dialog.None + runCatching { + jvm.installJDK() + }.getOrElse { + dialog = Dialog.Error( + "Failed to install Java", + it.stackTraceToString() + ) + return@LaunchyDialog + } + }, + onDecline = { dialog = Dialog.None; screen = Screen.Settings }, + onDismiss = { dialog = Dialog.None; }, + acceptText = "Install automatically", + declineText = "Choose manually", + ) { + Text("Launchy needs Java to run Minecraft. It can install a version for you, or you can choose a path to an existing installation.") + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/core/ui/screens/Screen.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/screens/Screen.kt new file mode 100644 index 0000000..ae89755 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/screens/Screen.kt @@ -0,0 +1,20 @@ +package com.mineinabyss.launchy.core.ui.screens + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Screen( + val transparentTopBar: Boolean = true, + val showTitle: Boolean = false, + val showSidebar: Boolean = false, +) { + interface OnLeftSidebar + + data object Default : Screen(transparentTopBar = true, showTitle = true, showSidebar = true), OnLeftSidebar + data object NewInstance : Screen(transparentTopBar = true, showTitle = true, showSidebar = true), OnLeftSidebar + data object Settings : Screen(transparentTopBar = true, showTitle = true, showSidebar = true), OnLeftSidebar + + data object InstanceSettings : Screen(showTitle = true) + data object Instance : Screen(transparentTopBar = true) + +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screens.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/screens/Screens.kt similarity index 53% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screens.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/screens/Screens.kt index dae0914..fa314ed 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screens.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/screens/Screens.kt @@ -1,52 +1,55 @@ -package com.mineinabyss.launchy.ui.screens +package com.mineinabyss.launchy.core.ui.screens import androidx.compose.animation.* -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.layout.* -import androidx.compose.material.LinearProgressIndicator +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.material.LocalTextStyle import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Update import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.logic.AppUpdateState -import com.mineinabyss.launchy.logic.DesktopHelpers -import com.mineinabyss.launchy.logic.GithubUpdateChecker -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.state.modpack.GameInstanceState -import com.mineinabyss.launchy.ui.AppTopBar -import com.mineinabyss.launchy.ui.colors.currentHue -import com.mineinabyss.launchy.ui.dialogs.AuthDialog -import com.mineinabyss.launchy.ui.dialogs.SelectJVMDialog -import com.mineinabyss.launchy.ui.elements.LaunchyDialog -import com.mineinabyss.launchy.ui.screens.home.HomeScreen -import com.mineinabyss.launchy.ui.screens.home.newinstance.NewInstance -import com.mineinabyss.launchy.ui.screens.home.settings.SettingsScreen -import com.mineinabyss.launchy.ui.screens.modpack.main.InstanceScreen -import com.mineinabyss.launchy.ui.screens.modpack.main.SlightBackgroundTint -import com.mineinabyss.launchy.ui.screens.modpack.settings.InfoBarProperties -import com.mineinabyss.launchy.ui.screens.modpack.settings.InstanceSettingsScreen -import com.mineinabyss.launchy.ui.state.TopBar +import com.mineinabyss.launchy.auth.ui.AuthDialog +import com.mineinabyss.launchy.core.ui.Dialog +import com.mineinabyss.launchy.core.ui.LocalUiState +import com.mineinabyss.launchy.core.ui.TopBar +import com.mineinabyss.launchy.core.ui.components.InProgressTasksIndicator +import com.mineinabyss.launchy.core.ui.components.LaunchyDialog +import com.mineinabyss.launchy.core.ui.components.LeftSidebar +import com.mineinabyss.launchy.core.ui.components.topbar.AppTopBar +import com.mineinabyss.launchy.core.ui.dialogs.SelectJVMDialog +import com.mineinabyss.launchy.core.ui.theme.currentHue +import com.mineinabyss.launchy.instance.ui.InstanceViewModel +import com.mineinabyss.launchy.instance.ui.screens.InstanceScreen +import com.mineinabyss.launchy.instance.ui.screens.InstanceSettingsScreen +import com.mineinabyss.launchy.instance_creation.ui.NewInstance +import com.mineinabyss.launchy.instance_list.ui.InstanceListScreen +import com.mineinabyss.launchy.settings.ui.SettingsScreen +import com.mineinabyss.launchy.updater.data.AppUpdateState +import com.mineinabyss.launchy.updater.data.GithubUpdateChecker +import com.mineinabyss.launchy.util.DesktopHelpers +import com.mineinabyss.launchy.util.koinViewModel var screen: Screen by mutableStateOf(Screen.Default) var dialog: Dialog by mutableStateOf(Dialog.None) var updateAvailable: AppUpdateState by mutableStateOf(AppUpdateState.Unknown) -private val ModpackStateProvider = compositionLocalOf { error("No local modpack provided") } +//private val ModpackStateProvider = compositionLocalOf { error("No local modpack provided") } val snackbarHostState = SnackbarHostState() -val LocalGameInstanceState: GameInstanceState - @Composable get() = ModpackStateProvider.current +//val LocalGameInstanceState: GameInstanceState +// @Composable get() = ModpackStateProvider.current @Composable -fun Screens() = Scaffold( +fun Screens( + instanceViewModel: InstanceViewModel = koinViewModel() +) = Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, floatingActionButton = { val update = updateAvailable @@ -62,14 +65,13 @@ fun Screens() = Scaffold( } } ) { - val state = LocalLaunchyState - val packState = state.instanceState - - if (packState != null) CompositionLocalProvider(ModpackStateProvider provides packState) { - Screen(Screen.Instance) { InstanceScreen() } - Screen(Screen.InstanceSettings, transition = Transitions.SlideUp) { InstanceSettingsScreen() } + val ui = LocalUiState.current + Screen(Screen.Instance) { + val instance by instanceViewModel.instanceUiState.collectAsState() + InstanceScreen(instance ?: return@Screen) } - Screen(Screen.Default) { HomeScreen() } + Screen(Screen.InstanceSettings, transition = Transitions.SlideUp) { InstanceSettingsScreen() } + Screen(Screen.Default) { InstanceListScreen() } Screen(Screen.NewInstance) { NewInstance() } Screen(Screen.Settings) { SettingsScreen() } AnimatedVisibility( @@ -82,8 +84,8 @@ fun Screens() = Scaffold( val isDefault = screen is Screen.OnLeftSidebar - LaunchedEffect(isDefault, state.ui.preferHue) { - if (isDefault) currentHue = state.ui.preferHue + LaunchedEffect(isDefault, ui.preferHue) { + if (isDefault) currentHue = ui.preferHue } LaunchedEffect(Unit) { updateAvailable = GithubUpdateChecker.checkForUpdates() @@ -98,17 +100,18 @@ fun Screens() = Scaffold( onBackButtonClicked = { screen = when (screen) { Screen.Instance -> { - packState?.saveToConfig() + //TODO save states +// packState?.saveToConfig() Screen.Default } Screen.InstanceSettings -> { - packState?.saveToConfig() +// packState?.saveToConfig() Screen.Instance } Screen.Settings -> { - state.saveToConfig() +// state.saveToConfig() Screen.Default } @@ -116,10 +119,10 @@ fun Screens() = Scaffold( } } ) - when (val castDialog = dialog) { Dialog.None -> {} - Dialog.Auth -> AuthDialog( + is Dialog.Auth -> AuthDialog( + castDialog, onDismissRequest = { dialog = Dialog.None }, ) @@ -145,42 +148,6 @@ fun Screens() = Scaffold( ) { Text(castDialog.message, style = LocalTextStyle.current) } } } - - val tasks = state.inProgressTasks - val progressBarHeight by animateDpAsState(if (screen == Screen.InstanceSettings) InfoBarProperties.height else 0.dp) - - if (tasks.isNotEmpty()) Box(Modifier.fillMaxSize().padding(bottom = progressBarHeight)) { - val task = tasks.values.first() - val textModifier = Modifier.align(Alignment.BottomStart).padding(start = 10.dp, bottom = 20.dp) - val progressBarModifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) - val progressBarColor = MaterialTheme.colorScheme.primaryContainer - SlightBackgroundTint(Modifier.height(50.dp)) - when (task) { - is InProgressTask.WithPercentage -> { - Text( - "${task.name}... (${task.current}/${task.total}${if (task.measurement != null) " ${task.measurement}" else ""})", - modifier = textModifier - ) - LinearProgressIndicator( - progress = task.current.toFloat() / task.total, - modifier = progressBarModifier, - color = progressBarColor - ) - } - - else -> { - Text( - "${task.name}...", - modifier = textModifier - ) - - LinearProgressIndicator( - modifier = progressBarModifier, - color = progressBarColor - ) - } - } - } } enum class Transitions { @@ -210,4 +177,5 @@ fun Screen( } } } + InProgressTasksIndicator() } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/colors/Color.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Color.kt similarity index 96% rename from src/main/kotlin/com/mineinabyss/launchy/ui/colors/Color.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Color.kt index 538c70a..c1bd28c 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/colors/Color.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Color.kt @@ -1,10 +1,6 @@ -package com.mineinabyss.launchy.ui.colors +package com.mineinabyss.launchy.core.ui.theme -import androidx.compose.animation.animateColorAsState import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color val md_theme_light_primary = Color(0xFF924C00) diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/colors/Theme.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Theme.kt similarity index 88% rename from src/main/kotlin/com/mineinabyss/launchy/ui/colors/Theme.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Theme.kt index a84f7e3..07755d2 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/colors/Theme.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.colors +package com.mineinabyss.launchy.core.ui.theme import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -7,7 +7,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.mineinabyss.launchy.ui.LaunchyTypography var currentHue by mutableStateOf(0f) diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/Typography.kt b/src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Typography.kt similarity index 97% rename from src/main/kotlin/com/mineinabyss/launchy/ui/Typography.kt rename to src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Typography.kt index 7f905f5..18359e7 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/Typography.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/core/ui/theme/Typography.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui +package com.mineinabyss.launchy.core.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.font.FontFamily diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/Typealiases.kt b/src/main/kotlin/com/mineinabyss/launchy/data/Typealiases.kt deleted file mode 100644 index c90f8f6..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/Typealiases.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.mineinabyss.launchy.data - -typealias ModName = String -typealias GroupName = String -typealias DownloadURL = String -typealias ConfigURL = String - - -typealias ModID = String diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/auth/SessionStorage.kt b/src/main/kotlin/com/mineinabyss/launchy/data/auth/SessionStorage.kt deleted file mode 100644 index 540e9f7..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/auth/SessionStorage.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.mineinabyss.launchy.data.auth - -import com.google.gson.GsonBuilder -import com.google.gson.JsonParser -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.logic.Auth -import kotlinx.serialization.Serializable -import net.raphimc.minecraftauth.step.java.session.StepFullJavaSession.FullJavaSession -import java.util.* -import kotlin.io.path.* - -@Serializable -data class SessionStorage( - val microsoftAccessToken: String, - val microsoftRefreshToken: String, - val minecraftAccessToken: String, - val xboxUserId: String, -) { - companion object { - val gson = GsonBuilder().setPrettyPrinting().create() - - fun load(uuid: UUID): FullJavaSession? { - val targetFile = (Dirs.accounts / "$uuid.json") - if (!targetFile.exists()) return null - return runCatching { - Auth.JAVA_DEVICE_CODE_LOGIN.fromJson( - JsonParser.parseString( - targetFile.readText() - ).asJsonObject - ) - }.onFailure { - println("Failed to load session for $uuid, ignoring file") - it.printStackTrace() - }.getOrNull() - } - - fun deleteIfExists(uuid: UUID) { - val targetFile = (Dirs.accounts / "$uuid.json") - targetFile.deleteIfExists() - } - - fun save(session: FullJavaSession) { - val json = Auth.JAVA_DEVICE_CODE_LOGIN.toJson(session) - val targetFile = (Dirs.accounts / "${session.mcProfile.id}.json").createParentDirectories() - targetFile.deleteIfExists() - targetFile.createFile() - targetFile.writeText(gson.toJson(json)) - - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt deleted file mode 100644 index 39b18bb..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.mineinabyss.launchy.data.config - -import com.charleskorn.kaml.decodeFromStream -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.Formats -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlin.io.path.inputStream -import kotlin.io.path.writeText - - -@Serializable -data class Config( - val handledImportOptions: Boolean = false, - val onboardingComplete: Boolean = false, - val currentProfile: PlayerProfile? = null, - val javaPath: String? = null, - val jvmArguments: String? = null, - val memoryAllocation: Int? = null, - val useRecommendedJvmArguments: Boolean = true, - val preferHue: Float? = null, - val startInFullscreen: Boolean = false, - val lastPlayedMap: Map = mapOf(), -) { - fun save() { - Dirs.configFile.writeText(Formats.yaml.encodeToString(this)) - } - - companion object { - fun read(): Result = runCatching { - Formats.yaml.decodeFromStream(serializer(), Dirs.configFile.inputStream()) - }.onFailure { it.printStackTrace() } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt deleted file mode 100644 index 01696b7..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.mineinabyss.launchy.data.config - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.charleskorn.kaml.encodeToStream -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.Formats -import com.mineinabyss.launchy.logic.AppDispatchers -import com.mineinabyss.launchy.logic.Downloader -import com.mineinabyss.launchy.logic.UpdateResult -import com.mineinabyss.launchy.logic.showDialogOnError -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.state.LaunchyState -import com.mineinabyss.launchy.state.modpack.GameInstanceState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.nio.file.Path -import kotlin.io.path.* - -class GameInstance( - val configDir: Path, -) { - val instanceFile = configDir / "instance.yml" - val config: GameInstanceConfig = GameInstanceConfig.read(instanceFile).getOrThrow() - - val overridesDir = configDir / "overrides" - - val minecraftDir = config.overrideMinecraftDir?.let { Path(it) } ?: Dirs.modpackDir(configDir.name) - - val modsDir = (minecraftDir / "mods").createDirectories() - val userMods = (minecraftDir / "modsFromUser").createDirectories() - - val downloadsDir: Path = minecraftDir / "launchyDownloads" - val userConfigFile = (configDir / "config.yml") - - var updatesAvailable by mutableStateOf(false) - var enabled: Boolean by mutableStateOf(true) - - suspend fun createModpackState(state: LaunchyState, awaitUpdatesCheck: Boolean = false): GameInstanceState? { - val userConfig = InstanceUserConfig.load(userConfigFile).getOrNull() ?: InstanceUserConfig() - - val modpack = state.runTask("loadingModpack ${config.name}", InProgressTask("Loading modpack ${config.name}")) { - config.source.loadInstance(this) - .showDialogOnError("Failed to read instance") - .getOrElse { - it.printStackTrace() - return null - } - } - val cloudUrl = config.cloudInstanceURL - if (cloudUrl != null) { - AppDispatchers.IO.launch { - val result = Downloader.checkUpdates(this@GameInstance, cloudUrl) - if (result !is UpdateResult.UpToDate) updatesAvailable = true - }.also { if(awaitUpdatesCheck) it.join() } - } - return GameInstanceState(this, modpack, userConfig) - } - - init { - require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" } - userMods - } - - data class CloudInstanceWithHeaders( - val config: GameInstanceConfig, - val url: String, - val headers: Downloader.ModifyHeaders, - ) - - companion object { - fun createCloudInstance(state: LaunchyState, cloud: CloudInstanceWithHeaders) { - val instanceDir = Dirs.modpackConfigDir(cloud.config.name) - instanceDir.createDirectories() - - Formats.yaml.encodeToStream( - cloud.config.copy(cloudInstanceURL = cloud.url), - (instanceDir / "instance.yml").outputStream() - ) - val instance = GameInstance(instanceDir) - Downloader.saveHeaders(instance, cloud.url, cloud.headers) - state.gameInstances += instance - } - - fun readAll(rootDir: Path): List { - return rootDir - .listDirectoryEntries() - .filter { it.isDirectory() } - .mapNotNull { - runCatching { GameInstance(it) } - .onFailure { it.printStackTrace() } - .getOrNull() - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt deleted file mode 100644 index 492bf0b..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstanceConfig.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.mineinabyss.launchy.data.config - -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.res.loadImageBitmap -import com.charleskorn.kaml.decodeFromStream -import com.charleskorn.kaml.encodeToStream -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.Formats -import com.mineinabyss.launchy.data.modpacks.source.PackSource -import com.mineinabyss.launchy.logic.Downloader -import com.mineinabyss.launchy.logic.urlToFileName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import java.nio.file.Path -import kotlin.io.path.div -import kotlin.io.path.inputStream -import kotlin.io.path.outputStream - -@Serializable -data class GameInstanceConfig( - val name: String, - val description: String, - val backgroundURL: String, - val logoURL: String, - val source: PackSource, - val hue: Float = 0f, - val cloudInstanceURL: String? = null, - val overrideMinecraftDir: String? = null, -) { - @Transient - val backgroundPath = Dirs.imageCache / "background-${urlToFileName(backgroundURL)}" - - @Transient - val logoPath = Dirs.imageCache / "icon-${urlToFileName(logoURL)}" - - @Transient - private var cachedBackground = mutableStateOf(null) - - @Transient - private var cachedLogo = mutableStateOf(null) - - @OptIn(ExperimentalCoroutinesApi::class) - @Transient - val downloadScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) - - private suspend fun loadBackground() { - runCatching { - Downloader.download(backgroundURL, backgroundPath, Downloader.Options(overwrite = false)) - val painter = BitmapPainter(loadImageBitmap(backgroundPath.inputStream())) - cachedBackground.value = painter - }.onFailure { it.printStackTrace() } - } - - private suspend fun loadLogo() { - runCatching { - Downloader.download(logoURL, logoPath, Downloader.Options(overwrite = false)) - val painter = BitmapPainter(loadImageBitmap(logoPath.inputStream())) - cachedLogo.value = painter - }.onFailure { it.printStackTrace() } - } - - fun getBackgroundAsState() = - cachedBackground.also { - if (it.value == null) downloadScope.launch { loadBackground() } - } - - fun getLogoAsState() = - cachedLogo.also { - if (it.value == null) downloadScope.launch { loadLogo() } - } - - fun saveTo(path: Path) = runCatching { - Formats.yaml.encodeToStream(this, path.outputStream()) - } - - companion object { - fun read(path: Path) = runCatching { - Formats.yaml.decodeFromStream(serializer(), path.inputStream()) - }.onFailure { it.printStackTrace() } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/InstanceUserConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/InstanceUserConfig.kt deleted file mode 100644 index 4e3dc58..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/InstanceUserConfig.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.mineinabyss.launchy.data.config - -import com.charleskorn.kaml.decodeFromStream -import com.mineinabyss.launchy.data.Formats -import com.mineinabyss.launchy.data.GroupName -import com.mineinabyss.launchy.data.ModID -import com.mineinabyss.launchy.data.modpacks.InstanceModLoaders -import com.mineinabyss.launchy.logic.ModDownloader -import com.mineinabyss.launchy.logic.hashing.Hashing.checksum -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import kotlinx.serialization.encodeToString -import java.nio.file.Path -import java.security.MessageDigest -import kotlin.io.path.* - -enum class HashCheck { - UNKNOWN, VERIFIED, FAILED -} - -@Serializable -data class DownloadInfo( - val url: String, - val path: String, - val desiredHash: String?, - val hashCheck: HashCheck, - val result: ModDownloader.DownloadResult, -) { - @Transient - val systemPath = Path(path) - - fun failed(): Boolean { - return result == ModDownloader.DownloadResult.Failed - || systemPath.isRegularFile() - || (desiredHash != null && hashCheck == HashCheck.FAILED) - } - - fun calculateSha1Hash(minecraftDir: Path): String { - val md = MessageDigest.getInstance("SHA-1") - return (minecraftDir / systemPath).checksum(md) - } -} - -@Serializable -data class InstanceUserConfig( - val userAgreedDeps: InstanceModLoaders? = null, - val fullEnabledGroups: Set = setOf(), - val fullDisabledGroups: Set = setOf(), - val toggledMods: Set = setOf(), - val toggledConfigs: Set = setOf(), - val seenGroups: Set = setOf(), - val modDownloadInfo: Map = mapOf(), -// val configDownloadInfo: Map = mapOf(), - val downloadUpdates: Boolean = true, -) { - fun save(file: Path) { - file.createParentDirectories().deleteIfExists() - file.writeText(Formats.yaml.encodeToString(this)) - } - - companion object { - fun load(file: Path): Result = runCatching { - return@runCatching if (file.exists()) Formats.yaml.decodeFromStream(file.inputStream()) - else InstanceUserConfig() - }.onFailure { it.printStackTrace() } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/PlayerProfile.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/PlayerProfile.kt deleted file mode 100644 index 942b0d7..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/PlayerProfile.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.mineinabyss.launchy.data.config - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.res.loadImageBitmap -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.serializers.UUIDSerializer -import com.mineinabyss.launchy.logic.Downloader -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import java.util.* -import kotlin.io.path.inputStream - -@Serializable -data class PlayerProfile( - val name: String, - val uuid: @Serializable(with = UUIDSerializer::class) UUID, -) { - @Transient - private val avatar = mutableStateOf(null) - - @OptIn(ExperimentalCoroutinesApi::class) - @Transient - private val downloadScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) - - @Composable - fun getAvatar(): MutableState = remember { - avatar.also { - if (it.value != null) return@also - downloadScope.launch { - Downloader.downloadAvatar(uuid, Downloader.Options(overwrite = false)) - it.value = BitmapPainter( - loadImageBitmap(Dirs.avatar(uuid).inputStream()), - filterQuality = FilterQuality.None - ) - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ExtraPackInfo.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ExtraPackInfo.kt deleted file mode 100644 index e96c0f2..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ExtraPackInfo.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks - -import kotlinx.serialization.Serializable - -@Serializable -class ModReference( - val urlContains: String, - val info: ModConfig? = null, -) -@Serializable -class ExtraPackInfo( - val groups: List = listOf(), - val modGroups: Map> = mapOf(), -) diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mod.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mod.kt deleted file mode 100644 index d0ec966..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mod.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks - -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.modpacks.formats.ModrinthPackFormat -import io.ktor.http.* -import java.nio.file.Path -import kotlin.io.path.div - -data class Mod( - private val downloadDir: Path, - val info: ModConfig, - val modId: String, - val desiredHashes: ModrinthPackFormat.Hashes?, -) { - val absoluteDownloadDest = - if (info.downloadPath != null) downloadDir / info.downloadPath.validated - else downloadDir / "mods" / "${info.id ?: info.name}.jar" - - val downloadUrl: Url = Url(info.url) - - fun compatibleWith(other: Mod) = - other.info.name !in info.incompatibleWith && info.name !in other.info.incompatibleWith -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Modpack.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Modpack.kt deleted file mode 100644 index 2b3f87a..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Modpack.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks - -import java.nio.file.Path - -class Modpack( - val modLoaders: InstanceModLoaders, - val mods: Mods, - val overridesPaths: List = listOf(), -) diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mods.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mods.kt deleted file mode 100644 index 731033a..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mods.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks - -import com.mineinabyss.launchy.data.GroupName -import com.mineinabyss.launchy.data.ModID - -data class Mods( - val modGroups: Map>, -) { - val groups = modGroups.keys - val mods = modGroups.values.flatten().toSet() - - private val nameToGroup: Map = groups.associateBy { it.name } - private val idToMod: Map = modGroups.values - .flatten() - .associateBy { it.modId } - - // - fun getModById(id: ModID): Mod? = idToMod[id] - fun getGroup(name: GroupName): Group? = nameToGroup[name] - - companion object { - const val VERSIONS_URL = "https://raw.githubusercontent.com/MineInAbyss/launchy/master/versions.yml" - - fun withSingleGroup(mods: Collection) = Mods( - modGroups = mapOf( - Group("Default", forceEnabled = true) to mods.toSet() - ) - ) - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ExtraInfoFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ExtraInfoFormat.kt deleted file mode 100644 index 3ff04cf..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ExtraInfoFormat.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks.formats - -import com.mineinabyss.launchy.data.modpacks.ExtraPackInfo -import com.mineinabyss.launchy.data.modpacks.Group -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.data.modpacks.Mods -import java.nio.file.Path - - -data class ExtraInfoFormat( - val format: PackFormat, - val extraInfoPack: ExtraPackInfo, -) : PackFormat by format { - override fun toGenericMods(downloadsDir: Path): Mods { - val originalMods = format.toGenericMods(downloadsDir) - val foundMods = mutableSetOf() - val mods: Map> = extraInfoPack.modGroups - .mapKeys { (name, _) -> extraInfoPack.groups.single { it.name == name } } - .mapValues { (_, mods) -> - mods.mapNotNull { ref -> - val found = originalMods.mods.find { mod -> ref.urlContains in mod.info.url } - if (found != null) foundMods.add(found) - if (found != null && ref.info != null) - found.copy(info = ref.info.copy(url = found.info.url)) - else found - }.toSet() - } - - val originalGroups = originalMods.modGroups.mapValues { - it.value.filterTo(mutableSetOf()) { mod -> mod !in foundMods } - } - return Mods((originalGroups + mods) - .filter { (_, mods) -> mods.isNotEmpty() }) - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/LaunchyPackFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/LaunchyPackFormat.kt deleted file mode 100644 index 94dfa6a..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/LaunchyPackFormat.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks.formats - -import com.mineinabyss.launchy.data.GroupName -import com.mineinabyss.launchy.data.modpacks.* -import kotlinx.serialization.Serializable -import java.nio.file.Path - -@Serializable -data class LaunchyPackFormat( - val fabricVersion: String? = null, - val minecraftVersion: String, - val groups: Set, - private val modGroups: Map>, -) : PackFormat { - override fun toGenericMods(downloadsDir: Path): Mods { - return Mods(modGroups - .mapKeys { (name, _) -> groups.single { it.name == name } } - .mapValues { (_, mods) -> - mods.map { - Mod( - downloadDir = downloadsDir, - info = it, - modId = it.id ?: it.name, - desiredHashes = null, - ) - }.toSet() - }) - } - - override fun getModLoaders(): InstanceModLoaders { - return InstanceModLoaders(minecraft = minecraftVersion, fabricLoader = fabricVersion) - } - - override fun getOverridesPaths(configDir: Path): List = emptyList() -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModrinthPackFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModrinthPackFormat.kt deleted file mode 100644 index f711224..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModrinthPackFormat.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks.formats - -import com.mineinabyss.launchy.data.modpacks.InstanceModLoaders -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.data.modpacks.ModConfig -import com.mineinabyss.launchy.data.modpacks.Mods -import kotlinx.serialization.Serializable -import java.nio.file.Path -import kotlin.io.path.div - -@Serializable -data class ModrinthPackFormat( - val dependencies: InstanceModLoaders, - val files: List, - val formatVersion: Int, - val name: String, - val versionId: String, -) : PackFormat { - @Serializable - data class PackFile( - val downloads: List, - val fileSize: Long, - val path: ModDownloadPath, - val hashes: Hashes, - ) { - fun toMod(packDir: Path) = Mod( - packDir, - ModConfig( - name = path.validated.toString().removePrefix("mods/").removeSuffix(".jar"), - desc = "", - url = downloads.single(), - downloadPath = path, - ), - modId = downloads.single().removePrefix("https://cdn.modrinth.com/data/").substringBefore("/versions"), - desiredHashes = hashes, - ) - } - - @Serializable - data class Hashes( - val sha1: String, - val sha512: String, - ) - - override fun getModLoaders(): InstanceModLoaders { - return dependencies - } - - override fun toGenericMods(downloadsDir: Path) = - Mods.withSingleGroup(files.map { it.toMod(downloadsDir) }) - - override fun getOverridesPaths(configDir: Path): List = listOf(configDir / "mrpack" / "overrides") -} - diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/PackFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/PackFormat.kt deleted file mode 100644 index 3d723a1..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/PackFormat.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks.formats - -import com.mineinabyss.launchy.data.modpacks.InstanceModLoaders -import com.mineinabyss.launchy.data.modpacks.Mods -import java.nio.file.Path - -sealed interface PackFormat { - fun toGenericMods(downloadsDir: Path): Mods - - fun getModLoaders(): InstanceModLoaders - - fun getOverridesPaths(configDir: Path): List -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt deleted file mode 100644 index 6f0a2fc..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks.source - -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.data.modpacks.Modpack -import com.mineinabyss.launchy.logic.AppDispatchers -import com.mineinabyss.launchy.logic.Downloader -import com.mineinabyss.launchy.logic.UpdateResult -import kotlinx.coroutines.launch -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlin.io.path.notExists - -@Serializable -sealed class PackSource { - abstract suspend fun loadInstance(instance: GameInstance): Result - - abstract suspend fun updateInstance(instance: GameInstance): Result - - @Serializable - @SerialName("localFile") - class LocalFile(val type: PackType) : PackSource() { - override suspend fun loadInstance(instance: GameInstance): Result = runCatching { - val format = type.getFormat(instance.configDir).getOrThrow() - val mods = format.toGenericMods(instance.downloadsDir) - val modLoaders = format.getModLoaders() - Modpack(modLoaders, mods, format.getOverridesPaths(instance.configDir)) - } - - override suspend fun updateInstance(instance: GameInstance): Result { - return runCatching { GameInstance(instance.configDir) } - } - } - - @SerialName("downloadFromURL") - @Serializable - class DownloadFromURL(val url: String, val type: PackType) : PackSource() { - override suspend fun loadInstance(instance: GameInstance): Result { - val downloadTo = type.getFilePath(instance.configDir) - if (downloadTo.notExists()) { - Downloader.download(url, downloadTo, options = Downloader.Options(saveModifyHeadersFor = instance)) - type.afterDownload(instance.configDir) - } else { - AppDispatchers.IO.launch { - val result = Downloader.checkUpdates(instance, url) - if (result !is UpdateResult.UpToDate) instance.updatesAvailable = true - } - } - return LocalFile(type).loadInstance(instance) - } - - override suspend fun updateInstance(instance: GameInstance): Result { - return runCatching { - val downloadTo = type.getFilePath(instance.configDir) - Downloader.download(url, downloadTo, options = Downloader.Options(saveModifyHeadersFor = instance)) - type.afterDownload(instance.configDir) - GameInstance(instance.configDir) - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackType.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackType.kt deleted file mode 100644 index 62345a8..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackType.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.mineinabyss.launchy.data.modpacks.source - -import com.charleskorn.kaml.decodeFromStream -import com.mineinabyss.launchy.data.Formats -import com.mineinabyss.launchy.data.modpacks.ExtraPackInfo -import com.mineinabyss.launchy.data.modpacks.formats.ExtraInfoFormat -import com.mineinabyss.launchy.data.modpacks.formats.LaunchyPackFormat -import com.mineinabyss.launchy.data.modpacks.formats.ModrinthPackFormat -import com.mineinabyss.launchy.data.modpacks.formats.PackFormat -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.decodeFromStream -import org.rauschig.jarchivelib.ArchiverFactory -import java.nio.file.Path -import kotlin.io.path.* - -@Serializable -enum class PackType { - Launchy, Modrinth; - - fun getFilePath(configDir: Path): Path { - val ext = when (this) { - Launchy -> "yml" - Modrinth -> "zip" - } - return configDir / "pack.$ext" - } - - @OptIn(ExperimentalPathApi::class) - fun afterDownload(configDir: Path) { - val path = getFilePath(configDir) - if (this == Modrinth) { - val unzipDir = configDir / "mrpack" - unzipDir.deleteRecursively() - ArchiverFactory.createArchiver("zip").extract(path.toFile(), unzipDir.toFile()) - } - } - - @OptIn(ExperimentalSerializationApi::class) - fun getFormat(configDir: Path): Result { - val file = getFilePath(configDir) - return when (this) { - Launchy -> runCatching { - if (!file.isRegularFile()) return Result.failure(IllegalStateException("Could not find modpack file at $file")) - Formats.yaml.decodeFromStream(file.inputStream()) - } - - Modrinth -> runCatching { - val unzipDir = configDir / "mrpack" - val index = unzipDir / "modrinth.index.json" - if (unzipDir.notExists()) { - afterDownload(configDir) - } - val extraInfoFile = (unzipDir / "launchy.yml").takeIf { it.isRegularFile() } - val extraInfo = extraInfoFile?.runCatching { - Formats.yaml.decodeFromStream(extraInfoFile.inputStream()) - }?.getOrNull() - val mrpack = Formats.json.decodeFromStream(index.inputStream()) - if (extraInfo != null) ExtraInfoFormat(mrpack, extraInfo) - else mrpack - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/DownloadQueueState.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/DownloadQueueState.kt new file mode 100644 index 0000000..081e753 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/DownloadQueueState.kt @@ -0,0 +1,53 @@ +package com.mineinabyss.launchy.downloads.data + +//class DownloadQueueState( +// private val userConfig: InstanceUserConfig, +// val modpack: Modpack, +// val toggles: InstanceViewModel +//) { +// /** Live mod download info, including mods that have been removed from the latest modpack version. */ +// val modDownloadInfo = mutableStateMapOf().apply { +// val availableIds = toggles.availableMods.map { it.modId } +// putAll(userConfig.modDownloadInfo.filter { it.key in availableIds }) +// } +// +// /** Mods whose download url matches a previously downloaded url and exist on the filesystem */ +// val failures by derivedStateOf { +// toggles.enabledMods.filter { +// modDownloadInfo[it.modId]?.failed() == true +// } +// } +// +// /** Toggled mods that haven't been previously installed (are new to the instance) */ +// val newDownloads by derivedStateOf { +// toggles.enabledMods.filter { it.modId !in modDownloadInfo.keys } +// } +// +// /** Toggled mods that have previously been downloaded but whose URL has changed */ +// val updates by derivedStateOf { +// toggles.enabledMods +// .filter { mod -> +// modDownloadInfo[mod.modId]?.let { mod.downloadUrl.toString() != it.url } == true +// } +// } +// +// /** Mods (currently listed in the Modpack) that were previously enabled, but no longer are */ +// val deletions by derivedStateOf { +// (modpack.mods.mods - toggles.enabledMods).filter { modDownloadInfo.contains(it.modId) } +// } +// +// val areModLoaderUpdatesAvailable by derivedStateOf { +// modpack.modLoaders != userAgreedModLoaders +// } +// +// var userAgreedModLoaders by mutableStateOf(userConfig.userAgreedDeps) +// +// val needsInstall by derivedStateOf { updates + newDownloads + failures } +// +// val areUpdatesQueued by derivedStateOf { updates.isNotEmpty() } +// val areNewDownloadsQueued by derivedStateOf { newDownloads.isNotEmpty() } +// val areDeletionsQueued by derivedStateOf { deletions.isNotEmpty() } +// val areOperationsQueued by derivedStateOf { +// areUpdatesQueued || areNewDownloadsQueued || areDeletionsQueued || areModLoaderUpdatesAvailable +// } +//} diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadState.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/DownloadState.kt similarity index 81% rename from src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadState.kt rename to src/main/kotlin/com/mineinabyss/launchy/downloads/data/DownloadState.kt index 96208ab..bd0dbe1 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/DownloadState.kt @@ -1,10 +1,10 @@ -package com.mineinabyss.launchy.state.modpack +package com.mineinabyss.launchy.downloads.data import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.logic.Progress +import com.mineinabyss.launchy.downloads.data.formats.Mod +import com.mineinabyss.launchy.util.Progress class DownloadState { val inProgressMods = mutableStateMapOf() diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/Downloader.kt similarity index 55% rename from src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt rename to src/main/kotlin/com/mineinabyss/launchy/downloads/data/Downloader.kt index 5eb93c3..5d50b49 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/Downloader.kt @@ -1,30 +1,36 @@ -package com.mineinabyss.launchy.logic - -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.state.LaunchyState -import com.mineinabyss.launchy.util.Arch -import com.mineinabyss.launchy.util.OS +package com.mineinabyss.launchy.downloads.data + +import com.mineinabyss.launchy.instance.data.InstanceModel +import com.mineinabyss.launchy.util.Dirs +import com.mineinabyss.launchy.util.Progress +import com.mineinabyss.launchy.util.UpdateResult +import com.mineinabyss.launchy.util.urlToFileName import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* import io.ktor.utils.io.* import io.ktor.utils.io.core.* -import org.rauschig.jarchivelib.ArchiveFormat +import kotlinx.serialization.json.Json import org.rauschig.jarchivelib.Archiver -import org.rauschig.jarchivelib.ArchiverFactory -import org.rauschig.jarchivelib.CompressionType import java.nio.file.Path import java.util.* import kotlin.io.path.* -object Downloader { +class Downloader { val httpClient = HttpClient(CIO) { install(HttpTimeout) + install(ContentNegotiation) { + json(json = Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + }) + } } data class ModifyHeaders(val lastModified: String, val contentLength: Long) { @@ -37,12 +43,12 @@ object Downloader { headers["Content-Length"]?.toLongOrNull() ?: 0 ) - fun fileFor(instance: GameInstance, url: String) = + fun fileFor(instance: InstanceModel, url: String) = Dirs.cacheDir(instance) / "${urlToFileName(url)}.header" } } - suspend fun checkUpdates(instance: GameInstance, url: String): UpdateResult { + suspend fun checkUpdates(instance: InstanceModel, url: String): UpdateResult { val headers = ModifyHeaders.of(httpClient.head(url).headers) val cache = headers.toCacheString() val cacheFile = ModifyHeaders.fileFor(instance, url) @@ -53,7 +59,7 @@ object Downloader { } } - fun saveHeaders(instance: GameInstance, url: String, headers: ModifyHeaders) { + fun saveHeaders(instance: InstanceModel, url: String, headers: ModifyHeaders) { ModifyHeaders.fileFor(instance, url).createParentDirectories().apply { deleteIfExists() createFile() @@ -119,64 +125,6 @@ object Downloader { download("https://mc-heads.net/avatar/$uuid", Dirs.avatar(uuid), options) } - /** @return Path to java executable */ - @OptIn(ExperimentalPathApi::class) - suspend fun installJDK( - state: LaunchyState, - ): Path? { - try { - state.inProgressTasks["installJDK"] = InProgressTask("Downloading Java environment") - val arch = Arch.get().openJDKArch - val os = OS.get().openJDKName - val url = "https://api.adoptium.net/v3/binary/latest/17/ga/$os/$arch/jre/hotspot/normal/eclipse" - val javaInstallation = when (OS.get()) { - OS.WINDOWS -> JavaInstallation( - url, - "bin/java.exe", - ArchiverFactory.createArchiver(ArchiveFormat.ZIP) - ) - - OS.MAC -> JavaInstallation( - url, - "Contents/Home/bin/java", - ArchiverFactory.createArchiver(ArchiveFormat.TAR, CompressionType.GZIP) - ) - - OS.LINUX -> JavaInstallation( - url, - "bin/java", - ArchiverFactory.createArchiver(ArchiveFormat.TAR, CompressionType.GZIP) - ) - } - val downloadTo = Dirs.jdks / "openjdk-17${javaInstallation.archiver.filenameExtension}" - val extractTo = Dirs.jdks / "openjdk-17" - - val existingInstall = extractTo.resolve(javaInstallation.relativeJavaExecutable) - if (existingInstall.exists()) return existingInstall - download(javaInstallation.url, downloadTo, Options( - onProgressUpdate = { - state.inProgressTasks["installJDK"] = - InProgressTask.bytes( - "Downloading Java environment", - it.bytesDownloaded, - it.totalBytes - ) - } - )) - state.inProgressTasks["installJDK"] = InProgressTask("Extracting Java environment") - - // Handle a case where the extraction failed and the folder exists but not the java executable - extractTo.takeIf { it.exists() }?.deleteRecursively() - javaInstallation.archiver.extract(downloadTo.toFile(), extractTo.toFile()) - val entries = extractTo.listDirectoryEntries() - val jrePath = if (entries.size == 1) entries.first() else extractTo - downloadTo.deleteIfExists() - return jrePath / javaInstallation.relativeJavaExecutable - } finally { - state.inProgressTasks.remove("installJDK") - } - } - class JavaInstallation( val url: String, val relativeJavaExecutable: String, @@ -187,6 +135,6 @@ object Downloader { val overwrite: Boolean = true, val whenChanged: () -> Unit = {}, val onProgressUpdate: (progress: Progress) -> Unit = {}, - val saveModifyHeadersFor: GameInstance? = null, + val saveModifyHeadersFor: InstanceModel? = null, ) } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/ModDownloader.kt similarity index 88% rename from src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt rename to src/main/kotlin/com/mineinabyss/launchy/downloads/data/ModDownloader.kt index 4f51b2f..854753f 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/ModDownloader.kt @@ -1,13 +1,13 @@ -package com.mineinabyss.launchy.logic - -import com.mineinabyss.launchy.data.ModID -import com.mineinabyss.launchy.data.config.DownloadInfo -import com.mineinabyss.launchy.data.config.HashCheck -import com.mineinabyss.launchy.data.modpacks.InstanceModLoaders -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.state.LaunchyState -import com.mineinabyss.launchy.state.modpack.GameInstanceState +package com.mineinabyss.launchy.downloads.data + +import com.mineinabyss.launchy.core.ui.LaunchyUiState +import com.mineinabyss.launchy.downloads.data.formats.Mod +import com.mineinabyss.launchy.instance.data.DownloadInfo +import com.mineinabyss.launchy.instance.data.HashCheck +import com.mineinabyss.launchy.instance.data.ModLoaderModel +import com.mineinabyss.launchy.instance.ui.GameInstanceState +import com.mineinabyss.launchy.launcher.data.Launcher +import com.mineinabyss.launchy.util.* import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -17,7 +17,7 @@ import kotlin.io.path.* object ModDownloader { - suspend fun GameInstanceState.installMCAndModLoaders(state: LaunchyState, modLoaders: InstanceModLoaders) { + suspend fun GameInstanceState.installMCAndModLoaders(state: LaunchyUiState, modLoaders: ModLoaderModel) { state.runTask(Tasks.installModLoadersId, InProgressTask("Installing ${modLoaders.fullVersionName}")) { Launcher.download( modLoaders, @@ -36,7 +36,7 @@ object ModDownloader { data object Failed : DownloadResult } - suspend fun GameInstanceState.download(mod: Mod, overwrite: Boolean): DownloadResult { + suspend fun download(mod: Mod, overwrite: Boolean): DownloadResult { val name = mod.info.name try { println("Starting download of $name") @@ -67,7 +67,7 @@ object ModDownloader { * does not install any mod updates or new dep versions if they changed in the modpack. * Primarily the mod loader/minecraft version. */ - suspend fun GameInstanceState.ensureDependenciesReady(state: LaunchyState) = coroutineScope { + suspend fun GameInstanceState.ensureDependenciesReady(state: LaunchyUiState) = coroutineScope { val currentDeps = queued.userAgreedModLoaders if (currentDeps == null) { queued.userAgreedModLoaders = modpack.modLoaders @@ -98,13 +98,13 @@ object ModDownloader { (existingEntries - linked).forEach { it.deleteIfExists() } } - suspend fun GameInstanceState.prepareWithoutChangingInstalledMods(state: LaunchyState) { + suspend fun GameInstanceState.prepareWithoutChangingInstalledMods(state: LaunchyUiState) { ensureDependenciesReady(state) copyMods() } @OptIn(ExperimentalPathApi::class) - fun GameInstanceState.copyOverrides(state: LaunchyState) { + fun GameInstanceState.copyOverrides(state: LaunchyUiState) { state.runTask(Tasks.copyOverridesId, InProgressTask("Copying overrides")) { modpack.overridesPaths.forEach { it.copyToRecursively( @@ -136,7 +136,7 @@ object ModDownloader { * Updates mod loader versions and mods to latest modpack definition. */ suspend fun GameInstanceState.startInstall( - state: LaunchyState, + state: LaunchyUiState, ignoreCachedCheck: Boolean = false ): Result<*> = coroutineScope { queued.userAgreedModLoaders = modpack.modLoaders diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ExtraInfoFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ExtraInfoFormat.kt new file mode 100644 index 0000000..9f17834 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ExtraInfoFormat.kt @@ -0,0 +1,67 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import com.charleskorn.kaml.decodeFromStream +import com.mineinabyss.launchy.instance.data.InstanceModel +import com.mineinabyss.launchy.instance.data.ModGroup +import com.mineinabyss.launchy.instance.data.ModListModel +import com.mineinabyss.launchy.instance.data.storage.ModConfig +import com.mineinabyss.launchy.util.Formats +import kotlinx.serialization.Serializable +import java.nio.file.Path +import kotlin.io.path.div +import kotlin.io.path.inputStream +import kotlin.io.path.isRegularFile + +data class ExtraInfoFormat( + val innerFormat: ModpackFormat, +) : ModpackFormat { + override suspend fun prepareSource(instance: InstanceModel, download: Path) { + innerFormat.prepareSource(instance, download) + } + + override suspend fun loadPackFor(instance: InstanceModel): Result { + val inner = innerFormat.loadPackFor(instance) + + val extraInfoFile = (instance.modpackFilesDir / "launchy.yml").takeIf { it.isRegularFile() } + val extraInfo = extraInfoFile?.runCatching { + Formats.yaml.decodeFromStream(extraInfoFile.inputStream()) + }?.getOrNull() ?: return inner + + return inner.map { pack -> + val originalMods = pack.modList.mods + val foundMods = mutableSetOf() + val mods: Map> = extraInfo.modGroups + .mapKeys { (name, _) -> pack.modList.groups.single { it.name == name } } + .mapValues { (_, mods) -> + mods.mapNotNull { ref -> + val found = originalMods.find { mod -> ref.urlContains in mod.info.url } + if (found != null) foundMods.add(found) + if (found != null && ref.info != null) + found.copy(info = ref.info.copy(url = found.info.url)) + else found + }.toSet() + } + + val originalGroups = pack.modList.modGroups.mapValues { + it.value.filterTo(mutableSetOf()) { mod -> mod !in foundMods } + } + + pack.copy( + modList = ModListModel((originalGroups + mods) + .filter { (_, mods) -> mods.isNotEmpty() }) + ) + } + } + + @Serializable + class ModReference( + val urlContains: String, + val info: ModConfig? = null, + ) + + @Serializable + class ExtraPackInfo( + val groups: List = listOf(), + val modGroups: Map> = mapOf(), + ) +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Hashes.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Hashes.kt new file mode 100644 index 0000000..76cb813 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Hashes.kt @@ -0,0 +1,9 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import kotlinx.serialization.Serializable + +@Serializable +data class Hashes( + val sha1: String, + val sha512: String, +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/LaunchyPackFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/LaunchyPackFormat.kt new file mode 100644 index 0000000..40b5964 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/LaunchyPackFormat.kt @@ -0,0 +1,51 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.decodeFromStream +import com.mineinabyss.launchy.instance.data.InstanceModel +import com.mineinabyss.launchy.instance.data.ModGroup +import com.mineinabyss.launchy.instance.data.ModListModel +import com.mineinabyss.launchy.instance.data.ModLoaderModel +import com.mineinabyss.launchy.instance.data.storage.ModConfig +import com.mineinabyss.launchy.util.GroupName +import kotlinx.serialization.Serializable +import java.nio.file.Path +import kotlin.io.path.copyTo +import kotlin.io.path.div +import kotlin.io.path.inputStream + +class LaunchyPackFormat : ModpackFormat { + override suspend fun prepareSource(instance: InstanceModel, download: Path) { + download.copyTo(instance.modpackFilesDir / "pack.yml") + } + + override suspend fun loadPackFor(instance: InstanceModel): Result = runCatching { + val pack = Yaml.Companion.default.decodeFromStream( + (instance.modpackFilesDir / "pack.yml").inputStream() + ) + Modpack( + modLoader = ModLoaderModel(minecraft = pack.minecraftVersion, fabricLoader = pack.fabricVersion), + modList = ModListModel( + pack.modGroups + .mapKeys { (name, _) -> pack.groups.single { it.name == name } } + .mapValues { (_, mods) -> + mods.map { + Mod( + info = it, + modId = it.id ?: it.name, + desiredHashes = null, + ) + }.toSet() + }), + configSources = emptyList(), + ) + } + + @Serializable + data class SerializedLaunchyPack( + val fabricVersion: String? = null, + val minecraftVersion: String, + val groups: Set, + val modGroups: Map>, + ) +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Mod.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Mod.kt new file mode 100644 index 0000000..906a8a9 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Mod.kt @@ -0,0 +1,19 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import com.mineinabyss.launchy.instance.data.storage.ModConfig +import com.mineinabyss.launchy.util.ModID + +data class Mod( + val modId: ModID, + val info: ModConfig, + val desiredHashes: Hashes?, +) { + // val absoluteDownloadDest = +// if (info.downloadPath != null) downloadDir / info.downloadPath.validated +// else downloadDir / "mods" / "${info.id ?: info.name}.jar" +// +// val downloadUrl: Url = Url(info.url) +// + fun compatibleWith(other: Mod) = + other.info.name !in info.incompatibleWith && info.name !in other.info.incompatibleWith +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModDownloadPath.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModDownloadPath.kt similarity index 95% rename from src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModDownloadPath.kt rename to src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModDownloadPath.kt index 2b421fc..bcd6671 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModDownloadPath.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModDownloadPath.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.data.modpacks.formats +package com.mineinabyss.launchy.downloads.data.formats import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Modpack.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Modpack.kt new file mode 100644 index 0000000..cac5fe3 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/Modpack.kt @@ -0,0 +1,11 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import com.mineinabyss.launchy.instance.data.ModListModel +import com.mineinabyss.launchy.instance.data.ModLoaderModel +import java.nio.file.Path + +data class Modpack( + val modLoader: ModLoaderModel, + val modList: ModListModel, + val configSources: List = listOf(), +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModpackFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModpackFormat.kt new file mode 100644 index 0000000..3a1c64d --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModpackFormat.kt @@ -0,0 +1,9 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import com.mineinabyss.launchy.instance.data.InstanceModel +import java.nio.file.Path + +interface ModpackFormat { + suspend fun prepareSource(instance: InstanceModel, download: Path) + suspend fun loadPackFor(instance: InstanceModel): Result +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModrinthPackFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModrinthPackFormat.kt new file mode 100644 index 0000000..55d49b4 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/ModrinthPackFormat.kt @@ -0,0 +1,69 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import com.mineinabyss.launchy.instance.data.InstanceModel +import com.mineinabyss.launchy.instance.data.ModListModel +import com.mineinabyss.launchy.instance.data.ModLoaderModel +import com.mineinabyss.launchy.instance.data.storage.ModConfig +import com.mineinabyss.launchy.util.Formats +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.decodeFromStream +import org.rauschig.jarchivelib.ArchiverFactory +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively +import kotlin.io.path.div +import kotlin.io.path.inputStream + +class ModrinthPackFormat : ModpackFormat { + @OptIn(ExperimentalPathApi::class) + override suspend fun prepareSource(instance: InstanceModel, download: Path) { + val unzipDest = instance.modpackFilesDir + unzipDest.deleteRecursively() + ArchiverFactory.createArchiver("zip").extract(download.toFile(), unzipDest.toFile()) + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun loadPackFor(instance: InstanceModel): Result { + val unzipDest = instance.modpackFilesDir + val index = unzipDest / "modrinth.index.json" + val mrpack = Formats.json.decodeFromStream(index.inputStream()) + + return runCatching { + Modpack( + modLoader = mrpack.dependencies, + modList = ModListModel.withSingleGroup(mrpack.files.map { it.toMod() }), + configSources = listOf(unzipDest / "overrides"), + ) + } + } + + @Serializable + data class SerializedModrinthPack( + val dependencies: ModLoaderModel, + val files: List, + val formatVersion: Int, + val name: String, + val versionId: String, + ) { + @Serializable + data class PackFile( + val downloads: List, + val fileSize: Long, + val path: ModDownloadPath, + val hashes: Hashes, + ) { + fun toMod() = Mod( + modId = downloads.single().removePrefix("https://cdn.modrinth.com/data/").substringBefore("/versions"), + ModConfig( + name = path.validated.toString().removePrefix("mods/").removeSuffix(".jar"), + desc = "", + url = downloads.single(), + downloadPath = path, + ), + desiredHashes = hashes, + ) + } + } +} + diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/SerializedPackFormat.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/SerializedPackFormat.kt new file mode 100644 index 0000000..7c6cd3b --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/formats/SerializedPackFormat.kt @@ -0,0 +1,27 @@ +package com.mineinabyss.launchy.downloads.data.formats + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SerializedPackFormat { + fun getFormat(): ModpackFormat + + @Serializable + @SerialName("launchy") + data object Launchy : SerializedPackFormat { + override fun getFormat(): ModpackFormat = LaunchyPackFormat() + } + + @Serializable + @SerialName("modrinth") + data object Modrinth : SerializedPackFormat { + override fun getFormat(): ModpackFormat = ModrinthPackFormat() + } + + @Serializable + @SerialName("extras") + data class WithExtraInfo(val format: SerializedPackFormat) : SerializedPackFormat { + override fun getFormat(): ModpackFormat = ExtraInfoFormat(format.getFormat()) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/LocalModpackDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/LocalModpackDataSource.kt new file mode 100644 index 0000000..7f588f6 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/LocalModpackDataSource.kt @@ -0,0 +1,9 @@ +package com.mineinabyss.launchy.downloads.data.sources + +import com.mineinabyss.launchy.instance.data.InstanceModel + +class LocalModpackDataSource : ModpackDataSource { + override suspend fun skip(instance: InstanceModel) = true + + override suspend fun fetchLatestModsFor(instance: InstanceModel) = null +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/ModpackDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/ModpackDataSource.kt new file mode 100644 index 0000000..16a00c0 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/ModpackDataSource.kt @@ -0,0 +1,10 @@ +package com.mineinabyss.launchy.downloads.data.sources + +import com.mineinabyss.launchy.instance.data.InstanceModel +import java.nio.file.Path + +interface ModpackDataSource { + suspend fun skip(instance: InstanceModel): Boolean = false + + suspend fun fetchLatestModsFor(instance: InstanceModel): Path? +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/SerializedDownloadSource.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/SerializedDownloadSource.kt new file mode 100644 index 0000000..e1ebf90 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/SerializedDownloadSource.kt @@ -0,0 +1,22 @@ +package com.mineinabyss.launchy.downloads.data.sources + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koin.core.scope.Scope + +@Serializable +sealed interface SerializedDownloadSource { + fun getDataSource(scope: Scope): ModpackDataSource + + @Serializable + @SerialName("local") + class Local : SerializedDownloadSource { + override fun getDataSource(scope: Scope) = LocalModpackDataSource() + } + + @SerialName("downloadFromURL") + @Serializable + class DownloadFromURL(val url: String) : SerializedDownloadSource { + override fun getDataSource(scope: Scope) = URLModpackDataSource(scope.get(), url) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/URLModpackDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/URLModpackDataSource.kt new file mode 100644 index 0000000..c0e3351 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/downloads/data/sources/URLModpackDataSource.kt @@ -0,0 +1,19 @@ +package com.mineinabyss.launchy.downloads.data.sources + +import com.mineinabyss.launchy.downloads.data.Downloader +import com.mineinabyss.launchy.instance.data.InstanceModel +import java.nio.file.Path + +class URLModpackDataSource( + val downloader: Downloader, + val resourceURL: String, +) : ModpackDataSource { + override suspend fun fetchLatestModsFor(instance: InstanceModel): Path? { + downloader.download( + resourceURL, + instance.packDownloadFile, + options = Downloader.Options(saveModifyHeadersFor = instance) + ) + return instance.packDownloadFile + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/DownloadInfo.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/DownloadInfo.kt new file mode 100644 index 0000000..32b4bcc --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/DownloadInfo.kt @@ -0,0 +1,34 @@ +package com.mineinabyss.launchy.instance.data + +import com.mineinabyss.launchy.downloads.data.ModDownloader +import com.mineinabyss.launchy.util.hashing.Hashing.checksum +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.nio.file.Path +import java.security.MessageDigest +import kotlin.io.path.Path +import kotlin.io.path.div +import kotlin.io.path.isRegularFile + +@Serializable +data class DownloadInfo( + val url: String, + val path: String, + val desiredHash: String?, + val hashCheck: HashCheck, + val result: ModDownloader.DownloadResult, +) { + @Transient + val systemPath = Path(path) + + fun failed(): Boolean { + return result == ModDownloader.DownloadResult.Failed + || systemPath.isRegularFile() + || (desiredHash != null && hashCheck == HashCheck.FAILED) + } + + fun calculateSha1Hash(minecraftDir: Path): String { + val md = MessageDigest.getInstance("SHA-1") + return (minecraftDir / systemPath).checksum(md) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/GameInstanceDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/GameInstanceDataSource.kt new file mode 100644 index 0000000..bd95d3b --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/GameInstanceDataSource.kt @@ -0,0 +1,77 @@ +package com.mineinabyss.launchy.instance.data + +//@OptIn(ExperimentalCoroutinesApi::class) +//class GameInstanceDataSource( +// val configDir: Path, +// val config: InstanceConfig, +//) { +// suspend fun createModpackState(state: LaunchyUiState, awaitUpdatesCheck: Boolean = false): GameInstanceState? { +// val userConfig = InstanceUserConfig.load(userConfigFile).getOrNull() ?: InstanceUserConfig() +// +// val modpack = state.runTask("loadingModpack ${config.name}", InProgressTask("Loading modpack ${config.name}")) { +// config.source.loadInstance(this) +// .showDialogOnError("Failed to read instance") +// .getOrElse { +// it.printStackTrace() +// return null +// } +// } +// val cloudUrl = config.cloudInstanceURL +// if (cloudUrl != null) { +// AppDispatchers.IO.launch { +// val result = Downloader.checkUpdates(this@GameInstanceDataSource, cloudUrl) +// if (result !is UpdateResult.UpToDate) updatesAvailable = true +// }.also { if (awaitUpdatesCheck) it.join() } +// } +// return GameInstanceState(this, modpack, userConfig) +// } +// +// init { +// require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" } +// userMods +// } +// +// companion object { +// fun createCloudInstance(state: LaunchyUiState, cloud: CloudInstanceWithHeaders) { +// val instanceDir = Dirs.modpackConfigDir(cloud.config.name) +// instanceDir.createDirectories() +// +// Formats.yaml.encodeToStream( +// cloud.config.copy(cloudInstanceURL = cloud.url), +// (instanceDir / "instance.yml").outputStream() +// ) +// val instance = GameInstanceDataSource(instanceDir) +// Downloader.saveHeaders(instance, cloud.url, cloud.headers) +// state.gameInstances += instance +// } +// } +// +// private suspend fun loadBackground() { +// runCatching { +// Downloader.download(config.backgroundURL, config.backgroundPath, Downloader.Options(overwrite = false)) +// val painter = BitmapPainter(loadImageBitmap(config.backgroundPath.inputStream())) +// cachedBackground = painter +// }.onFailure { it.printStackTrace() } +// } +// +// private suspend fun loadLogo() { +// runCatching { +// Downloader.download(config.logoURL, config.logoPath, Downloader.Options(overwrite = false)) +// val painter = BitmapPainter(loadImageBitmap(config.logoPath.inputStream())) +// cachedLogo = painter +// }.onFailure { it.printStackTrace() } +// } +// +// private var cachedBackground: BitmapPainter? = null +// private var cachedLogo: BitmapPainter? = null +// +// suspend fun getBackground() = withContext(imageLoaderDispatcher) { +// if (cachedBackground == null) loadLogo() +// cachedBackground +// } +// +// suspend fun getLogo(): BitmapPainter? = withContext(imageLoaderDispatcher) { +// if (cachedLogo == null) loadLogo() +// cachedLogo +// } +//} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/HashCheck.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/HashCheck.kt new file mode 100644 index 0000000..e5d4174 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/HashCheck.kt @@ -0,0 +1,5 @@ +package com.mineinabyss.launchy.instance.data + +enum class HashCheck { + UNKNOWN, VERIFIED, FAILED +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/InstanceModel.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/InstanceModel.kt new file mode 100644 index 0000000..773aaf9 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/InstanceModel.kt @@ -0,0 +1,41 @@ +package com.mineinabyss.launchy.instance.data + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.mineinabyss.launchy.instance.data.storage.InstanceConfig +import com.mineinabyss.launchy.instance.data.storage.InstanceUserConfig +import com.mineinabyss.launchy.util.Dirs +import com.mineinabyss.launchy.util.InstanceKey +import kotlinx.coroutines.Dispatchers +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.div +import kotlin.io.path.name + +data class InstanceModel( + val config: InstanceConfig, + val userConfig: InstanceUserConfig, + val directory: Path, + val key: InstanceKey = InstanceKey(directory.name), +) { + val instanceFile = directory / "instance.yml" + val backupInstanceFile = directory / "instance-backup.yml" + val overridesDir = directory / "overrides" + val imageLoaderDispatcher = Dispatchers.IO.limitedParallelism(1) + val modpackFilesDir = directory / "modpack" + + val minecraftDir = config.overrideMinecraftDir?.let { Path(it) } ?: Dirs.modpackDir(directory.name) + + val modsDir = (minecraftDir / "mods").createDirectories() + val userMods = (minecraftDir / "modsFromUser").createDirectories() + + val downloadsDir: Path = minecraftDir / "launchyDownloads" + val userConfigFile = (directory / "config.yml") + val packDownloadFile = (downloadsDir / "pack") + + var updatesAvailable by mutableStateOf(false) + var enabled: Boolean by mutableStateOf(true) + +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Group.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/ModGroup.kt similarity index 75% rename from src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Group.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/data/ModGroup.kt index f307895..e5aa556 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Group.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/ModGroup.kt @@ -1,9 +1,9 @@ -package com.mineinabyss.launchy.data.modpacks +package com.mineinabyss.launchy.instance.data import kotlinx.serialization.Serializable @Serializable -data class Group( +data class ModGroup( val name: String, val enabledByDefault: Boolean = false, val forceEnabled: Boolean = false, diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/ModListModel.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/ModListModel.kt new file mode 100644 index 0000000..d126f31 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/ModListModel.kt @@ -0,0 +1,33 @@ +package com.mineinabyss.launchy.instance.data + +import androidx.compose.runtime.Immutable +import com.mineinabyss.launchy.downloads.data.formats.Mod +import com.mineinabyss.launchy.util.GroupName +import com.mineinabyss.launchy.util.ModID + +@Immutable +data class ModListModel( + val modGroups: Map>, +) { + val groups = modGroups.keys + val mods = modGroups.values.flatten().toSet() + + private val nameToGroup: Map = groups.associateBy { it.name } + private val idToMod: Map = modGroups.values + .flatten() + .associateBy { it.modId } + + // + fun getModById(id: ModID): Mod? = idToMod[id] + fun getGroup(name: GroupName): ModGroup? = nameToGroup[name] + + companion object { + const val VERSIONS_URL = "https://raw.githubusercontent.com/MineInAbyss/launchy/master/versions.yml" + + fun withSingleGroup(mods: Collection) = ModListModel( + modGroups = mapOf( + ModGroup("Default", forceEnabled = true) to mods.toSet() + ) + ) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/InstanceModLoaders.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/ModLoaderModel.kt similarity index 90% rename from src/main/kotlin/com/mineinabyss/launchy/data/modpacks/InstanceModLoaders.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/data/ModLoaderModel.kt index 84e50b8..a4a672d 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/InstanceModLoaders.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/ModLoaderModel.kt @@ -1,11 +1,11 @@ -package com.mineinabyss.launchy.data.modpacks +package com.mineinabyss.launchy.instance.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable -data class InstanceModLoaders( +data class ModLoaderModel( val minecraft: String, @SerialName("fabric-loader") val fabricLoader: String? = null, diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/InstanceConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/InstanceConfig.kt new file mode 100644 index 0000000..e3ee093 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/InstanceConfig.kt @@ -0,0 +1,31 @@ +package com.mineinabyss.launchy.instance.data.storage + +import androidx.compose.runtime.Immutable +import com.mineinabyss.launchy.downloads.data.formats.SerializedPackFormat +import com.mineinabyss.launchy.downloads.data.sources.SerializedDownloadSource +import com.mineinabyss.launchy.util.Dirs +import com.mineinabyss.launchy.util.urlToFileName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlin.io.path.div + +@Immutable +@Serializable +data class InstanceConfig( + val name: String, + val description: String, + val backgroundURL: String, + val logoURL: String, + val source: SerializedDownloadSource, + val pack: SerializedPackFormat, + val hue: Float = 0f, + val cloudInstanceURL: String? = null, + val overrideMinecraftDir: String? = null, +) { + + @Transient + val backgroundPath = Dirs.imageCache / "background-${urlToFileName(backgroundURL)}" + + @Transient + val logoPath = Dirs.imageCache / "icon-${urlToFileName(logoURL)}" +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/InstanceUserConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/InstanceUserConfig.kt new file mode 100644 index 0000000..ae65a63 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/InstanceUserConfig.kt @@ -0,0 +1,37 @@ +package com.mineinabyss.launchy.instance.data.storage + +import com.charleskorn.kaml.decodeFromStream +import com.mineinabyss.launchy.instance.data.DownloadInfo +import com.mineinabyss.launchy.instance.data.ModLoaderModel +import com.mineinabyss.launchy.util.Formats +import com.mineinabyss.launchy.util.GroupName +import com.mineinabyss.launchy.util.ModID +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import java.nio.file.Path +import kotlin.io.path.* + +@Serializable +data class InstanceUserConfig( + val userAgreedDeps: ModLoaderModel? = null, + val fullEnabledGroups: Set = setOf(), + val fullDisabledGroups: Set = setOf(), + val toggledMods: Set = setOf(), + val toggledConfigs: Set = setOf(), + val seenGroups: Set = setOf(), + val modDownloadInfo: Map = mapOf(), +// val configDownloadInfo: Map = mapOf(), + val downloadUpdates: Boolean = true, +) { + fun save(file: Path) { + file.createParentDirectories().deleteIfExists() + file.writeText(Formats.yaml.encodeToString(this)) + } + + companion object { + fun load(file: Path): Result = runCatching { + return@runCatching if (file.exists()) Formats.yaml.decodeFromStream(file.inputStream()) + else InstanceUserConfig() + }.onFailure { it.printStackTrace() } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/ModConfig.kt similarity index 72% rename from src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModConfig.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/ModConfig.kt index 9057ae2..a79e5cc 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModConfig.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/storage/ModConfig.kt @@ -1,11 +1,12 @@ -package com.mineinabyss.launchy.data.modpacks +package com.mineinabyss.launchy.instance.data.storage -import com.mineinabyss.launchy.data.modpacks.formats.ModDownloadPath +import com.mineinabyss.launchy.downloads.data.formats.ModDownloadPath +import com.mineinabyss.launchy.util.ModID import kotlinx.serialization.Serializable @Serializable data class ModConfig( - val id: String? = null, + val id: ModID? = null, val name: String, val license: String = "", val homepage: String = "", diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/data/usermods/UserModsDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/data/usermods/UserModsDataSource.kt new file mode 100644 index 0000000..16fd273 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/data/usermods/UserModsDataSource.kt @@ -0,0 +1,8 @@ +package com.mineinabyss.launchy.instance.data.usermods + +import java.nio.file.Path + +class UserModsDataSource( + val modsFolder: Path +) { +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ButtonInteractions.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ButtonInteractions.kt new file mode 100644 index 0000000..6729d61 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ButtonInteractions.kt @@ -0,0 +1,5 @@ +package com.mineinabyss.launchy.instance.ui + +data class ButtonInteractions( + val onUpdate: () -> Unit, +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstallState.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstallState.kt new file mode 100644 index 0000000..ad5fef2 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstallState.kt @@ -0,0 +1,17 @@ +package com.mineinabyss.launchy.instance.ui + +import com.mineinabyss.launchy.util.ModID + +sealed interface InstallState { + data object InProgress : InstallState + data class Queued( + val modLoaderChange: String?, + val install: List, + val update: List, + val remove: List, + val failures: List, + ) : InstallState + + data object AllInstalled : InstallState + data object Error : InstallState +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstanceUiState.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstanceUiState.kt new file mode 100644 index 0000000..b2fc28c --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstanceUiState.kt @@ -0,0 +1,18 @@ +package com.mineinabyss.launchy.instance.ui + +import androidx.compose.ui.graphics.painter.BitmapPainter +import com.mineinabyss.launchy.util.InstanceKey + +data class InstanceUiState( + val title: String, + val description: String, + val isCloudInstance: Boolean, + val logo: BitmapPainter?, + val background: BitmapPainter?, + val runningProcess: Process?, + val hue: Float, + val enabled: Boolean, + val updatesAvailable: Boolean, + val key: InstanceKey, + val installedModLoader: String? +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstanceViewModel.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstanceViewModel.kt new file mode 100644 index 0000000..ca49b86 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/InstanceViewModel.kt @@ -0,0 +1,147 @@ +package com.mineinabyss.launchy.instance.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mineinabyss.launchy.instance.data.InstanceModel +import com.mineinabyss.launchy.instance_list.data.InstanceRepository +import com.mineinabyss.launchy.util.ModID +import kotlinx.coroutines.flow.* +import org.to2mbn.jmccc.mcdownloader.download.Downloader + +class InstanceViewModel( + val downloader: Downloader, + val instanceRepo: InstanceRepository, +) : ViewModel() { + private val currentInstance = MutableStateFlow(null) + + private val _installState = MutableStateFlow(InstallState.InProgress) + private val _instanceUiState = MutableStateFlow(null) + + val modsState = currentInstance.mapLatest { instance -> + if (instance == null) return@mapLatest ModListUiState.Error("No instance selected") + val pack = instanceRepo + .loadPack(instance.key) + .getOrElse { return@mapLatest ModListUiState.Error(it.message ?: "Unknown error") } + ModListUiState.Loaded(pack) + }.stateIn(viewModelScope, SharingStarted.Eagerly, ModListUiState.Loading) + + val instanceUiState = _instanceUiState.asStateFlow() + val installState = _installState.asStateFlow() + + //TODO read + val userInstalledMods = MutableStateFlow(null) +// val userMods by remember { +// mutableStateOf( +// state.instance.userMods.listDirectoryEntries("*.jar").map { +// Mod( +// downloadDir = it, +// modId = it.fileName.toString(), +// info = ModConfig(name = it.fileName.toString()), +// desiredHashes = null +// ) +// } +// ) +// } + + val enabledMods = MutableStateFlow(listOf()) +// val instanceUIState = +// val modpack: Modpack +// val modpackConfig: InstanceUserConfig + + // trigger update incase we have dependencies +// enabledMods.forEach { setModEnabled(it, true) } + + +// val availableMods = mutableStateSetOf().apply { +// addAll(modpack.mods.mods) +// } +// val enabledMods = mutableStateSetOf().apply { +// addAll(modpackConfig.toggledMods.mapNotNull { modpack.mods.getModById(it) }) +// val defaultEnabled = modpack.mods.groups +// .filter { it.enabledByDefault } +// .map { it.name } - modpackConfig.seenGroups +// val fullEnabled = modpackConfig.fullEnabledGroups +// val forceEnabled = modpack.mods.groups.filter { it.forceEnabled }.map { it.name } +// val forceDisabled = modpack.mods.groups.filter { it.forceDisabled } +// val fullDisabled = modpackConfig.fullDisabledGroups +// addAll(((fullEnabled + defaultEnabled + forceEnabled).toSet()) +// .mapNotNull { modpack.mods.getGroup(it) } +// .mapNotNull { modpack.mods.modGroups[it] }.flatten() +// ) +// removeAll((forceDisabled + fullDisabled).toSet().mapNotNull { modpack.mods.modGroups[it] }.flatten().toSet()) +// } +// +// val disabledMods: Set by derivedStateOf { modpack.mods.mods - enabledMods } + +// val enabledModsWithConfig by derivedStateOf { +// enabledMods.filter { it.info.configUrl != "" } +// } +// +// val enabledConfigs: MutableSet = mutableStateSetOf().apply { +// addAll(modpackConfig.toggledConfigs.mapNotNull { modpack.mods.getModById(it) }) +// } + +// val queued = DownloadQueueState(userConfig, modpack, toggles) +// val downloads = DownloadState() + + fun setModState(mod: ModID, enabled: Boolean) { + if (enabled) enabledMods.value += mod + else enabledMods.value -= mod + } + + fun installMods( + ignoreCachedCheck: Boolean = false, + ) { + TODO() + } + + fun launch() { + TODO() + } +// fun saveToConfig() { +// userConfig.copy( +// fullEnabledGroups = modpack.mods.modGroups +// .filter { toggles.enabledMods.containsAll(it.value) }.keys +// .map { it.name }.toSet(), +// userAgreedDeps = queued.userAgreedModLoaders, +// toggledMods = toggles.enabledMods.mapTo(mutableSetOf()) { it.modId }, +// toggledConfigs = toggles.enabledConfigs.mapTo(mutableSetOf()) { it.modId } + toggles.enabledMods.filter { it.info.forceConfigDownload } +// .mapTo(mutableSetOf()) { it.info.name }, +// seenGroups = modpack.mods.groups.map { it.name }.toSet(), +// modDownloadInfo = queued.modDownloadInfo, +//// configDownloadInfo = toggles.downloadConfigURLs.mapKeys { it.key.info.name }, +// ).save(instance.userConfigFile) +// } + +// fun setModEnabled(mod: Mod, enabled: Boolean) { +// if (enabled) { +// enabledMods += mod +// enabledMods.filter { !mod.compatibleWith(it) } +// .forEach { setModEnabled(it, false) } +// disabledMods.filter { it.info.name in mod.info.requires }.forEach { setModEnabled(it, true) } +// } else { +// enabledMods -= mod +// // if a mod is disabled, disable all mods that depend on it +// enabledMods.filter { it.info.requires.contains(mod.info.name) }.forEach { setModEnabled(it, false) } +// // if a mod is disabled, and the dependency is only used by this mod, disable the dependency too, unless it's not marked as a dependency +// enabledMods.filter { dep -> +// mod.info.requires.contains(dep.info.name) // if the mod depends on this dependency +// && dep.info.dependency // if the dependency is marked as a dependency +// && enabledMods.none { it.info.requires.contains(dep.info.name) } // and no other mod depends on this dependency +//// && !versions.modGroups.filterValues { it.contains(dep) }.keys.any { it.forceEnabled } // and the group the dependency is in is not force enabled +// }.forEach { setModEnabled(it, false) } +// } +// setModConfigEnabled(mod, enabled) +// } +// +// fun setModConfigEnabled(mod: Mod, enabled: Boolean) { +// if (mod.info.configUrl.isNotBlank() && enabled) enabledConfigs.add(mod) +// else enabledConfigs.remove(mod) +// } + + fun groupInteractionsFor(id: String): ModGroupInteractions = TODO() + + fun modInteractionsFor(id: ModID): ModInteractions = TODO() + + fun buttonInteractions(): ButtonInteractions = TODO() +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModGroupUiState.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModGroupUiState.kt new file mode 100644 index 0000000..b8e00f3 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModGroupUiState.kt @@ -0,0 +1,11 @@ +package com.mineinabyss.launchy.instance.ui + +import com.mineinabyss.launchy.util.Option + +data class ModGroupUiState( + val id: String, + val title: String, + val enabled: Boolean, + val force: Option, + val mods: List +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModInteractions.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModInteractions.kt new file mode 100644 index 0000000..3c82301 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModInteractions.kt @@ -0,0 +1,11 @@ +package com.mineinabyss.launchy.instance.ui + +import com.mineinabyss.launchy.util.Option + +data class ModGroupInteractions( + val onToggleGroup: (Option) -> Unit, +) + +data class ModInteractions( + val onToggleMod: (Boolean) -> Unit, +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModListUiState.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModListUiState.kt new file mode 100644 index 0000000..88c1bf8 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModListUiState.kt @@ -0,0 +1,11 @@ +package com.mineinabyss.launchy.instance.ui + +import com.mineinabyss.launchy.downloads.data.formats.Modpack + +sealed interface ModListUiState { + object Loading : ModListUiState + data class Error(val message: String) : ModListUiState + class Loaded( + val groups: Modpack, + ) : ModListUiState +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModUiState.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModUiState.kt new file mode 100644 index 0000000..966b47a --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/ModUiState.kt @@ -0,0 +1,59 @@ +package com.mineinabyss.launchy.instance.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material.icons.rounded.Update +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.vector.ImageVector +import com.mineinabyss.launchy.instance.data.storage.ModConfig +import com.mineinabyss.launchy.util.ModID +import com.mineinabyss.launchy.util.ModName +import com.mineinabyss.launchy.util.Progress + +data class ModUiState( + val id: ModID, + val enabled: Boolean, + val configEnabled: Boolean, + val queueState: ModQueueState, + val info: ModConfig, + val incompatibleWith: List, + val dependsOn: List, + val installProgress: Progress?, +) + + +enum class ModQueueState { + RETRY_DOWNLOAD, + DELETE, + INSTALL, + UPDATE, + NONE; + + companion object { + @Composable + fun surfaceColor(state: ModQueueState) = remember(state) { + when (state) { + RETRY_DOWNLOAD -> MaterialTheme.colorScheme.error + DELETE -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f) + INSTALL -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.25f) + UPDATE -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.1f) + NONE -> MaterialTheme.colorScheme.surface + } + } + + @Composable + fun infoIcon(state: ModQueueState): ImageVector? = remember(state) { + when (state) { + RETRY_DOWNLOAD -> Icons.Rounded.Error + DELETE -> Icons.Rounded.Delete + INSTALL -> Icons.Rounded.Download + UPDATE -> Icons.Rounded.Update + NONE -> null + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/BackgroundImage.kt similarity index 59% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/BackgroundImage.kt index d674420..ed38f52 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/BackgroundImage.kt @@ -1,7 +1,6 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main +package com.mineinabyss.launchy.instance.ui.components import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image @@ -10,27 +9,21 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowScope -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState @Composable -fun BoxScope.BackgroundImage(windowScope: WindowScope) { - val pack = LocalGameInstanceState - val background by remember { pack.instance.config.getBackgroundAsState() } - AnimatedVisibility(background != null, enter = fadeIn(), exit = fadeOut()) { - if (background == null) return@AnimatedVisibility +fun BoxScope.BackgroundImage(painter: BitmapPainter?, windowScope: WindowScope) { + AnimatedVisibility(painter != null, enter = fadeIn(), exit = fadeOut()) { + if (painter == null) return@AnimatedVisibility windowScope.WindowDraggableArea { Image( - painter = background!!, + painter = painter!!, contentDescription = "Modpack background", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() @@ -74,22 +67,3 @@ fun BoxScope.SlightBackgroundTint(modifier: Modifier = Modifier) { } } -@Composable -fun LogoLarge(modifier: Modifier) { - LocalLaunchyState - val pack = LocalGameInstanceState - val painter by remember { pack.instance.config.getLogoAsState() } - AnimatedVisibility( - painter != null, - enter = fadeIn() + expandVertically(clip = false) + fadeIn(), - modifier = Modifier.widthIn(0.dp, 500.dp).then(modifier) - ) { - if (painter == null) return@AnimatedVisibility - Image( - painter = painter!!, - contentDescription = "Modpack logo", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.FillWidth - ) - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/LogoLarge.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/LogoLarge.kt new file mode 100644 index 0000000..663c101 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/LogoLarge.kt @@ -0,0 +1,30 @@ +package com.mineinabyss.launchy.instance.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp + +@Composable +fun LogoLarge(painter: BitmapPainter?, modifier: Modifier) { + AnimatedVisibility( + painter != null, + enter = fadeIn() + expandVertically(clip = false) + fadeIn(), + modifier = Modifier.widthIn(0.dp, 500.dp).then(modifier) + ) { + if (painter == null) return@AnimatedVisibility + Image( + painter = painter, + contentDescription = "Modpack logo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillWidth + ) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/InstallButton.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/InstallButton.kt new file mode 100644 index 0000000..1ef56e7 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/InstallButton.kt @@ -0,0 +1,53 @@ +package com.mineinabyss.launchy.instance.ui.components.buttons + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.ui.components.PrimaryButton +import com.mineinabyss.launchy.instance.ui.InstallState + +@Composable +fun InstallButton( + state: InstallState, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + PrimaryButton( + enabled = state is InstallState.Queued, + onClick = onClick, + modifier = modifier.width(150.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Download, "Download") + AnimatedVisibility(true, Modifier.animateContentSize()) { + val text = when (state) { + is InstallState.Queued, InstallState.Error -> "Install" + InstallState.AllInstalled -> "Installed" + InstallState.InProgress -> "Installing" + } + AnimatedContent(text) { + Text(it) + } + } + } + } +} + +@Composable +fun InstallTextAnimatedVisibility(visible: Boolean, content: @Composable () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + ) { + content() + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/NewsButton.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/NewsButton.kt similarity index 89% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/NewsButton.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/NewsButton.kt index 439d6ef..8edd303 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/NewsButton.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/NewsButton.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main.buttons +package com.mineinabyss.launchy.instance.ui.components.buttons import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -15,7 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.ui.elements.SecondaryButton +import com.mineinabyss.launchy.core.ui.components.SecondaryButton @Composable fun NewsButton(hasUpdates: Boolean) { diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/PlayButton.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/PlayButton.kt new file mode 100644 index 0000000..fdb5ae2 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/PlayButton.kt @@ -0,0 +1,71 @@ +package com.mineinabyss.launchy.instance.ui.components.buttons + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.ui.components.PrimaryButtonColors +import com.mineinabyss.launchy.core.ui.components.SecondaryButtonColors +import com.mineinabyss.launchy.instance.ui.InstanceUiState + +@Composable +fun PlayButton( + hideText: Boolean = false, + instance: InstanceUiState, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val buttonIcon by remember(instance, instance.runningProcess) { + mutableStateOf( + when { +// state.profile.currentProfile == null -> Icons.Rounded.PlayDisabled + instance.runningProcess == null -> Icons.Rounded.PlayArrow + else -> Icons.Rounded.Stop + } + ) + } + val buttonText by remember(instance.runningProcess) { + mutableStateOf(if (instance.runningProcess == null) "Play" else "Stop") + } + val buttonColors by mutableStateOf( + if (instance.runningProcess == null) PrimaryButtonColors + else SecondaryButtonColors + ) + + val enabled = instance.enabled + + Box { + if (hideText) Button( + enabled = enabled, + onClick = onClick, + modifier = Modifier.size(52.dp).then(modifier), + contentPadding = PaddingValues(0.dp), + colors = buttonColors, + shape = MaterialTheme.shapes.medium + ) { + Icon(buttonIcon, buttonText) + } + else Button( + enabled = enabled, + onClick = onClick, + shape = RoundedCornerShape(20.dp), + colors = buttonColors + ) { + Icon(buttonIcon, buttonText) + Text(buttonText) + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/RetryFailedButton.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/RetryFailedButton.kt new file mode 100644 index 0000000..8eb12a6 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/RetryFailedButton.kt @@ -0,0 +1,17 @@ +package com.mineinabyss.launchy.instance.ui.components.buttons + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.mineinabyss.launchy.core.ui.components.OutlinedRedButton + +@Composable +fun RetryFailedButton( + failureCount: Int, + onClick: () -> Unit, +) { + OutlinedRedButton( + onClick = onClick, + ) { + Text("Retry $failureCount failed downloads") + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/SettingsButton.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/SettingsButton.kt similarity index 73% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/SettingsButton.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/SettingsButton.kt index 9a527d0..e6040d8 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/SettingsButton.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/SettingsButton.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main.buttons +package com.mineinabyss.launchy.instance.ui.components.buttons import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Settings @@ -6,8 +6,8 @@ import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.screen +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.screen @Composable fun SettingsButton() { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/UpdateButton.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/UpdateButton.kt similarity index 51% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/UpdateButton.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/UpdateButton.kt index 58b44fe..6c0d80c 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/UpdateButton.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/buttons/UpdateButton.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main.buttons +package com.mineinabyss.launchy.instance.ui.components.buttons import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.Icons @@ -7,18 +7,11 @@ import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.logic.Instances.updateInstance -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState @Composable -fun UpdateButton() { - val state = LocalLaunchyState - val pack = LocalGameInstanceState +fun UpdateButton(onClick: () -> Unit = {}) { Box { - Button(onClick = { - pack.instance.updateInstance(state) - }) { + Button(onClick) { Icon(Icons.Rounded.Update, contentDescription = "Update") Text("Update Available") } diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModConfigOptions.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModConfigOptions.kt new file mode 100644 index 0000000..c6b5248 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModConfigOptions.kt @@ -0,0 +1,41 @@ +package com.mineinabyss.launchy.instance.ui.components.settings + +import androidx.compose.runtime.Composable + +@Composable +fun ModConfigOptions() { +// Row( +// verticalAlignment = Alignment.CenterVertically, +// modifier = Modifier +// .clickable { +// if (!mod.info.forceConfigDownload) state.toggles.setModConfigEnabled( +// mod, +// !configEnabled +// ) +// } +// .fillMaxWidth() +// ) { +// Spacer(Modifier.width(20.dp)) +// Checkbox( +// checked = configEnabled || mod.info.forceConfigDownload, +// onCheckedChange = { +// if (!mod.info.forceConfigDownload) state.toggles.setModConfigEnabled(mod, !configEnabled) +// }, +// enabled = !mod.info.forceConfigDownload, +// ) +// Column { +// Text( +// "Download our recommended configuration", +// style = MaterialTheme.typography.bodyMedium +// ) +// if (mod.info.configDesc.isNotEmpty()) { +// Spacer(Modifier.width(4.dp)) +// Text( +// mod.info.configDesc, +// style = MaterialTheme.typography.bodySmall, +// modifier = Modifier.alpha(0.5f) +// ) +// } +// } +// } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModGroup.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModGroup.kt similarity index 67% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModGroup.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModGroup.kt index 62c8e2d..8cf4871 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModGroup.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModGroup.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.modpack.settings +package com.mineinabyss.launchy.instance.ui.components.settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState @@ -20,24 +20,25 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.data.modpacks.Group -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.logic.ToggleMods.setModEnabled -import com.mineinabyss.launchy.ui.elements.Tooltip -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState -import com.mineinabyss.launchy.util.Option +import androidx.compose.ui.util.fastAny +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mineinabyss.launchy.core.ui.components.Tooltip +import com.mineinabyss.launchy.instance.ui.InstanceViewModel +import com.mineinabyss.launchy.instance.ui.ModGroupInteractions +import com.mineinabyss.launchy.instance.ui.ModGroupUiState +import com.mineinabyss.launchy.instance.ui.ModQueueState @Composable -fun ModGroup(group: Group, mods: Collection) { +fun ModGroup( + group: ModGroupUiState, + interactions: ModGroupInteractions, + viewModel: InstanceViewModel = viewModel() +) { var expanded by remember { mutableStateOf(false) } val arrowRotationState by animateFloatAsState(targetValue = if (expanded) 180f else 0f) - val state = LocalGameInstanceState - - val modsChanged = mods.any { - it in state.queued.deletions || it in state.queued.newDownloads || it in state.queued.failures - } - + val changesQueued = group.mods.fastAny { it.queueState != ModQueueState.NONE } val tonalElevation by animateDpAsState(if (expanded) 1.6.dp else 1.dp) + Column { Surface( tonalElevation = tonalElevation, @@ -52,23 +53,17 @@ fun ModGroup(group: Group, mods: Collection) { ) { ToggleButtons( - onSwitch = { option -> - val mods = state.modpack.mods.modGroups[group] - if (option == Option.ENABLED) - mods?.forEach { state.toggles.setModEnabled(it, true) } - else if (option == Option.DISABLED) - mods?.forEach { state.toggles.setModEnabled(it, false) } - }, + onSwitch = { option -> interactions.onToggleGroup(option) }, group = group, - mods = mods + mods = group.mods ) Spacer(Modifier.width(10.dp)) Text( - group.name, Modifier.weight(1f), + group.title, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, ) - AnimatedVisibility(modsChanged, enter = fadeIn(), exit = fadeOut()) { + AnimatedVisibility(changesQueued, enter = fadeIn(), exit = fadeOut()) { TooltipArea( tooltip = { Tooltip("Some mods in this group have pending changes") } ) { @@ -87,7 +82,10 @@ fun ModGroup(group: Group, mods: Collection) { modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp, start = 10.dp) ) { Column { - for (mod in mods) ModInfoDisplay(group, mod) + for (mod in group.mods) key(mod.id) { + val modInteractions = remember(mod.id) { viewModel.modInteractionsFor(mod.id) } + ModInfoDisplay(group, mod, modInteractions) + } } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModInfoDisplay.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModInfoDisplay.kt similarity index 50% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModInfoDisplay.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModInfoDisplay.kt index ac40edc..bbde957 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModInfoDisplay.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/ModInfoDisplay.kt @@ -1,15 +1,15 @@ -package com.mineinabyss.launchy.ui.screens.modpack.settings +package com.mineinabyss.launchy.instance.ui.components.settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.OpenInNew -import androidx.compose.material.icons.rounded.* +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -17,65 +17,51 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.data.modpacks.Group -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.logic.DesktopHelpers -import com.mineinabyss.launchy.logic.ToggleMods.setModConfigEnabled -import com.mineinabyss.launchy.logic.ToggleMods.setModEnabled -import com.mineinabyss.launchy.ui.elements.Tooltip -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState +import com.mineinabyss.launchy.core.ui.components.Tooltip +import com.mineinabyss.launchy.instance.ui.ModGroupUiState +import com.mineinabyss.launchy.instance.ui.ModInteractions +import com.mineinabyss.launchy.instance.ui.ModQueueState +import com.mineinabyss.launchy.instance.ui.ModUiState +import com.mineinabyss.launchy.util.DesktopHelpers +import com.mineinabyss.launchy.util.Option @OptIn(ExperimentalFoundationApi::class) @Composable -fun ModInfoDisplay(group: Group, mod: Mod) { - val state = LocalGameInstanceState - val modEnabled by derivedStateOf { mod in state.toggles.enabledMods } - val configEnabled by derivedStateOf { mod in state.toggles.enabledConfigs } - var configExpanded by remember { mutableStateOf(false) } - val configTabState by animateFloatAsState(targetValue = if (configExpanded) 180f else 0f) +fun ModInfoDisplay( + group: ModGroupUiState, + mod: ModUiState, + interactions: ModInteractions, +) { + val surfaceColor = ModQueueState.surfaceColor(mod.queueState) + val infoIcon = ModQueueState.infoIcon(mod.queueState) - val surfaceColor = when (mod) { - in state.queued.failures -> MaterialTheme.colorScheme.error - in state.queued.deletions -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f) - in state.queued.newDownloads -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.25f) - in state.queued.updates -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.1f) - else -> MaterialTheme.colorScheme.surface - } - - val infoIcon: ImageVector? = when (mod) { - in state.queued.failures -> Icons.Rounded.Error - in state.queued.deletions -> Icons.Rounded.Delete - in state.queued.newDownloads -> Icons.Rounded.Download - in state.queued.updates -> Icons.Rounded.Update - else -> null - } Surface( modifier = Modifier.fillMaxWidth(), color = surfaceColor, - onClick = { if (!group.forceEnabled && !group.forceDisabled) state.toggles.setModEnabled(mod, !modEnabled) } + onClick = { if (group.force == Option.DEFAULT) interactions.onToggleMod(!mod.enabled) } ) { - if (state.downloads.inProgressMods.containsKey(mod) || state.downloads.inProgressConfigs.containsKey(mod)) { - val modProgress = state.downloads.inProgressMods[mod] - val configProgress = state.downloads.inProgressConfigs[mod] - val downloaded = (modProgress?.bytesDownloaded ?: 0L) + (configProgress?.bytesDownloaded ?: 0L) - val total = (modProgress?.totalBytes ?: 0L) + (configProgress?.totalBytes ?: 0L) + if (mod.installProgress != null) { + val downloaded = mod.installProgress.bytesDownloaded + val total = mod.installProgress.totalBytes LinearProgressIndicator( progress = if (total == 0L) 0f else downloaded.toFloat() / total, color = MaterialTheme.colorScheme.primaryContainer ) } - Column() { + var configExpanded by remember { mutableStateOf(false) } + val configTabState by animateFloatAsState(targetValue = if (configExpanded) 180f else 0f) + + Column { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.padding(end = 8.dp) ) { Checkbox( - enabled = !group.forceEnabled && !group.forceDisabled, - checked = modEnabled, - onCheckedChange = { state.toggles.setModEnabled(mod, !modEnabled) } + enabled = group.force == Option.DEFAULT, + checked = mod.enabled, + onCheckedChange = { interactions.onToggleMod(!mod.enabled) } ) if (infoIcon != null) Icon(infoIcon, "Mod Information", modifier = Modifier.padding(end = 8.dp)) @@ -83,9 +69,7 @@ fun ModInfoDisplay(group: Group, mod: Mod) { Row(Modifier.weight(6f)) { Text(mod.info.name, style = MaterialTheme.typography.bodyLarge) // build list of mods that are incompatible with this mod - val incompatibleMods = state.modpack.mods.mods - .filter { !mod.compatibleWith(it) } - .map { it.info.name } + val incompatibleMods = mod.incompatibleWith if (mod.info.requires.isNotEmpty() || incompatibleMods.isNotEmpty()) { TooltipArea( modifier = Modifier.alpha(0.5f), @@ -138,12 +122,7 @@ fun ModInfoDisplay(group: Group, mod: Mod) { TooltipArea( modifier = Modifier.alpha(0.5f), tooltip = { - Tooltip { - Text( - text = "Open homepage", - style = MaterialTheme.typography.labelMedium - ) - } + Tooltip("Open homepage") } ) { IconButton(onClick = { DesktopHelpers.browse(mod.info.homepage) }) { @@ -155,42 +134,12 @@ fun ModInfoDisplay(group: Group, mod: Mod) { } } } + AnimatedVisibility(configExpanded) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { - if (!mod.info.forceConfigDownload) state.toggles.setModConfigEnabled( - mod, - !configEnabled - ) - } - .fillMaxWidth() - ) { - Spacer(Modifier.width(20.dp)) - Checkbox( - checked = configEnabled || mod.info.forceConfigDownload, - onCheckedChange = { - if (!mod.info.forceConfigDownload) state.toggles.setModConfigEnabled(mod, !configEnabled) - }, - enabled = !mod.info.forceConfigDownload, - ) - Column { - Text( - "Download our recommended configuration", - style = MaterialTheme.typography.bodyMedium - ) - if (mod.info.configDesc.isNotEmpty()) { - Spacer(Modifier.width(4.dp)) - Text( - mod.info.configDesc, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.alpha(0.5f) - ) - } - } - } + ModConfigOptions() } } } } + + diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/TripleSwitch.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/TripleSwitch.kt similarity index 87% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/TripleSwitch.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/TripleSwitch.kt index c0d72ed..3ada6c4 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/TripleSwitch.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/TripleSwitch.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.modpack.settings +package com.mineinabyss.launchy.instance.ui.components.settings import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.layout.Arrangement @@ -16,22 +16,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.data.Constants -import com.mineinabyss.launchy.data.modpacks.Group -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState +import com.mineinabyss.launchy.core.ui.Constants +import com.mineinabyss.launchy.instance.ui.ModGroupUiState +import com.mineinabyss.launchy.instance.ui.ModUiState import com.mineinabyss.launchy.util.Option @Composable fun ToggleButtons( onSwitch: (Option) -> Unit, - group: Group, - mods: Collection, + group: ModGroupUiState, + mods: List, ) { - val state = LocalGameInstanceState val offColor = Color.Transparent val offTextColor = MaterialTheme.colorScheme.surface - val forced = group.forceEnabled || group.forceDisabled + val forced = group.force != Option.DEFAULT Surface(shape = RoundedCornerShape(20.0.dp)) { Surface( color = MaterialTheme.colorScheme.background, @@ -43,8 +41,8 @@ fun ToggleButtons( horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.width(Constants.SETTINGS_PRIMARY_BUTTON_WIDTH) ) { - val fullEnable = state.toggles.enabledMods.containsAll(mods) - val fullDisable = mods.none { it in state.toggles.enabledMods } + val fullEnable = mods.all { it.enabled } + val fullDisable = mods.all { !it.enabled } val disableColorContainer by animateColorAsState( if (fullDisable) MaterialTheme.colorScheme.errorContainer diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/ActionButton.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/ActionButton.kt new file mode 100644 index 0000000..a5696e6 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/ActionButton.kt @@ -0,0 +1,37 @@ +package com.mineinabyss.launchy.instance.ui.components.settings.infobar + +import androidx.compose.animation.* +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.ui.components.Tooltip + +@Composable +fun ActionButton(shown: Boolean, icon: ImageVector, desc: String, count: Int? = null) { + AnimatedVisibility( + shown, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start) + ) { + Row { + TooltipArea(tooltip = { Tooltip(desc) }) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, desc, modifier = Modifier.padding(end = 4.dp).alignByBaseline()) + if (count != null) { + val animatedCount by animateIntAsState(targetValue = count) + Text(animatedCount.toString(), modifier = Modifier.alignByBaseline()) + } + } + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/InfoBar.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/InfoBar.kt new file mode 100644 index 0000000..b9b94f9 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/InfoBar.kt @@ -0,0 +1,90 @@ +package com.mineinabyss.launchy.instance.ui.components.settings.infobar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.HistoryEdu +import androidx.compose.material.icons.rounded.Update +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mineinabyss.launchy.core.ui.Constants +import com.mineinabyss.launchy.core.ui.Constants.SETTINGS_HORIZONTAL_PADDING +import com.mineinabyss.launchy.instance.ui.InstallState +import com.mineinabyss.launchy.instance.ui.InstanceViewModel +import com.mineinabyss.launchy.instance.ui.components.buttons.InstallButton +import com.mineinabyss.launchy.instance.ui.components.buttons.RetryFailedButton + +@Composable +fun InfoBar( + viewModel: InstanceViewModel = viewModel(), + modifier: Modifier = Modifier +) { + val queuedState by viewModel.installState.collectAsState() + val queue = when (queuedState) { + is InstallState.Queued -> queuedState as InstallState.Queued + else -> return + } + + Surface( + tonalElevation = 2.dp, + shadowElevation = 0.dp, + shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp), + modifier = Modifier.fillMaxWidth().height(InfoBarProperties.height).then(modifier), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = SETTINGS_HORIZONTAL_PADDING, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val installState by viewModel.installState.collectAsState() + InstallButton( + installState, + Modifier.width(Constants.SETTINGS_PRIMARY_BUTTON_WIDTH), + onClick = { viewModel.installMods() } + ) + AnimatedVisibility(queue.failures.isNotEmpty()) { + RetryFailedButton( + queue.failures.count(), + onClick = { viewModel.installMods(ignoreCachedCheck = true) } + ) + } + val queued = installState as? InstallState.Queued + val instance by viewModel.instanceUiState.collectAsState() + ActionButton( + shown = queue.modLoaderChange != null, + icon = Icons.Rounded.HistoryEdu, + desc = "Mod loader updates:\n${instance?.installedModLoader ?: "Not installed"} -> ${queue.modLoaderChange}", + count = 1 + ) + ActionButton( + shown = queue.update.isNotEmpty(), + icon = Icons.Rounded.Update, + desc = "Queued updates", + count = queue.update.size + ) + ActionButton( + shown = queue.install.isNotEmpty(), + icon = Icons.Rounded.Download, + desc = "Queued downloads for new mods", + count = queue.install.size + ) + ActionButton( + shown = queue.remove.isNotEmpty(), + icon = Icons.Rounded.Delete, + desc = "Queued mod deletions", + count = queue.remove.size + ) + } + } +} + diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/InfoBarProperties.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/InfoBarProperties.kt new file mode 100644 index 0000000..4bc7407 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/components/settings/infobar/InfoBarProperties.kt @@ -0,0 +1,7 @@ +package com.mineinabyss.launchy.instance.ui.components.settings.infobar + +import androidx.compose.ui.unit.dp + +object InfoBarProperties { + val height = 64.dp +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceProperties.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceProperties.kt new file mode 100644 index 0000000..991f27b --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceProperties.kt @@ -0,0 +1,51 @@ +package com.mineinabyss.launchy.instance.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FileOpen +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.ui.components.DirectoryDialog + +@Composable +fun InstanceProperties( + minecraftDir: String, + onChangeMinecraftDir: (String) -> Unit +) { + var directoryPickerShown by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + DirectoryDialog( + directoryPickerShown, + title = "Choose your .minecraft directory", + fallbackTitle = "Choose a file in your .minecraft directory", + onCloseRequest = { + if (it != null) onChangeMinecraftDir(it.toString()) + directoryPickerShown = false + }, + ) + Column(Modifier.padding(start = 8.dp)) { + OutlinedTextField( + value = minecraftDir, + singleLine = true, + leadingIcon = { Icon(Icons.Rounded.Folder, contentDescription = "Directory") }, + trailingIcon = { + IconButton(onClick = { directoryPickerShown = true }) { + Icon(Icons.Rounded.FileOpen, contentDescription = "Choose") + } + }, + onValueChange = { onChangeMinecraftDir(it) }, + label = { Text(".minecraft directory") }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/InstanceScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceScreen.kt similarity index 50% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/InstanceScreen.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceScreen.kt index 1964e39..7eab22c 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/InstanceScreen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceScreen.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main +package com.mineinabyss.launchy.instance.ui.screens import androidx.compose.animation.AnimatedVisibility import androidx.compose.desktop.ui.tooling.preview.Preview @@ -8,20 +8,25 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState -import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.PlayButton -import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.SettingsButton -import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.UpdateButton -import com.mineinabyss.launchy.ui.state.windowScope +import com.mineinabyss.launchy.core.ui.windowScope +import com.mineinabyss.launchy.instance.ui.InstanceUiState +import com.mineinabyss.launchy.instance.ui.InstanceViewModel +import com.mineinabyss.launchy.instance.ui.components.BackgroundImage +import com.mineinabyss.launchy.instance.ui.components.LogoLarge +import com.mineinabyss.launchy.instance.ui.components.buttons.PlayButton +import com.mineinabyss.launchy.instance.ui.components.buttons.SettingsButton +import com.mineinabyss.launchy.instance.ui.components.buttons.UpdateButton +import com.mineinabyss.launchy.util.koinViewModel @ExperimentalComposeUiApi @Preview @Composable -fun InstanceScreen() { - val packState = LocalGameInstanceState - +fun InstanceScreen( + instance: InstanceUiState, + viewModel: InstanceViewModel = koinViewModel(), +) { Box { - BackgroundImage(windowScope) + BackgroundImage(instance.background, windowScope) Column( modifier = @@ -31,14 +36,18 @@ fun InstanceScreen() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - LogoLarge(Modifier.weight(3f, false)) + LogoLarge(instance.logo, Modifier.weight(3f, false)) Row( horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().weight(1f, false), ) { - PlayButton(hideText = false, packState.instance) { packState } - AnimatedVisibility(packState.instance.updatesAvailable) { + PlayButton( + hideText = false, + instance, + onClick = { viewModel.launch() }, + ) + AnimatedVisibility(instance.updatesAvailable) { UpdateButton() } SettingsButton() diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceSettingsScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceSettingsScreen.kt new file mode 100644 index 0000000..42837ba --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/InstanceSettingsScreen.kt @@ -0,0 +1,45 @@ +package com.mineinabyss.launchy.instance.ui.screens + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.mineinabyss.launchy.core.ui.components.AnimatedTab +import com.mineinabyss.launchy.core.ui.components.ComfyWidth + +@Composable +@Preview +fun InstanceSettingsScreen() { + var selectedTabIndex by remember { mutableStateOf(0) } + ComfyWidth { + Column { + PrimaryTabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent) { + Tab( + text = { Text("Manage Mods") }, + selected = selectedTabIndex == 0, + onClick = { selectedTabIndex = 0 } + ) + Tab( + text = { Text("Options") }, + selected = selectedTabIndex == 1, + onClick = { selectedTabIndex = 1 } + ) + } + Box(Modifier.fillMaxSize()) { + AnimatedTab(selectedTabIndex == 0) { + ModManagementTab() + } + AnimatedTab(selectedTabIndex == 1) { + OptionsTab() + } + } + } + } +} + diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/ModManagementTab.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/ModManagementTab.kt new file mode 100644 index 0000000..1ebc851 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/ModManagementTab.kt @@ -0,0 +1,77 @@ +package com.mineinabyss.launchy.instance.ui.screens + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mineinabyss.launchy.core.ui.Constants +import com.mineinabyss.launchy.instance.ui.InstanceViewModel +import com.mineinabyss.launchy.instance.ui.ModListUiState +import com.mineinabyss.launchy.instance.ui.components.settings.ModGroup +import com.mineinabyss.launchy.instance.ui.components.settings.infobar.InfoBar + +@Composable +fun ModManagementTab( + instance: InstanceViewModel = viewModel() +) { + Scaffold( + containerColor = Color.Transparent, + bottomBar = { InfoBar() }, + ) { paddingValues -> + val modsState by instance.modsState.collectAsState() + val groups = when (modsState) { + is ModListUiState.Loading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + + is ModListUiState.Error -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Error loading mods: ${(modsState as ModListUiState.Error).message}") + } + return@Scaffold + } + + is ModListUiState.Loaded -> (modsState as ModListUiState.Loaded).groups + } + Box(Modifier.padding(paddingValues)) { + Box(Modifier.padding(horizontal = Constants.SETTINGS_HORIZONTAL_PADDING)) { + val userMods by instance.userInstalledMods.collectAsState() + val lazyListState = rememberLazyListState() + LazyColumn(Modifier.fillMaxSize().padding(end = 12.dp), lazyListState) { + item { Spacer(Modifier.height(4.dp)) } + items(groups) { group -> + val groupInteractions = instance.groupInteractionsFor(group.id) + ModGroup(group, groupInteractions) + } + // TODO probably worth just merging into groups + userMods?.let { + item { + val groupInteractions = instance.groupInteractionsFor(it.id) + ModGroup(it, groupInteractions) + } + } + } + VerticalScrollbar( + modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd).padding(vertical = 2.dp), + adapter = rememberScrollbarAdapter(lazyListState) + ) + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/OptionsTab.kt b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/OptionsTab.kt new file mode 100644 index 0000000..40eef1d --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance/ui/screens/OptionsTab.kt @@ -0,0 +1,71 @@ +package com.mineinabyss.launchy.instance.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.ui.components.ComfyContent +import com.mineinabyss.launchy.core.ui.components.TitleSmall +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.screen +import com.mineinabyss.launchy.util.AppDispatchers +import com.mineinabyss.launchy.util.DesktopHelpers +import com.mineinabyss.launchy.util.InProgressTask +import kotlinx.coroutines.launch + +@Composable +fun OptionsTab() { + ComfyContent(Modifier.padding(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TitleSmall("Mods") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { pack.instance.updateInstance(state) }) { + Text("Force update Instance") + } + OutlinedButton(onClick = { DesktopHelpers.openDirectory(pack.instance.minecraftDir) }) { + Text("Open .minecraft folder") + } + OutlinedButton(onClick = { + AppDispatchers.IO.launch { + state.runTask("checkHashes", InProgressTask("Checking hashes")) { + pack.checkHashes(pack.queued.modDownloadInfo).forEach { (modId, newInfo) -> + pack.queued.modDownloadInfo[modId] = newInfo + } + } + } + }) { + Text("Re-check hashes") + } + } + + TitleSmall("Danger zone") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { + screen = Screen.Default + pack.instance.delete(state, deleteDotMinecraft = false) + }) { + Text("Delete Instance from config") + } + OutlinedButton( + onClick = { + screen = Screen.Default + pack.instance.delete(state, deleteDotMinecraft = true) + }, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) + ) { + Text("Delete Instance and its .minecraft") + } + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/ConfirmImportTab.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/ConfirmImportTab.kt new file mode 100644 index 0000000..981605f --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/ConfirmImportTab.kt @@ -0,0 +1,104 @@ +package com.mineinabyss.launchy.instance_creation.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.TextFields +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.ui.components.AnimatedTab +import com.mineinabyss.launchy.core.ui.components.ComfyContent +import com.mineinabyss.launchy.core.ui.components.ComfyTitle +import com.mineinabyss.launchy.core.ui.components.ComfyWidth +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.screen +import com.mineinabyss.launchy.instance.ui.screens.InstanceProperties +import com.mineinabyss.launchy.instance_list.data.LocalInstancesDataSource +import com.mineinabyss.launchy.instance_list.ui.components.InstanceCard +import com.mineinabyss.launchy.util.Dirs +import kotlin.io.path.exists + +@Composable +fun ConfirmImportTab( + visible: Boolean, + cloudInstance: LocalInstancesDataSource.CloudInstanceWithHeaders? +) { + if (cloudInstance == null) return + AnimatedTab(visible) { + val scrollState = rememberScrollState() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.verticalScroll(scrollState) + ) { + ComfyTitle("Confirm import") + ComfyContent { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + var nameText by remember { mutableStateOf(cloudInstance.config.name) } + fun nameValid() = nameText.matches(validInstanceNameRegex) + fun instanceExists() = Dirs.modpackConfigDir(nameText).exists() + var nameValid by remember { mutableStateOf(nameValid()) } + var instanceExists by remember { mutableStateOf(instanceExists()) } + var minecraftDir: String? by remember { mutableStateOf(null) } + + TextField( + value = nameText, + onValueChange = { + nameText = it + instanceExists = false + }, + singleLine = true, + isError = !nameValid || instanceExists, + leadingIcon = { Icon(Icons.Rounded.TextFields, contentDescription = "Name") }, + supportingText = { + if (!nameValid) Text("Name must be alphanumeric") + else if (instanceExists) Text("An instance with this name already exists") + }, + label = { Text("Instance name") }, + modifier = Modifier.fillMaxWidth(), + ) + + InstanceProperties( + minecraftDir ?: nameText, + onChangeMinecraftDir = { minecraftDir = it } + ) + + TextButton( + onClick = { + nameValid = nameValid() + instanceExists = instanceExists() + if (!nameValid || instanceExists) return@TextButton + val editedConfig = cloudInstance.config.copy( + name = nameText, + overrideMinecraftDir = minecraftDir.takeIf { it?.isNotEmpty() == true } + ) + GameInstanceDataSource.createCloudInstance( + state, cloudInstance.copy(config = editedConfig) + ) + screen = Screen.Default + } + ) { + Text("Confirm", color = MaterialTheme.colorScheme.primary) + } + } + } + + ComfyWidth { + InstanceCard( + cloudInstance.config.copy(name = "Preview"), + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/ImportTab.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/ImportTab.kt new file mode 100644 index 0000000..b3cd357 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/ImportTab.kt @@ -0,0 +1,103 @@ +package com.mineinabyss.launchy.instance_creation.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mineinabyss.launchy.core.ui.components.AnimatedTab +import com.mineinabyss.launchy.core.ui.components.ComfyContent +import com.mineinabyss.launchy.core.ui.components.ComfyTitle +import com.mineinabyss.launchy.downloads.data.Downloader +import com.mineinabyss.launchy.instance.data.storage.InstanceConfig +import com.mineinabyss.launchy.util.AppDispatchers +import com.mineinabyss.launchy.util.Dirs +import com.mineinabyss.launchy.util.InProgressTask +import kotlinx.coroutines.launch +import kotlin.io.path.deleteIfExists + +@Composable +fun ImportTab( + visible: Boolean, + onGetInstance: (GameInstanceDataSource.CloudInstanceWithHeaders) -> Unit = {} +) { + AnimatedTab(visible) { + Column { + ComfyTitle("Import from link") + + ComfyContent { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + var urlText by remember { mutableStateOf("") } + var urlValid by remember { mutableStateOf(true) } + fun urlValid() = urlText.startsWith("https://") || urlText.startsWith("http://") + var failMessage: String? by remember { mutableStateOf(null) } + + OutlinedTextField( + value = urlText, + singleLine = true, + isError = !urlValid || failMessage != null, + leadingIcon = { Icon(Icons.Rounded.Link, contentDescription = "Link") }, + onValueChange = { + urlText = it + failMessage = null + }, + label = { Text("Link") }, + supportingText = { + if (!urlValid) Text("Must be valid URL") + else if (failMessage != null) Text(failMessage!!) + }, + modifier = Modifier.fillMaxWidth() + ) + + TextButton(onClick = { + urlValid = urlValid() + if (!urlValid) return@TextButton + val taskKey = "importCloudInstance" + val downloadPath = Dirs.createTempCloudInstanceFile() + downloadPath.deleteIfExists() + AppDispatchers.IO.launch { + val cloudInstance = state.runTask(taskKey, InProgressTask("Importing cloud instance")) { + Downloader.download(urlText, downloadPath).mapCatching { + when (it) { + is Downloader.DownloadResult.AlreadyExists -> { + failMessage = "Instance already downloaded locally" + return@launch + } + + is Downloader.DownloadResult.Success -> { + GameInstanceDataSource.CloudInstanceWithHeaders( + config = InstanceConfig.read(downloadPath) + .showDialogOnError("Failed to read cloud instance") + .getOrThrow(), + url = urlText, + headers = it.modifyHeaders + ) + } + } + }.getOrElse { + failMessage = "URL is not a valid instance file" + return@launch + } + } + onGetInstance(cloudInstance) + } + }) { + Text("Import", color = MaterialTheme.colorScheme.primary) + } + } + } +// PopularInstances() + } + } + +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/NewInstance.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/NewInstance.kt new file mode 100644 index 0000000..243bbc1 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/NewInstance.kt @@ -0,0 +1,36 @@ +package com.mineinabyss.launchy.instance_creation.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import com.mineinabyss.launchy.core.ui.components.ComfyWidth +import com.mineinabyss.launchy.instance_list.data.LocalInstancesDataSource + +val validInstanceNameRegex = Regex("^[a-zA-Z0-9_ ]+$") + +@Composable +fun NewInstance() { + var selectedTabIndex by remember { mutableStateOf(0) } + var importingInstance: LocalInstancesDataSource.CloudInstanceWithHeaders? by remember { mutableStateOf(null) } + Column { + ComfyWidth { + PrimaryTabRow(selectedTabIndex = selectedTabIndex) { + Tab( + text = { Text("Import") }, + selected = true, + onClick = { selectedTabIndex = 0 } + ) + } + } + Box { + ImportTab(selectedTabIndex == 0 && importingInstance == null, onGetInstance = { + importingInstance = it + }) + ConfirmImportTab(selectedTabIndex == 0 && importingInstance != null, importingInstance) + } + } +} + diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/PopularInstances.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/PopularInstances.kt new file mode 100644 index 0000000..e7d89ca --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_creation/ui/PopularInstances.kt @@ -0,0 +1,25 @@ +package com.mineinabyss.launchy.instance_creation.ui + +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.mineinabyss.launchy.core.ui.components.ComfyContent +import com.mineinabyss.launchy.core.ui.components.ComfyTitle + +@Composable +fun PopularInstances() { + val popularInstances = remember { + listOf( + "" + ) + } + ComfyTitle("Popular instances") + ComfyContent { + LazyRow { + items(popularInstances) { +// InstanceCard(it, modifier = Modifier.padding(8.dp)) + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/InstanceCardInteractions.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/InstanceCardInteractions.kt new file mode 100644 index 0000000..f98bf4a --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/InstanceCardInteractions.kt @@ -0,0 +1,6 @@ +package com.mineinabyss.launchy.instance_list.data + +data class InstanceCardInteractions( + val onOpen: () -> Unit, + val onPlay: () -> Unit, +) diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/InstanceRepository.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/InstanceRepository.kt new file mode 100644 index 0000000..d7d3116 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/InstanceRepository.kt @@ -0,0 +1,88 @@ +package com.mineinabyss.launchy.instance_list.data + +import com.mineinabyss.launchy.config.data.ConfigRepository +import com.mineinabyss.launchy.core.data.FileSystemDataSource +import com.mineinabyss.launchy.core.data.TasksRepository +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.screen +import com.mineinabyss.launchy.downloads.data.formats.Modpack +import com.mineinabyss.launchy.instance.data.InstanceModel +import com.mineinabyss.launchy.util.AppDispatchers +import com.mineinabyss.launchy.util.InProgressTask +import com.mineinabyss.launchy.util.InstanceKey +import com.mineinabyss.launchy.util.showDialogOnError +import io.ktor.http.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.koin.compose.currentKoinScope + +class InstanceRepository( + val local: LocalInstancesDataSource, + val remote: RemoteInstanceDataSource, + val tasks: TasksRepository, + val files: FileSystemDataSource, + val configRepo: ConfigRepository, +) { + private val _instances = MutableStateFlow(mapOf()) + + val instances = _instances.asStateFlow() + val lastPlayed = configRepo.config.map { it.lastPlayedMap } + + suspend fun loadLocalInstances() = withContext(AppDispatchers.IO) { + val instances = local.readInstances() + _instances.update { instances.associateBy { it.key } } + } + + suspend fun delete( + key: InstanceKey, + deleteMinecraftDir: Boolean + ) = withContext(AppDispatchers.IO) { + val instance = _instances.value[key] ?: return@withContext + tasks.run("deleteInstance", InProgressTask("Deleting instance ${instance.config.name}")) { + local.deleteInstance(instance, deleteMinecraftDir) + _instances.update { it.minus(key) } + } + } + + suspend fun fetchCloudInstanceUpdates( + key: InstanceKey + ) = withContext(AppDispatchers.IO) { + val instance = _instances.value[key] ?: return@withContext + screen = Screen.Default +// TODO enabled = false + tasks.run("updateInstance", InProgressTask("Updating instance: ${instance.config.name}")) { + val cloudUrl = instance.config.cloudInstanceURL ?: return@withContext + remote.fetchUpdatesForInstance(instance.config, Url(cloudUrl)) + .mapCatching { merged -> + val model = instance.copy(config = merged) + local.saveInstance(model) + model + } + .showDialogOnError("Failed to update instance ${instance.config.name}") + .onSuccess { merged -> + _instances.update { it + (key to merged) } + } + } + } + + suspend fun fetchPackUpdates(key: InstanceKey) = withContext(AppDispatchers.IO) { + val instance = _instances.value[key] ?: error("Instance $key not found") + //TODO how to pass koin scope here? + val source = instance.config.source.getDataSource(currentKoinScope()) + val packFormat = instance.config.pack.getFormat() + if (!source.skip(instance)) { + source.fetchLatestModsFor(instance)?.let { + packFormat.prepareSource(instance, it) + } + } + } + + suspend fun loadPack(key: InstanceKey): Result = withContext(AppDispatchers.IO) { + val instance = _instances.value[key] ?: error("Instance $key not found") + val packFormat = instance.config.pack.getFormat() + packFormat.loadPackFor(instance) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/LocalInstancesDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/LocalInstancesDataSource.kt new file mode 100644 index 0000000..62e6f8f --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/LocalInstancesDataSource.kt @@ -0,0 +1,61 @@ +package com.mineinabyss.launchy.instance_list.data + +import com.charleskorn.kaml.decodeFromStream +import com.charleskorn.kaml.encodeToStream +import com.mineinabyss.launchy.downloads.data.Downloader +import com.mineinabyss.launchy.instance.data.InstanceModel +import com.mineinabyss.launchy.instance.data.ModListModel +import com.mineinabyss.launchy.instance.data.storage.InstanceConfig +import com.mineinabyss.launchy.util.Formats +import java.nio.file.Path +import kotlin.io.path.* + +class LocalInstancesDataSource( + val rootDir: Path, +) { + fun readInstances(): List = rootDir + .listDirectoryEntries() + .filter { it.isDirectory() } + .mapNotNull { dir -> + readInstance(dir / "instance.yml") + .onFailure { it.printStackTrace() } + .getOrNull() + } + + fun readInstance(instanceFile: Path): Result = runCatching { + InstanceModel( + Formats.yaml.decodeFromStream(instanceFile.inputStream()), + instanceFile.parent + ) + } + + fun saveInstance(instance: InstanceModel) { + val file = instance.instanceFile + if (file.exists()) { + file.copyTo(instance.backupInstanceFile, overwrite = true) + } else { + file.createFile() + } + Formats.yaml.encodeToStream(instance, file.outputStream()) + } + + @OptIn(ExperimentalPathApi::class) + fun deleteInstance( + instance: InstanceModel, + deleteMinecraftDir: Boolean + ) { + if (deleteMinecraftDir) instance.minecraftDir.deleteRecursively() + instance.directory.deleteRecursively() + } + + fun loadModList(instance: InstanceModel): ModListModel { + TODO() + } + + data class CloudInstanceWithHeaders( + val config: InstanceConfig, + val url: String, + val headers: Downloader.ModifyHeaders, + ) + +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/RemoteInstanceDataSource.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/RemoteInstanceDataSource.kt new file mode 100644 index 0000000..9b4846f --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/data/RemoteInstanceDataSource.kt @@ -0,0 +1,31 @@ +package com.mineinabyss.launchy.instance_list.data + +import com.mineinabyss.launchy.downloads.data.Downloader +import com.mineinabyss.launchy.instance.data.storage.InstanceConfig +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class RemoteInstanceDataSource( + val downloader: Downloader +) { + suspend fun getRemoteInstance( + url: Url + ): Result = runCatching { + //TODO cache by headers + downloader.httpClient.get(url).body() + } + + suspend fun fetchUpdatesForInstance( + instance: InstanceConfig, + url: Url, + ): Result = getRemoteInstance(url).mapCatching { remote -> + instance.copy( + description = remote.description, + backgroundURL = remote.backgroundURL, + logoURL = remote.logoURL, + hue = remote.hue, + source = remote.source, + ) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/HomeScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/InstanceListScreen.kt similarity index 59% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/HomeScreen.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/InstanceListScreen.kt index 3b2293c..1569a78 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/InstanceListScreen.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.home +package com.mineinabyss.launchy.instance_list.ui import androidx.compose.foundation.LocalScrollbarStyle import androidx.compose.foundation.VerticalScrollbar @@ -8,45 +8,28 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mineinabyss.launchy.instance_list.ui.components.InstanceList @Composable -fun HomeScreen() { - val state = LocalLaunchyState - +fun InstanceListScreen(viewModel: InstanceListViewModel = viewModel()) { Box { val scrollState = rememberLazyListState() BoxWithConstraints { Column(Modifier.padding(end = 20.dp).fillMaxSize()) { -// var searchQuery by remember { mutableStateOf("") } -// SearchBar( -// searchQuery, -// active = false, -// placeholder = { Text("Search for modpacks") }, -// onQueryChange = { searchQuery = it }, -// onSearch = {}, -// onActiveChange = {}, -// modifier = Modifier.fillMaxWidth(), -// leadingIcon = { -// Icon(Icons.Rounded.Search, contentDescription = "Search") -// } -// ) { -// } LazyColumn(state = scrollState, modifier = Modifier.fillMaxSize()) { item { Spacer(Modifier.height(16.dp)) } item { - InstanceList( - "Instances", - state.gameInstances.sortedByDescending { state.lastPlayed[it.config.name] }) + val instances by viewModel.instances.collectAsState() + InstanceList("Instances", instances) } -// item { -// ModpackGroup("Find more", state.downloadedModpacks) -// } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/InstanceListViewModel.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/InstanceListViewModel.kt new file mode 100644 index 0000000..43bb399 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/InstanceListViewModel.kt @@ -0,0 +1,49 @@ +package com.mineinabyss.launchy.instance_list.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mineinabyss.launchy.instance.ui.InstanceUiState +import com.mineinabyss.launchy.instance_list.data.InstanceCardInteractions +import com.mineinabyss.launchy.instance_list.data.InstanceRepository +import com.mineinabyss.launchy.util.InstanceKey +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class InstanceListViewModel( + val instanceRepo: InstanceRepository, +) : ViewModel() { + val instances = instanceRepo.instances.combine(instanceRepo.lastPlayed) { inst, lastPlayed -> + inst.values.sortedBy { lastPlayed[it.key] } + .map { (config, userConfig, dir, key) -> + InstanceUiState( + title = config.name, + description = config.description, + isCloudInstance = config.cloudInstanceURL != null, + // TODO + logo = null, + background = null, + runningProcess = null, + enabled = true, + updatesAvailable = false, + hue = 0f, + key = key, + installedModLoader = userConfig.userAgreedDeps?.fullVersionName, + ) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + init { + merge(instanceRepo.instances, instanceRepo.lastPlayed).map { + } + viewModelScope.launch { + instanceRepo.loadLocalInstances() + } + } + + fun cardInteractionsFor(key: InstanceKey): InstanceCardInteractions { + return InstanceCardInteractions( + onOpen = { TODO() }, + onPlay = { TODO() } + ) + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/AddNewModpackCard.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/AddNewModpackCard.kt similarity index 88% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/AddNewModpackCard.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/AddNewModpackCard.kt index 6e7cc7e..fd5eedc 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/AddNewModpackCard.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/AddNewModpackCard.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.home +package com.mineinabyss.launchy.instance_list.ui.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -15,8 +15,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.screen +import com.mineinabyss.launchy.core.ui.screens.Screen +import com.mineinabyss.launchy.core.ui.screens.screen @Composable fun AddNewModpackCard(modifier: Modifier = Modifier) { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/InstanceCard.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/InstanceCard.kt similarity index 51% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/InstanceCard.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/InstanceCard.kt index 578a61f..4fa5e05 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/InstanceCard.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/InstanceCard.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.home +package com.mineinabyss.launchy.instance_list.ui.components import androidx.compose.animation.fadeIn import androidx.compose.foundation.Image @@ -11,9 +11,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -21,21 +18,15 @@ import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.data.config.GameInstanceConfig -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.ui.colors.LaunchyColors -import com.mineinabyss.launchy.ui.colors.currentHue -import com.mineinabyss.launchy.ui.elements.Tooltip -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.home.InstanceCardStyle.cardHeight -import com.mineinabyss.launchy.ui.screens.home.InstanceCardStyle.cardPadding -import com.mineinabyss.launchy.ui.screens.home.InstanceCardStyle.cardWidth -import com.mineinabyss.launchy.ui.screens.modpack.main.SlightBackgroundTint -import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.PlayButton -import com.mineinabyss.launchy.ui.screens.screen -import kotlinx.coroutines.launch +import com.mineinabyss.launchy.core.ui.components.Tooltip +import com.mineinabyss.launchy.core.ui.theme.LaunchyColors +import com.mineinabyss.launchy.instance.ui.InstanceUiState +import com.mineinabyss.launchy.instance.ui.components.SlightBackgroundTint +import com.mineinabyss.launchy.instance.ui.components.buttons.PlayButton +import com.mineinabyss.launchy.instance_list.data.InstanceCardInteractions +import com.mineinabyss.launchy.instance_list.ui.components.InstanceCardStyle.cardHeight +import com.mineinabyss.launchy.instance_list.ui.components.InstanceCardStyle.cardPadding +import com.mineinabyss.launchy.instance_list.ui.components.InstanceCardStyle.cardWidth object InstanceCardStyle { val cardHeight = 256.dp @@ -45,37 +36,25 @@ object InstanceCardStyle { @Composable fun InstanceCard( - config: GameInstanceConfig, - instance: GameInstance? = null, + instance: InstanceUiState, + interactions: InstanceCardInteractions, modifier: Modifier = Modifier -) = MaterialTheme( - colorScheme = LaunchyColors(config.hue).DarkColors -) { - val state = LocalLaunchyState - val coroutineScope = rememberCoroutineScope() - val background by remember(config) { config.getBackgroundAsState() } +) = MaterialTheme(colorScheme = LaunchyColors(instance.hue).DarkColors) { Card( - onClick = { - instance ?: return@Card - coroutineScope.launch { - state.instanceState = instance.createModpackState(state) - currentHue = instance.config.hue - screen = Screen.Instance - } - }, - enabled = instance?.enabled == true, + onClick = { interactions.onOpen() }, + enabled = instance.enabled, modifier = modifier.height(cardHeight).width(cardWidth), ) { Box(Modifier.fillMaxSize()) { androidx.compose.animation.AnimatedVisibility( - visible = background != null, + visible = instance.background != null, enter = fadeIn(), modifier = Modifier.fillMaxSize() ) { - if (background != null) Image( - painter = background!!, + if (instance.background != null) Image( + painter = instance.background, colorFilter = - if (instance?.enabled == false) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + if (instance.enabled == false) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null, contentDescription = "Pack background image", contentScale = ContentScale.Crop, @@ -84,7 +63,7 @@ fun InstanceCard( } SlightBackgroundTint() - if (config.cloudInstanceURL != null) TooltipArea( + if (instance.isCloudInstance) TooltipArea( tooltip = { Tooltip("Cloud modpack") }, modifier = Modifier.align(Alignment.TopEnd).padding(cardPadding + 4.dp).size(24.dp), ) { @@ -98,24 +77,27 @@ fun InstanceCard( ) { Column(Modifier.weight(1f, true)) { Text( - config.name, + instance.title, style = MaterialTheme.typography.headlineMedium, overflow = TextOverflow.Ellipsis, maxLines = 1 ) Text( - config.description, + instance.description, style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis, maxLines = 1 ) } - if (instance?.enabled == true) - PlayButton(hideText = true, instance, Modifier.weight(1f, false)) { - state.runTask("modpackState", InProgressTask("Checking for pack updates...")) { - instance.createModpackState(state, awaitUpdatesCheck = true) + if (instance.enabled) + PlayButton( + hideText = true, + instance, + Modifier.weight(1f, false), + onClick = { + interactions.onPlay() } - } + ) } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/InstanceList.kt b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/InstanceList.kt similarity index 68% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/InstanceList.kt rename to src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/InstanceList.kt index 911045f..8a732b2 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/InstanceList.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/instance_list/ui/components/InstanceList.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.home +package com.mineinabyss.launchy.instance_list.ui.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells @@ -8,25 +8,28 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.config.GameInstance +import com.mineinabyss.launchy.instance.ui.InstanceUiState +import com.mineinabyss.launchy.instance_list.ui.InstanceListViewModel +import com.mineinabyss.launchy.util.koinViewModel @Composable -fun InstanceList(title: String, packs: List) { - val state = LocalLaunchyState +fun InstanceList( + title: String, + instances: List, + viewModel: InstanceListViewModel = koinViewModel() +) { Column { -// var showAll by remember { mutableStateOf(false) } - val visiblePacks = packs//.take(6) Row { Text(title, style = MaterialTheme.typography.headlineMedium) } Spacer(Modifier.height(8.dp)) - if (visiblePacks.isEmpty()) { + if (instances.isEmpty()) { Text("No instances installed yet, click the + button on the sidebar to add one!") } else BoxWithConstraints(Modifier) { - val total = packs.size + 1 + val total = instances.size + 1 val colums = ((maxWidth / InstanceCardStyle.cardWidth).toInt()).coerceAtMost(total).coerceAtLeast(1) val rows = (total / colums).coerceAtLeast(1) val lazyGridState = rememberLazyGridState() @@ -40,8 +43,9 @@ fun InstanceList(title: String, packs: List) { horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - items(visiblePacks) { pack -> - InstanceCard(pack.config, pack) + items(instances, key = { it.key }) { instance -> + val interactions = remember(instance.key) { viewModel.cardInteractionsFor(instance.key) } + InstanceCard(instance, interactions) } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Launcher.kt b/src/main/kotlin/com/mineinabyss/launchy/launcher/data/Launcher.kt similarity index 87% rename from src/main/kotlin/com/mineinabyss/launchy/logic/Launcher.kt rename to src/main/kotlin/com/mineinabyss/launchy/launcher/data/Launcher.kt index cad9641..c2f8a27 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Launcher.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/launcher/data/Launcher.kt @@ -1,11 +1,12 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.launcher.data -import com.mineinabyss.launchy.data.modpacks.InstanceModLoaders -import com.mineinabyss.launchy.state.LaunchyState -import com.mineinabyss.launchy.state.ProfileState -import com.mineinabyss.launchy.state.modpack.GameInstanceState -import com.mineinabyss.launchy.ui.screens.Dialog -import com.mineinabyss.launchy.ui.screens.dialog +import com.mineinabyss.launchy.auth.data.ProfileRepository +import com.mineinabyss.launchy.core.ui.Dialog +import com.mineinabyss.launchy.core.ui.LaunchyUiState +import com.mineinabyss.launchy.core.ui.screens.dialog +import com.mineinabyss.launchy.instance.data.ModLoaderModel +import com.mineinabyss.launchy.instance.ui.GameInstanceState +import com.mineinabyss.launchy.util.AppDispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -30,7 +31,8 @@ import kotlin.io.path.notExists object Launcher { - suspend fun launch(state: LaunchyState, pack: GameInstanceState, profile: ProfileState): Unit = coroutineScope { + suspend fun launch(state: LaunchyUiState, pack: GameInstanceState, profile: ProfileRepository): Unit = + coroutineScope { val dir = MinecraftDirectory(pack.instance.minecraftDir.toFile()) val launcher = LauncherBuilder.buildDefault() val javaPath = state.jvm.javaPath @@ -41,7 +43,7 @@ object Launcher { state.lastPlayed[pack.instance.config.name] = Date().time // Auth or show dialog when (val session = profile.currentSession) { - null -> Auth.authOrShowDialog(state, profile) { + null -> Authenticator.authOrShowDialog(state, profile) { launch { launch(state, pack, profile) } } @@ -90,7 +92,7 @@ object Launcher { } fun download( - modLoaders: InstanceModLoaders, + modLoaders: ModLoaderModel, minecraftDir: Path, onStartDownload: (String) -> Unit = {}, onFinishDownload: (String) -> Unit = {} diff --git a/src/main/kotlin/com/mineinabyss/launchy/launcher/data/ProcessRepository.kt b/src/main/kotlin/com/mineinabyss/launchy/launcher/data/ProcessRepository.kt new file mode 100644 index 0000000..45147d2 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/launcher/data/ProcessRepository.kt @@ -0,0 +1,14 @@ +package com.mineinabyss.launchy.launcher.data + +import com.mineinabyss.launchy.util.InstanceKey + +class ProcessRepository { + private val launchedProcesses = mutableMapOf() + + fun processFor(instance: InstanceKey): Process? = launchedProcesses[instance] + + fun setProcessFor(instance: InstanceKey, process: Process?) { + if (process == null) launchedProcesses.remove(instance) + else launchedProcesses[instance] = process + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/launcher/ui/LauncherViewModel.kt b/src/main/kotlin/com/mineinabyss/launchy/launcher/ui/LauncherViewModel.kt new file mode 100644 index 0000000..137b5c7 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/launcher/ui/LauncherViewModel.kt @@ -0,0 +1,59 @@ +package com.mineinabyss.launchy.launcher.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mineinabyss.launchy.core.ui.Dialog +import com.mineinabyss.launchy.core.ui.screens.dialog +import com.mineinabyss.launchy.downloads.data.ModDownloader.startInstall +import com.mineinabyss.launchy.instance.ui.InstanceUiState +import com.mineinabyss.launchy.launcher.data.Launcher +import com.mineinabyss.launchy.util.AppDispatchers +import com.mineinabyss.launchy.util.AppDispatchers.launchOrShowDialog +import kotlinx.coroutines.launch + +class LauncherViewModel : ViewModel() { + fun launch(instance: InstanceUiState) { + viewModelScope.launch(AppDispatchers.IO) { + val packState = foundPackState ?: getModpackState() ?: return@launch + foundPackState = packState + val updatesAvailable = packState.instance.updatesAvailable + + if (process == null) { + when { + // Assume this means not launched before + packState.queued.userAgreedModLoaders == null -> { + AppDispatchers.profileLaunch.launchOrShowDialog { + packState.startInstall(state).getOrThrow() + Launcher.launch(state, packState, state.profile) + } + } + + updatesAvailable -> { + dialog = Dialog.Options( + title = "Update Available", + message = buildString { + appendLine("This cloud instance has updates available.") + appendLine("Would you like to download them now?") + }, + acceptText = "Download", + declineText = "Ignore", + onAccept = { packState.instance.updateInstance(state) }, + onDecline = { } + ) + } + + else -> { + AppDispatchers.profileLaunch.launchOrShowDialog { + packState.startInstall(state).getOrThrow() + println("Launching now!") + Launcher.launch(state, packState, state.profile) + } + } + } + } else { + process.destroyForcibly() + state.setProcessFor(packState.instance, null) + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Auth.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Auth.kt deleted file mode 100644 index 117eaa2..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Auth.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.mineinabyss.launchy.logic - -import com.mineinabyss.launchy.data.auth.SessionStorage -import com.mineinabyss.launchy.data.config.PlayerProfile -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.state.LaunchyState -import com.mineinabyss.launchy.state.ProfileState -import com.mineinabyss.launchy.ui.screens.Dialog -import com.mineinabyss.launchy.ui.screens.dialog -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -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.* - - -object Auth { - val JAVA_DEVICE_CODE_LOGIN = 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) - - suspend fun authOrShowDialog( - state: LaunchyState, - profile: ProfileState, - onAuthenticate: (FullJavaSession) -> Unit = {}, - ) = coroutineScope { - launch(Dispatchers.IO) { - if (profile.currentProfile == null) dialog = Dialog.Auth - state.runTask("auth", InProgressTask("Authenticating")) { - authFlow( - profile, - onVerificationRequired = { - state.inProgressTasks.remove("auth") - DesktopHelpers.browse(it.redirectTo) - profile.authCode = it.code - dialog = Dialog.Auth - println(profile.authCode) - }, - onAuthenticate = { - launch(Dispatchers.IO) { - SessionStorage.save(it) - } - profile.currentSession = it - profile.currentProfile = PlayerProfile(it.mcProfile.name, it.mcProfile.id) - dialog = Dialog.None - onAuthenticate(it) - } - ) - } - } - } - - class VerificationRequired( - val code: String, - val redirectTo: String, - ) - - private fun authFlow( - state: ProfileState, - onVerificationRequired: (VerificationRequired) -> Unit, - onAuthenticate: (FullJavaSession) -> Unit, - ) { - MinecraftAuth.builder() - val httpClient = MinecraftAuth.createHttpClient() - val previousSession = state.currentProfile?.let { SessionStorage.load(it.uuid) } - if (previousSession != null) { - println("refreshing token") - val refreshedSession = JAVA_DEVICE_CODE_LOGIN.refresh(httpClient, previousSession) - onAuthenticate(refreshedSession) - return - } - val javaSession = JAVA_DEVICE_CODE_LOGIN.getFromInput( - httpClient, - MsaDeviceCodeCallback { msaDeviceCode: MsaDeviceCode -> - onVerificationRequired( - VerificationRequired( - msaDeviceCode.userCode, - msaDeviceCode.directVerificationUri - ) - ) - }) - onAuthenticate(javaSession) - } - - fun ProfileState.logout(uuid: UUID) { - SessionStorage.deleteIfExists(uuid) - if (currentProfile?.uuid == uuid) { - currentProfile = null - currentSession = null - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt deleted file mode 100644 index 9dc3144..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.mineinabyss.launchy.logic - -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.data.config.GameInstanceConfig -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.state.LaunchyState -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.screen -import kotlinx.coroutines.launch -import kotlin.io.path.* - -object Instances { - @OptIn(ExperimentalPathApi::class) - fun GameInstance.delete(state: LaunchyState, deleteDotMinecraft: Boolean) { - state.gameInstances.remove(this) - state.runTask("deleteInstance", InProgressTask("Deleting instance ${config.name}")) { - AppDispatchers.IO.launch { - if (deleteDotMinecraft) minecraftDir.deleteRecursively() - configDir.deleteRecursively() - } - } - } - - fun GameInstance.updateInstance( - state: LaunchyState, - onSuccess: () -> Unit = {}, - ) { - screen = Screen.Default - enabled = false - val index = state.gameInstances.indexOf(this) - AppDispatchers.IO.launch { - state.runTask("updateInstance", InProgressTask("Updating instance: ${config.name}")) { - val cloudUrl = config.cloudInstanceURL - if (cloudUrl != null) { - val newCloudInstancePath = Dirs.createTempCloudInstanceFile() - Downloader.download( - cloudUrl, newCloudInstancePath, Downloader.Options( - saveModifyHeadersFor = this@updateInstance - ) - ) - instanceFile.copyTo(configDir / "instance-backup.yml", overwrite = true) - GameInstanceConfig.read(newCloudInstancePath).onSuccess { cloudConfig -> - instanceFile.deleteIfExists() - instanceFile.createFile() - config.copy( - description = cloudConfig.description, - backgroundURL = cloudConfig.backgroundURL, - logoURL = cloudConfig.logoURL, - hue = cloudConfig.hue, - source = cloudConfig.source, - ).saveTo(instanceFile) - } - } - - // Handle case where we just updated from cloud - val config = GameInstanceConfig.read(instanceFile).getOrElse { config } - - config.source.updateInstance(this@updateInstance) - .showDialogOnError("Failed to update instance ${config.name}") - .onFailure { it.printStackTrace() } - .onSuccess { - state.gameInstances[index] = it - onSuccess() - } - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Tasks.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Tasks.kt deleted file mode 100644 index 5761c81..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Tasks.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.mineinabyss.launchy.logic - -object Tasks { - val installModLoadersId = "installMCAndModLoaders" - val copyOverridesId = "copyOverrides" -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/ToggleMods.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/ToggleMods.kt deleted file mode 100644 index 9c42dcb..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/ToggleMods.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.mineinabyss.launchy.logic - -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.state.modpack.ModTogglesState - -object ToggleMods { - fun ModTogglesState.setModEnabled(mod: Mod, enabled: Boolean) { - if (enabled) { - enabledMods += mod - enabledMods.filter { !mod.compatibleWith(it) } - .forEach { setModEnabled(it, false) } - disabledMods.filter { it.info.name in mod.info.requires }.forEach { setModEnabled(it, true) } - } else { - enabledMods -= mod - // if a mod is disabled, disable all mods that depend on it - enabledMods.filter { it.info.requires.contains(mod.info.name) }.forEach { setModEnabled(it, false) } - // if a mod is disabled, and the dependency is only used by this mod, disable the dependency too, unless it's not marked as a dependency - enabledMods.filter { dep -> - mod.info.requires.contains(dep.info.name) // if the mod depends on this dependency - && dep.info.dependency // if the dependency is marked as a dependency - && enabledMods.none { it.info.requires.contains(dep.info.name) } // and no other mod depends on this dependency -// && !versions.modGroups.filterValues { it.contains(dep) }.keys.any { it.forceEnabled } // and the group the dependency is in is not force enabled - }.forEach { setModEnabled(it, false) } - } - setModConfigEnabled(mod, enabled) - } - - fun ModTogglesState.setModConfigEnabled(mod: Mod, enabled: Boolean) { - if (mod.info.configUrl.isNotBlank() && enabled) enabledConfigs.add(mod) - else enabledConfigs.remove(mod) - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/settings/ui/JVMSettingsViewModel.kt b/src/main/kotlin/com/mineinabyss/launchy/settings/ui/JVMSettingsViewModel.kt new file mode 100644 index 0000000..a96e3bc --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/settings/ui/JVMSettingsViewModel.kt @@ -0,0 +1,110 @@ +package com.mineinabyss.launchy.settings.ui + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mineinabyss.launchy.config.data.Config +import com.mineinabyss.launchy.core.data.TasksRepository +import com.mineinabyss.launchy.downloads.data.Downloader +import com.mineinabyss.launchy.downloads.data.Downloader.JavaInstallation +import com.mineinabyss.launchy.downloads.data.Downloader.Options +import com.mineinabyss.launchy.util.* +import kotlinx.coroutines.launch +import org.rauschig.jarchivelib.ArchiveFormat +import org.rauschig.jarchivelib.ArchiverFactory +import org.rauschig.jarchivelib.CompressionType +import kotlin.io.path.* + +class JVMSettingsViewModel( + val config: Config, + val tasks: TasksRepository, + val downloader: Downloader, +) : ViewModel() { + var javaPath by mutableStateOf(config.javaPath?.let { Path(it) }) + var userMemoryAllocation by mutableStateOf(config.memoryAllocation) + var userJvmArgs by mutableStateOf(config.jvmArguments) + var useRecommendedJvmArgs by mutableStateOf(config.useRecommendedJvmArguments) + val suggestedArgs + get() = buildString { + if ("graalvm" in javaPath.toString()) { + append(SuggestedJVMArgs.graalVMBaseFlags) + } else { + append(SuggestedJVMArgs.baseFlags) + } + append(" ") + append(SuggestedJVMArgs.clientG1GC) + } + val jvmArgs by derivedStateOf { + val memory = (userMemoryAllocation ?: SuggestedJVMArgs.memory).toString() + + "-Xms${memory}M -Xmx${memory}M ${userJvmArgs?.takeIf { !useRecommendedJvmArgs } ?: suggestedArgs}" + } + val memory get() = userMemoryAllocation ?: SuggestedJVMArgs.memory + + + /** @return Path to java executable */ + @OptIn(ExperimentalPathApi::class) + fun installJDK() = viewModelScope.launch(AppDispatchers.IO) { + try { + tasks.start("installJDK", InProgressTask("Downloading Java environment")) + val arch = Arch.get().openJDKArch + val os = OS.get().openJDKName + val url = "https://api.adoptium.net/v3/binary/latest/17/ga/$os/$arch/jre/hotspot/normal/eclipse" + val javaInstallation = when (OS.get()) { + OS.WINDOWS -> JavaInstallation( + url, + "bin/java.exe", + ArchiverFactory.createArchiver(ArchiveFormat.ZIP) + ) + + OS.MAC -> JavaInstallation( + url, + "Contents/Home/bin/java", + ArchiverFactory.createArchiver(ArchiveFormat.TAR, CompressionType.GZIP) + ) + + OS.LINUX -> JavaInstallation( + url, + "bin/java", + ArchiverFactory.createArchiver(ArchiveFormat.TAR, CompressionType.GZIP) + ) + } + val downloadTo = Dirs.jdks / "openjdk-17${javaInstallation.archiver.filenameExtension}" + val extractTo = Dirs.jdks / "openjdk-17" + + val existingInstall = extractTo.resolve(javaInstallation.relativeJavaExecutable) + if (existingInstall.exists()) return@launch + downloader.download(javaInstallation.url, downloadTo, Options( + onProgressUpdate = { + tasks.start( + "installJDK", + InProgressTask.bytes( + "Downloading Java environment", + it.bytesDownloaded, + it.totalBytes + ) + ) + } + )) + tasks.start("installJDK", InProgressTask("Extracting Java environment")) + + // Handle a case where the extraction failed and the folder exists but not the java executable + extractTo.takeIf { it.exists() }?.deleteRecursively() + javaInstallation.archiver.extract(downloadTo.toFile(), extractTo.toFile()) + val entries = extractTo.listDirectoryEntries() + val jrePath = if (entries.size == 1) entries.first() else extractTo + downloadTo.deleteIfExists() + + javaPath = jrePath / javaInstallation.relativeJavaExecutable +// dialog = Dialog.Error( +// "Failed to install Java", +// "Please install Java manually and select the path in settings." +// ) + } finally { + tasks.finish("installJDK") + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/settings/SettingsScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/settings/ui/SettingsScreen.kt similarity index 78% rename from src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/settings/SettingsScreen.kt rename to src/main/kotlin/com/mineinabyss/launchy/settings/ui/SettingsScreen.kt index 2fae540..ab00165 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/settings/SettingsScreen.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/settings/ui/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.ui.screens.home.settings +package com.mineinabyss.launchy.settings.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.desktop.ui.tooling.preview.Preview @@ -14,16 +14,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.logic.DesktopHelpers -import com.mineinabyss.launchy.logic.SuggestedJVMArgs -import com.mineinabyss.launchy.ui.elements.* +import com.mineinabyss.launchy.core.ui.LocalUiState +import com.mineinabyss.launchy.core.ui.components.* +import com.mineinabyss.launchy.util.DesktopHelpers +import com.mineinabyss.launchy.util.Dirs +import com.mineinabyss.launchy.util.SuggestedJVMArgs +import com.mineinabyss.launchy.util.koinViewModel @Composable @Preview -fun SettingsScreen() { - val state = LocalLaunchyState +fun SettingsScreen( + jvm: JVMSettingsViewModel = koinViewModel(), +) { + val ui = LocalUiState.current val scrollState = rememberScrollState() Column { ComfyTitle("Settings") @@ -38,16 +41,16 @@ fun SettingsScreen() { Column { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( - state.ui.fullscreen, - onCheckedChange = { state.ui.fullscreen = it } + ui.fullscreen, + onCheckedChange = { ui.fullscreen = it } ) Text("Fullscreen mode") } } Setting("Hue", icon = { Icon(Icons.Rounded.Colorize, contentDescription = "Hue") }) { Slider( - value = state.ui.preferHue, - onValueChange = { state.ui.preferHue = it }, + value = ui.preferHue, + onValueChange = { ui.preferHue = it }, valueRange = 0f..1f, modifier = Modifier.weight(1f) ) @@ -79,7 +82,7 @@ fun SettingsScreen() { title = "Choose java executable", onCloseRequest = { if (it != null) { - state.jvm.javaPath = it + jvm.javaPath = it } directoryPickerShown = false }, @@ -89,7 +92,7 @@ fun SettingsScreen() { Setting("Java path") { OutlinedTextField( - value = state.jvm.javaPath?.toString() ?: "No path selected", + value = jvm.javaPath?.toString() ?: "No path selected", readOnly = true, singleLine = true, leadingIcon = { Icon(Icons.Rounded.Folder, contentDescription = "Link") }, @@ -105,11 +108,11 @@ fun SettingsScreen() { Setting("Memory", icon = { Icon(Icons.Rounded.Memory, "Memory icon") }) { Row(verticalAlignment = Alignment.CenterVertically) { - val memory = state.jvm.userMemoryAllocation ?: SuggestedJVMArgs.memory + val memory = jvm.userMemoryAllocation ?: SuggestedJVMArgs.memory Slider( value = memory.toFloat(), - onValueChange = { state.jvm.userMemoryAllocation = it.toInt() }, + onValueChange = { jvm.userMemoryAllocation = it.toInt() }, valueRange = 1024f..8192f, steps = 13, modifier = Modifier.weight(1f) @@ -119,7 +122,7 @@ fun SettingsScreen() { keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number ), - onValueChange = { state.jvm.userMemoryAllocation = it.toIntOrNull() ?: memory }, + onValueChange = { jvm.userMemoryAllocation = it.toIntOrNull() ?: memory }, label = { Text("Memory (MB)") }, modifier = Modifier.widthIn(120.dp) ) @@ -127,13 +130,13 @@ fun SettingsScreen() { } Setting("JVM arguments") { - AnimatedVisibility(!state.jvm.useRecommendedJvmArgs) { + AnimatedVisibility(!jvm.useRecommendedJvmArgs) { OutlinedTextField( - value = state.jvm.userJvmArgs ?: "", - enabled = !state.jvm.useRecommendedJvmArgs, + value = jvm.userJvmArgs ?: "", + enabled = !jvm.useRecommendedJvmArgs, singleLine = false, leadingIcon = { Icon(Icons.Rounded.Code, contentDescription = "") }, - onValueChange = { state.jvm.userJvmArgs = it }, + onValueChange = { jvm.userJvmArgs = it }, label = { Text("Custom JVM arguments") }, modifier = Modifier.fillMaxWidth() ) @@ -141,15 +144,15 @@ fun SettingsScreen() { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( - !state.jvm.useRecommendedJvmArgs, - onCheckedChange = { state.jvm.useRecommendedJvmArgs = !it }) + !jvm.useRecommendedJvmArgs, + onCheckedChange = { jvm.useRecommendedJvmArgs = !it }) Text("Use custom JVM arguments") } Spacer(Modifier.height(16.dp)) OutlinedTextField( - value = state.jvm.jvmArgs, + value = jvm.jvmArgs, enabled = false, singleLine = false, readOnly = true, diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/JvmState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/JvmState.kt deleted file mode 100644 index 51fee27..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/state/JvmState.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.mineinabyss.launchy.state - -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.mineinabyss.launchy.data.config.Config -import com.mineinabyss.launchy.logic.SuggestedJVMArgs -import kotlin.io.path.Path - -class JvmState( - val config: Config -) { - var javaPath by mutableStateOf(config.javaPath?.let { Path(it) }) - var userMemoryAllocation by mutableStateOf(config.memoryAllocation) - var userJvmArgs by mutableStateOf(config.jvmArguments) - var useRecommendedJvmArgs by mutableStateOf(config.useRecommendedJvmArguments) - val suggestedArgs get() = buildString { - if("graalvm" in javaPath.toString()) { - append(SuggestedJVMArgs.graalVMBaseFlags) - } else { - append(SuggestedJVMArgs.baseFlags) - } - append(" ") - append(SuggestedJVMArgs.clientG1GC) - } - val jvmArgs by derivedStateOf { - val memory = (userMemoryAllocation ?: SuggestedJVMArgs.memory).toString() - - "-Xms${memory}M -Xmx${memory}M ${userJvmArgs?.takeIf { !useRecommendedJvmArgs } ?: suggestedArgs}" - } - val memory get() = userMemoryAllocation ?: SuggestedJVMArgs.memory -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt deleted file mode 100644 index a80249a..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.mineinabyss.launchy.state - -import androidx.compose.runtime.* -import com.mineinabyss.launchy.data.config.Config -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.state.modpack.GameInstanceState -import java.util.* - -class LaunchyState( - // Config should never be mutated unless it also updates UI state - private val config: Config, - private val instances: List -) { - val profile = ProfileState(config) - var instanceState: GameInstanceState? by mutableStateOf(null) - private val launchedProcesses = mutableStateMapOf() - val jvm = JvmState(config) - val ui = UIState(config) - val lastPlayed = mutableStateMapOf().apply { - putAll(config.lastPlayedMap) - } - - val gameInstances = mutableStateListOf().apply { - addAll(instances) - } - - val inProgressTasks = mutableStateMapOf() - - fun processFor(instance: GameInstance): Process? = launchedProcesses[instance.minecraftDir.toString()] - fun setProcessFor(instance: GameInstance, process: Process?) { - if (process == null) launchedProcesses.remove(instance.minecraftDir.toString()) - else launchedProcesses[instance.minecraftDir.toString()] = process - } - - // If any state is true, we consider import handled and move on - var handledImportOptions by mutableStateOf( - config.handledImportOptions - ) - - var onboardingComplete by mutableStateOf(config.onboardingComplete) - - fun saveToConfig() { - config.copy( - handledImportOptions = handledImportOptions, - onboardingComplete = onboardingComplete, - currentProfile = profile.currentProfile, - javaPath = jvm.javaPath?.toString(), - jvmArguments = jvm.userJvmArgs, - memoryAllocation = jvm.userMemoryAllocation, - useRecommendedJvmArguments = jvm.useRecommendedJvmArgs, - preferHue = ui.preferHue, - startInFullscreen = ui.fullscreen, - lastPlayedMap = lastPlayed - ).save() - } - - inline fun runTask(key: String, task: InProgressTask, run: () -> T): T { - try { - inProgressTasks[key] = task - return run() - } finally { - inProgressTasks.remove(key) - } - } -} - -fun mutableStateSetOf() = Collections.newSetFromMap(mutableStateMapOf()) diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/ProfileState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/ProfileState.kt deleted file mode 100644 index a7e3160..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/state/ProfileState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.mineinabyss.launchy.state - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.mineinabyss.launchy.data.config.Config -import com.mineinabyss.launchy.data.config.PlayerProfile -import net.raphimc.minecraftauth.step.java.session.StepFullJavaSession.FullJavaSession - -class ProfileState( - val config: Config -) { - var authCode: String? by mutableStateOf(null) - - var currentSession: FullJavaSession? by mutableStateOf(null) - var currentProfile: PlayerProfile? by mutableStateOf(config.currentProfile) -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/UIState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/UIState.kt deleted file mode 100644 index 58831f8..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/state/UIState.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.mineinabyss.launchy.state - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.mineinabyss.launchy.data.config.Config - -class UIState(config: Config) { - var preferHue: Float by mutableStateOf(config.preferHue ?: 0f) - var fullscreen: Boolean by mutableStateOf(config.startInFullscreen) -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadQueueState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadQueueState.kt deleted file mode 100644 index 10089f0..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadQueueState.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.mineinabyss.launchy.state.modpack - -import androidx.compose.runtime.* -import com.mineinabyss.launchy.data.ModID -import com.mineinabyss.launchy.data.config.DownloadInfo -import com.mineinabyss.launchy.data.config.InstanceUserConfig -import com.mineinabyss.launchy.data.modpacks.Modpack - -class DownloadQueueState( - private val userConfig: InstanceUserConfig, - val modpack: Modpack, - val toggles: ModTogglesState -) { - /** Live mod download info, including mods that have been removed from the latest modpack version. */ - val modDownloadInfo = mutableStateMapOf().apply { - val availableIds = toggles.availableMods.map { it.modId } - putAll(userConfig.modDownloadInfo.filter { it.key in availableIds }) - } - - /** Mods whose download url matches a previously downloaded url and exist on the filesystem */ - val failures by derivedStateOf { - toggles.enabledMods.filter { - modDownloadInfo[it.modId]?.failed() == true - } - } - - /** Toggled mods that haven't been previously installed (are new to the instance) */ - val newDownloads by derivedStateOf { - toggles.enabledMods.filter { it.modId !in modDownloadInfo.keys } - } - - /** Toggled mods that have previously been downloaded but whose URL has changed */ - val updates by derivedStateOf { - toggles.enabledMods - .filter { mod -> - modDownloadInfo[mod.modId]?.let { mod.downloadUrl.toString() != it.url } == true - } - } - - /** Mods (currently listed in the Modpack) that were previously enabled, but no longer are */ - val deletions by derivedStateOf { - (modpack.mods.mods - toggles.enabledMods).filter { modDownloadInfo.contains(it.modId) } - } - - val areModLoaderUpdatesAvailable by derivedStateOf { - modpack.modLoaders != userAgreedModLoaders - } - - var userAgreedModLoaders by mutableStateOf(userConfig.userAgreedDeps) - - val needsInstall by derivedStateOf { updates + newDownloads + failures } - - val areUpdatesQueued by derivedStateOf { updates.isNotEmpty() } - val areNewDownloadsQueued by derivedStateOf { newDownloads.isNotEmpty() } - val areDeletionsQueued by derivedStateOf { deletions.isNotEmpty() } - val areOperationsQueued by derivedStateOf { - areUpdatesQueued || areNewDownloadsQueued || areDeletionsQueued || areModLoaderUpdatesAvailable - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/GameInstanceState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/GameInstanceState.kt deleted file mode 100644 index 80112d7..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/GameInstanceState.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.mineinabyss.launchy.state.modpack - -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.data.config.InstanceUserConfig -import com.mineinabyss.launchy.data.modpacks.Modpack - -class GameInstanceState( - val instance: GameInstance, - val modpack: Modpack, - private val userConfig: InstanceUserConfig -) { - val toggles: ModTogglesState = ModTogglesState(modpack, userConfig) - val queued = DownloadQueueState(userConfig, modpack, toggles) - val downloads = DownloadState() - - fun saveToConfig() { - userConfig.copy( - fullEnabledGroups = modpack.mods.modGroups - .filter { toggles.enabledMods.containsAll(it.value) }.keys - .map { it.name }.toSet(), - userAgreedDeps = queued.userAgreedModLoaders, - toggledMods = toggles.enabledMods.mapTo(mutableSetOf()) { it.modId }, - toggledConfigs = toggles.enabledConfigs.mapTo(mutableSetOf()) { it.modId } + toggles.enabledMods.filter { it.info.forceConfigDownload } - .mapTo(mutableSetOf()) { it.info.name }, - seenGroups = modpack.mods.groups.map { it.name }.toSet(), - modDownloadInfo = queued.modDownloadInfo, -// configDownloadInfo = toggles.downloadConfigURLs.mapKeys { it.key.info.name }, - ).save(instance.userConfigFile) - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModTogglesState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModTogglesState.kt deleted file mode 100644 index 2b3ec8a..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModTogglesState.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.mineinabyss.launchy.state.modpack - -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import com.mineinabyss.launchy.data.config.InstanceUserConfig -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.data.modpacks.Modpack -import com.mineinabyss.launchy.logic.ToggleMods.setModEnabled -import com.mineinabyss.launchy.state.mutableStateSetOf - -class ModTogglesState( - val modpack: Modpack, - val modpackConfig: InstanceUserConfig -) { - val availableMods = mutableStateSetOf().apply { - addAll(modpack.mods.mods) - } - val enabledMods = mutableStateSetOf().apply { - addAll(modpackConfig.toggledMods.mapNotNull { modpack.mods.getModById(it) }) - val defaultEnabled = modpack.mods.groups - .filter { it.enabledByDefault } - .map { it.name } - modpackConfig.seenGroups - val fullEnabled = modpackConfig.fullEnabledGroups - val forceEnabled = modpack.mods.groups.filter { it.forceEnabled }.map { it.name } - val forceDisabled = modpack.mods.groups.filter { it.forceDisabled } - val fullDisabled = modpackConfig.fullDisabledGroups - addAll(((fullEnabled + defaultEnabled + forceEnabled).toSet()) - .mapNotNull { modpack.mods.getGroup(it) } - .mapNotNull { modpack.mods.modGroups[it] }.flatten() - ) - removeAll((forceDisabled + fullDisabled).toSet().mapNotNull { modpack.mods.modGroups[it] }.flatten().toSet()) - } - - val disabledMods: Set by derivedStateOf { modpack.mods.mods - enabledMods } - - val enabledModsWithConfig by derivedStateOf { - enabledMods.filter { it.info.configUrl != "" } - } - - val enabledConfigs: MutableSet = mutableStateSetOf().apply { - addAll(modpackConfig.toggledConfigs.mapNotNull { modpack.mods.getModById(it) }) - } - - init { - // trigger update incase we have dependencies - enabledMods.forEach { setModEnabled(it, true) } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt deleted file mode 100644 index 489c601..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.mineinabyss.launchy.ui.dialogs - -import androidx.compose.material.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.logic.AppDispatchers -import com.mineinabyss.launchy.logic.Downloader -import com.mineinabyss.launchy.ui.elements.LaunchyDialog -import com.mineinabyss.launchy.ui.screens.Dialog -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.dialog -import com.mineinabyss.launchy.ui.screens.screen -import kotlinx.coroutines.launch - -@Composable -fun SelectJVMDialog() { - val coroutineScope = rememberCoroutineScope() - val state = LocalLaunchyState - LaunchyDialog( - title = { Text("Install java", style = LocalTextStyle.current) }, - onAccept = { - dialog = Dialog.None - AppDispatchers.IO.launch { - val jdkPath = runCatching { - Downloader.installJDK(state) - }.getOrElse { - dialog = Dialog.Error( - "Failed to install Java", - it.stackTraceToString() - ) - return@launch - } - if (jdkPath != null) { - state.jvm.javaPath = jdkPath - state.saveToConfig() - } else { - dialog = Dialog.Error( - "Failed to install Java", - "Please install Java manually and select the path in settings." - ) - } - } - }, - onDecline = { dialog = Dialog.None; screen = Screen.Settings }, - onDismiss = { dialog = Dialog.None; }, - acceptText = "Install automatically", - declineText = "Choose manually", - ) { - Text("Launchy needs Java to run Minecraft. It can install a version for you, or you can choose a path to an existing installation.") - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/PlayerAvatar.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/PlayerAvatar.kt deleted file mode 100644 index a8572dc..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/PlayerAvatar.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.mineinabyss.launchy.ui.elements - -import androidx.compose.foundation.Image -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.layout.ContentScale -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.config.PlayerProfile - -@Composable -fun PlayerAvatar(profile: PlayerProfile, modifier: Modifier = Modifier) { - val state = LocalLaunchyState - val avatar: BitmapPainter? by profile.getAvatar() - if (avatar != null) Image( - painter = avatar!!, - contentDescription = "Avatar", - contentScale = ContentScale.FillWidth, - modifier = modifier - ) -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screen.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screen.kt deleted file mode 100644 index 9824dc6..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/Screen.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.mineinabyss.launchy.ui.screens - -sealed class Screen( - val transparentTopBar: Boolean = true, - val showTitle: Boolean = false, - val showSidebar: Boolean = false, -) { - interface OnLeftSidebar - - object Default : Screen(transparentTopBar = true, showTitle = true, showSidebar = true), OnLeftSidebar - object NewInstance: Screen(transparentTopBar = true, showTitle = true, showSidebar = true), OnLeftSidebar - object Settings : Screen(transparentTopBar = true, showTitle = true, showSidebar = true), OnLeftSidebar - - object InstanceSettings : Screen(showTitle = true) - object Instance : Screen(transparentTopBar = true) - -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/newinstance/NewInstance.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/newinstance/NewInstance.kt deleted file mode 100644 index fa73456..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/home/newinstance/NewInstance.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.home.newinstance - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.TextFields -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.data.config.GameInstanceConfig -import com.mineinabyss.launchy.logic.AppDispatchers -import com.mineinabyss.launchy.logic.Downloader -import com.mineinabyss.launchy.logic.showDialogOnError -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.ui.elements.AnimatedTab -import com.mineinabyss.launchy.ui.elements.ComfyContent -import com.mineinabyss.launchy.ui.elements.ComfyTitle -import com.mineinabyss.launchy.ui.elements.ComfyWidth -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.home.InstanceCard -import com.mineinabyss.launchy.ui.screens.modpack.settings.InstanceProperties -import com.mineinabyss.launchy.ui.screens.screen -import kotlinx.coroutines.launch -import kotlin.io.path.deleteIfExists -import kotlin.io.path.exists - -val validInstanceNameRegex = Regex("^[a-zA-Z0-9_ ]+$") - -@Composable -fun NewInstance() { - val state = LocalLaunchyState - var selectedTabIndex by remember { mutableStateOf(0) } - var importingInstance: GameInstance.CloudInstanceWithHeaders? by remember { mutableStateOf(null) } - Column { - ComfyWidth { - PrimaryTabRow(selectedTabIndex = selectedTabIndex) { - Tab( - text = { Text("Import") }, - selected = true, - onClick = { selectedTabIndex = 0 } - ) - } - } - val coroutineScope = rememberCoroutineScope() - Box { - ImportTab(selectedTabIndex == 0 && importingInstance == null, onGetInstance = { - importingInstance = it - }) - ConfirmImportTab(selectedTabIndex == 0 && importingInstance != null, importingInstance) - } - } -} - -@Composable -fun ImportTab(visible: Boolean, onGetInstance: (GameInstance.CloudInstanceWithHeaders) -> Unit = {}) { - val state = LocalLaunchyState - AnimatedTab(visible) { - Column { - ComfyTitle("Import from link") - - ComfyContent { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - var urlText by remember { mutableStateOf("") } - var urlValid by remember { mutableStateOf(true) } - fun urlValid() = urlText.startsWith("https://") || urlText.startsWith("http://") - var failMessage: String? by remember { mutableStateOf(null) } - - OutlinedTextField( - value = urlText, - singleLine = true, - isError = !urlValid || failMessage != null, - leadingIcon = { Icon(Icons.Rounded.Link, contentDescription = "Link") }, - onValueChange = { - urlText = it - failMessage = null - }, - label = { Text("Link") }, - supportingText = { - if (!urlValid) Text("Must be valid URL") - else if (failMessage != null) Text(failMessage!!) - }, - modifier = Modifier.fillMaxWidth() - ) - - TextButton(onClick = { - urlValid = urlValid() - if (!urlValid) return@TextButton - val taskKey = "importCloudInstance" - val downloadPath = Dirs.createTempCloudInstanceFile() - downloadPath.deleteIfExists() - AppDispatchers.IO.launch { - val cloudInstance = state.runTask(taskKey, InProgressTask("Importing cloud instance")) { - Downloader.download(urlText, downloadPath).mapCatching { - when (it) { - is Downloader.DownloadResult.AlreadyExists -> { - failMessage = "Instance already downloaded locally" - return@launch - } - - is Downloader.DownloadResult.Success -> { - GameInstance.CloudInstanceWithHeaders( - config = GameInstanceConfig.read(downloadPath) - .showDialogOnError("Failed to read cloud instance") - .getOrThrow(), - url = urlText, - headers = it.modifyHeaders - ) - } - } - }.getOrElse { - failMessage = "URL is not a valid instance file" - return@launch - } - } - onGetInstance(cloudInstance) - } - }) { - Text("Import", color = MaterialTheme.colorScheme.primary) - } - } - } -// PopularInstances() - } - } - -} - -@Composable -fun ConfirmImportTab(visible: Boolean, cloudInstance: GameInstance.CloudInstanceWithHeaders?) { - if (cloudInstance == null) return - val state = LocalLaunchyState - AnimatedTab(visible) { - val scrollState = rememberScrollState() - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.verticalScroll(scrollState) - ) { - ComfyTitle("Confirm import") - ComfyContent { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - var nameText by remember { mutableStateOf(cloudInstance.config.name) } - fun nameValid() = nameText.matches(validInstanceNameRegex) - fun instanceExists() = Dirs.modpackConfigDir(nameText).exists() - var nameValid by remember { mutableStateOf(nameValid()) } - var instanceExists by remember { mutableStateOf(instanceExists()) } - var minecraftDir: String? by remember { mutableStateOf(null) } - - OutlinedTextField( - value = nameText, - singleLine = true, - isError = !nameValid || instanceExists, - leadingIcon = { Icon(Icons.Rounded.TextFields, contentDescription = "Name") }, - supportingText = { - if (!nameValid) Text("Name must be alphanumeric") - else if (instanceExists) Text("An instance with this name already exists") - }, - onValueChange = { - nameText = it - instanceExists = false - }, - label = { Text("Instance name") }, - modifier = Modifier.fillMaxWidth() - ) - - InstanceProperties( - minecraftDir ?: nameText, - onChangeMinecraftDir = { minecraftDir = it } - ) - - TextButton( - onClick = { - nameValid = nameValid() - instanceExists = instanceExists() - if (!nameValid || instanceExists) return@TextButton - val editedConfig = cloudInstance.config.copy( - name = nameText, - overrideMinecraftDir = minecraftDir.takeIf { it?.isNotEmpty() == true } - ) - GameInstance.createCloudInstance( - state, cloudInstance.copy(config = editedConfig) - ) - screen = Screen.Default - } - ) { - Text("Confirm", color = MaterialTheme.colorScheme.primary) - } - } - } - - ComfyWidth { - InstanceCard( - cloudInstance.config.copy(name = "Preview"), - modifier = Modifier.fillMaxWidth() - ) - } - } - } -} - -@Composable -fun PopularInstances() { - val state = LocalLaunchyState - val coroutineScope = rememberCoroutineScope() - val popularInstances = remember { - listOf( - "" - ) - } - ComfyTitle("Popular instances") - ComfyContent { - LazyRow { - items(popularInstances) { -// InstanceCard(it, modifier = Modifier.padding(8.dp)) - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/FirstLaunchDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/FirstLaunchDialog.kt deleted file mode 100644 index 5d01be8..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/FirstLaunchDialog.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.window.WindowDraggableArea -import androidx.compose.material.Text -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.ui.elements.LaunchyDialog -import com.mineinabyss.launchy.ui.state.windowScope - -@Composable -fun FirstLaunchDialog() { - val state = LocalLaunchyState - if (state.onboardingComplete) return - - val complete = { state.onboardingComplete = true} - // Overlay that prevents clicking behind it - windowScope.WindowDraggableArea { - Box(Modifier.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)).fillMaxSize()) - } - - LaunchyDialog( - title = { Text("Welcome to Launchy!") }, - onAccept = complete, - onDecline = complete, - onDismiss = complete, - acceptText = "Ok", - declineText = null, - content = { - Text( - """Launchy is a launcher & mod installer provided by the MineInAbyss team. - You can launch the game by connecting your Microsoft account. - It comes bundled with a bunch of recommended mods for performance and quality of life. - You can change these settings later in the settings screen.""".trimIndent(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 10.dp), - color = MaterialTheme.colorScheme.onSurface, - ) - } - ) -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/ImportSettingsDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/ImportSettingsDialog.kt deleted file mode 100644 index c037e62..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/ImportSettingsDialog.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.Dirs -import com.mineinabyss.launchy.ui.elements.LaunchyDialog -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.screen -import kotlin.io.path.copyTo -import kotlin.io.path.div - -//TODO needs to be updated for multiple instances -@Composable -fun HandleImportSettings() { - val state = LocalLaunchyState - AnimatedVisibility( - !state.handledImportOptions && state.onboardingComplete, - enter = fadeIn(), exit = fadeOut(), - ) { - ImportSettingsDialog( - onAccept = { - try { - (Dirs.minecraft / "options.txt").copyTo(Dirs.mineinabyss / "options.txt") - } catch (e: Exception) { - // TODO: Show error message - e.printStackTrace() - } - screen = Screen.InstanceSettings - state.handledImportOptions = true - }, - onDecline = { - screen = Screen.InstanceSettings - state.handledImportOptions = true - } - ) - } -} - -@Composable -fun ImportSettingsDialog( - onAccept: () -> Unit, - onDecline: () -> Unit, -) { - LaunchyDialog( - title = { Text("Import Settings") }, - onAccept = onAccept, - onDecline = onDecline, - onDismiss = onDecline, - acceptText = "Import", - declineText = "Skip", - content = { - Text("This will import the options.txt file from your .minecraft directory.") - } - ) -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/UpdateInfoButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/UpdateInfoButton.kt deleted file mode 100644 index 1de3779..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/UpdateInfoButton.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main - -import androidx.compose.animation.* -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.Update -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState - -@Composable -fun UpdateInfoButton() { - val state = LocalLaunchyState - val packState = LocalGameInstanceState - var toggled by remember { mutableStateOf(false) } - Button(onClick = { toggled = !toggled }, shape = RoundedCornerShape(20.dp)) { - Column { - val queued = packState.queued - - Row { - Icon(Icons.Rounded.Update, contentDescription = "Updates") - Text("${queued.newDownloads.size + queued.deletions.size} Updates") - } - - AnimatedVisibility( - toggled, - enter = expandIn(tween(200)) + fadeIn(tween(200, 100)), - exit = fadeOut() + shrinkOut(tween(200, 100)) - ) { - Column { - InfoText( - shown = queued.areUpdatesQueued, - icon = Icons.Rounded.Update, - desc = "Update", - extra = queued.updates.size.toString() - ) - InfoText( - shown = queued.areNewDownloadsQueued, - icon = Icons.Rounded.Download, - desc = "Download", - extra = queued.newDownloads.size.toString() - ) - InfoText( - shown = queued.areDeletionsQueued, - icon = Icons.Rounded.Delete, - desc = "Remove", - extra = queued.deletions.size.toString() - ) - } - } - } - } -} - -@Composable -fun InfoText(shown: Boolean, icon: ImageVector, desc: String, extra: String = "") { - if (shown) Row(verticalAlignment = Alignment.CenterVertically) { - Icon(icon, desc) - Text(desc, Modifier.padding(5.dp)) - Text(extra) - } -} - - diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/AuthButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/AuthButton.kt deleted file mode 100644 index 2b23a68..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/AuthButton.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main.buttons - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Login -import androidx.compose.material3.* -import androidx.compose.runtime.* -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.logic.Auth -import com.mineinabyss.launchy.ui.elements.PrimaryButton -import kotlinx.coroutines.launch - -@Composable -fun AuthButton() { - val state = LocalLaunchyState - val coroutineScope = rememberCoroutineScope() - - PrimaryButton( - enabled = state.profile.currentSession == null, - onClick = { - coroutineScope.launch { - Auth.authOrShowDialog(state, state.profile) - } - }, - ) { - Icon(Icons.AutoMirrored.Rounded.Login, "Login") - Text("Login") - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/InstallButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/InstallButton.kt deleted file mode 100644 index f6f16e9..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/InstallButton.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main.buttons - -import androidx.compose.animation.* -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.logic.AppDispatchers -import com.mineinabyss.launchy.logic.ModDownloader.startInstall -import com.mineinabyss.launchy.ui.elements.OutlinedRedButton -import com.mineinabyss.launchy.ui.elements.PrimaryButton -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState -import kotlinx.coroutines.launch - -@Composable -fun RetryFailedButton(enabled: Boolean) { - val state = LocalLaunchyState - val packState = LocalGameInstanceState - OutlinedRedButton( - enabled = enabled, - onClick = { - AppDispatchers.profileLaunch.launch { - packState.startInstall(state, ignoreCachedCheck = true) - } - }, - ) { - Text("Retry ${packState.queued.failures.size} failed downloads") - } -} -@Composable -fun InstallButton(enabled: Boolean, modifier: Modifier = Modifier) { - val state = LocalLaunchyState - val packState = LocalGameInstanceState - PrimaryButton( - enabled = enabled, - onClick = { - AppDispatchers.profileLaunch.launch { - packState.startInstall(state, ignoreCachedCheck = true) - } - }, - modifier = modifier.width(150.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Rounded.Download, "Download") - val queued = packState.queued - AnimatedVisibility(true, Modifier.animateContentSize()) { - val isDownloading = packState.downloads.isDownloading - InstallTextAnimatedVisibility(queued.areOperationsQueued && !isDownloading) { - Text("Install") - } - InstallTextAnimatedVisibility(!queued.areOperationsQueued && !isDownloading) { - Text("Installed") - } - InstallTextAnimatedVisibility(isDownloading) { - Text("Installing") - } - } - } - } -} - -@Composable -fun InstallTextAnimatedVisibility(visible: Boolean, content: @Composable () -> Unit) { - AnimatedVisibility( - visible = visible, - enter = fadeIn(), - exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() - ) { - content() - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/PlayButton.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/PlayButton.kt deleted file mode 100644 index 2ce7973..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/buttons/PlayButton.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.main.buttons - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.PlayDisabled -import androidx.compose.material.icons.rounded.Stop -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.logic.AppDispatchers -import com.mineinabyss.launchy.logic.AppDispatchers.launchOrShowDialog -import com.mineinabyss.launchy.logic.Instances.updateInstance -import com.mineinabyss.launchy.logic.Launcher -import com.mineinabyss.launchy.logic.ModDownloader.startInstall -import com.mineinabyss.launchy.state.modpack.GameInstanceState -import com.mineinabyss.launchy.ui.elements.PrimaryButtonColors -import com.mineinabyss.launchy.ui.elements.SecondaryButtonColors -import com.mineinabyss.launchy.ui.screens.Dialog -import com.mineinabyss.launchy.ui.screens.dialog -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Composable -fun PlayButton( - hideText: Boolean = false, - instance: GameInstance, - modifier: Modifier = Modifier, - getModpackState: suspend () -> GameInstanceState?, -) { - val state = LocalLaunchyState - val process = state.processFor(instance) - val coroutineScope = rememberCoroutineScope() - val buttonIcon by remember(state.profile.currentProfile, process) { - mutableStateOf( - when { - state.profile.currentProfile == null -> Icons.Rounded.PlayDisabled - process == null -> Icons.Rounded.PlayArrow - else -> Icons.Rounded.Stop - } - ) - } - val buttonText by remember(process) { - mutableStateOf(if (process == null) "Play" else "Stop") - } - val buttonColors by mutableStateOf( - if (process == null) PrimaryButtonColors - else SecondaryButtonColors - ) - - Box { - var foundPackState: GameInstanceState? by remember { mutableStateOf(null) } - val onClick: () -> Unit = { - coroutineScope.launch(Dispatchers.IO) { - val packState = foundPackState ?: getModpackState() ?: return@launch - foundPackState = packState - val updatesAvailable = packState.instance.updatesAvailable - - if (process == null) { - when { - // Assume this means not launched before - packState.queued.userAgreedModLoaders == null -> { - AppDispatchers.profileLaunch.launchOrShowDialog { - packState.startInstall(state).getOrThrow() - Launcher.launch(state, packState, state.profile) - } - } - - updatesAvailable -> { - dialog = Dialog.Options( - title = "Update Available", - message = buildString { - appendLine("This cloud instance has updates available.") - appendLine("Would you like to download them now?") - }, - acceptText = "Download", - declineText = "Ignore", - onAccept = { packState.instance.updateInstance(state) }, - onDecline = { } - ) - } - - else -> { - AppDispatchers.profileLaunch.launchOrShowDialog { - packState.startInstall(state).getOrThrow() - println("Launching now!") - Launcher.launch(state, packState, state.profile) - } - } - } - } else { - process.destroyForcibly() - state.setProcessFor(packState.instance, null) - } - } - } - val enabled = state.profile.currentProfile != null - && foundPackState?.downloads?.isDownloading != true - && state.inProgressTasks.isEmpty() - - if (hideText) Button( - enabled = enabled, - onClick = onClick, - modifier = Modifier.size(52.dp).then(modifier), - contentPadding = PaddingValues(0.dp), - colors = buttonColors, - shape = MaterialTheme.shapes.medium - ) { - Icon(buttonIcon, buttonText) - } - else Button( - enabled = enabled, - onClick = onClick, - shape = RoundedCornerShape(20.dp), - colors = buttonColors - ) { - Icon(buttonIcon, buttonText) - Text(buttonText) - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InfoBar.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InfoBar.kt deleted file mode 100644 index 75e9d54..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InfoBar.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.settings - -import androidx.compose.animation.* -import androidx.compose.animation.core.animateIntAsState -import androidx.compose.foundation.TooltipArea -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.HistoryEdu -import androidx.compose.material.icons.rounded.Update -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.Constants -import com.mineinabyss.launchy.data.Constants.SETTINGS_HORIZONTAL_PADDING -import com.mineinabyss.launchy.ui.elements.Tooltip -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState -import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.InstallButton -import com.mineinabyss.launchy.ui.screens.modpack.main.buttons.RetryFailedButton - -object InfoBarProperties { - val height = 64.dp -} -@Composable -fun InfoBar(modifier: Modifier = Modifier) { - val state = LocalLaunchyState - val packState = LocalGameInstanceState - Surface( - tonalElevation = 2.dp, - shadowElevation = 0.dp, - shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp), - modifier = Modifier.fillMaxWidth().height(InfoBarProperties.height).then(modifier), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = SETTINGS_HORIZONTAL_PADDING, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - InstallButton( - state.processFor(packState.instance) == null - && !packState.downloads.isDownloading - && (packState.queued.areOperationsQueued || packState.queued.userAgreedModLoaders == null) - && state.inProgressTasks.isEmpty(), - Modifier.width(Constants.SETTINGS_PRIMARY_BUTTON_WIDTH) - ) - val failures = packState.queued.failures.isNotEmpty() - AnimatedVisibility(failures) { - RetryFailedButton(failures) - } - ActionButton( - shown = packState.queued.areModLoaderUpdatesAvailable, - icon = Icons.Rounded.HistoryEdu, - desc = "Mod loader updates:\n${packState.queued.userAgreedModLoaders?.fullVersionName ?: "Not installed"} -> ${packState.modpack.modLoaders.fullVersionName}", - count = 1 - ) - ActionButton( - shown = packState.queued.areUpdatesQueued, - icon = Icons.Rounded.Update, - desc = "Queued updates", - count = packState.queued.updates.size - ) - ActionButton( - shown = packState.queued.areNewDownloadsQueued, - icon = Icons.Rounded.Download, - desc = "Queued downloads for new mods", - count = packState.queued.newDownloads.size - ) - ActionButton( - shown = packState.queued.areDeletionsQueued, - icon = Icons.Rounded.Delete, - desc = "Queued mod deletions", - count = packState.queued.deletions.size - ) - } - } -} - -@Composable -fun ActionButton(shown: Boolean, icon: ImageVector, desc: String, count: Int? = null) { - AnimatedVisibility( - shown, - enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), - exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start) - ) { - Row { - TooltipArea(tooltip = { Tooltip(desc) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(icon, desc, modifier = Modifier.padding(end = 4.dp).alignByBaseline()) - if (count != null) { - val animatedCount by animateIntAsState(targetValue = count) - Text(animatedCount.toString(), modifier = Modifier.alignByBaseline()) - } - } - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InstanceSettingsScreen.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InstanceSettingsScreen.kt deleted file mode 100644 index 598a8b5..0000000 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/InstanceSettingsScreen.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.mineinabyss.launchy.ui.screens.modpack.settings - -import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.VerticalScrollbar -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.FileOpen -import androidx.compose.material.icons.rounded.Folder -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.mineinabyss.launchy.LocalLaunchyState -import com.mineinabyss.launchy.data.Constants.SETTINGS_HORIZONTAL_PADDING -import com.mineinabyss.launchy.data.modpacks.Group -import com.mineinabyss.launchy.data.modpacks.Mod -import com.mineinabyss.launchy.data.modpacks.ModConfig -import com.mineinabyss.launchy.logic.AppDispatchers -import com.mineinabyss.launchy.logic.DesktopHelpers -import com.mineinabyss.launchy.logic.Instances.delete -import com.mineinabyss.launchy.logic.Instances.updateInstance -import com.mineinabyss.launchy.logic.ModDownloader.checkHashes -import com.mineinabyss.launchy.state.InProgressTask -import com.mineinabyss.launchy.ui.elements.* -import com.mineinabyss.launchy.ui.screens.LocalGameInstanceState -import com.mineinabyss.launchy.ui.screens.Screen -import com.mineinabyss.launchy.ui.screens.screen -import kotlinx.coroutines.launch -import kotlin.io.path.listDirectoryEntries - -@Composable -@Preview -fun InstanceSettingsScreen() { - val state = LocalGameInstanceState - var selectedTabIndex by remember { mutableStateOf(0) } - ComfyWidth { - Column { - PrimaryTabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent) { - Tab( - text = { Text("Manage Mods") }, - selected = selectedTabIndex == 0, - onClick = { selectedTabIndex = 0 } - ) - Tab( - text = { Text("Options") }, - selected = selectedTabIndex == 1, - onClick = { selectedTabIndex = 1 } - ) - } - Box(Modifier.fillMaxSize()) { - AnimatedTab(selectedTabIndex == 0) { - ModManagement() - } - AnimatedTab(selectedTabIndex == 1) { - OptionsTab() - } - } - } - } -} - -@Composable -fun InstanceProperties( - minecraftDir: String, - onChangeMinecraftDir: (String) -> Unit -) { - var directoryPickerShown by remember { mutableStateOf(false) } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - DirectoryDialog( - directoryPickerShown, - title = "Choose your .minecraft directory", - fallbackTitle = "Choose a file in your .minecraft directory", - onCloseRequest = { - if (it != null) onChangeMinecraftDir(it.toString()) - directoryPickerShown = false - }, - ) - Column(Modifier.padding(start = 8.dp)) { - OutlinedTextField( - value = minecraftDir, - singleLine = true, - leadingIcon = { Icon(Icons.Rounded.Folder, contentDescription = "Directory") }, - trailingIcon = { - IconButton(onClick = { directoryPickerShown = true }) { - Icon(Icons.Rounded.FileOpen, contentDescription = "Choose") - } - }, - onValueChange = { onChangeMinecraftDir(it) }, - label = { Text(".minecraft directory") }, - modifier = Modifier.fillMaxWidth() - ) - } - } -} - -@Composable -fun OptionsTab() { - val state = LocalLaunchyState - val pack = LocalGameInstanceState - - ComfyContent(Modifier.padding(16.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - TitleSmall("Mods") - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { pack.instance.updateInstance(state) }) { - Text("Force update Instance") - } - OutlinedButton(onClick = { DesktopHelpers.openDirectory(pack.instance.minecraftDir) }) { - Text("Open .minecraft folder") - } - OutlinedButton(onClick = { - AppDispatchers.IO.launch { - state.runTask("checkHashes", InProgressTask("Checking hashes")) { - pack.checkHashes(pack.queued.modDownloadInfo).forEach { (modId, newInfo) -> - pack.queued.modDownloadInfo[modId] = newInfo - } - } - } - }) { - Text("Re-check hashes") - } - } - - TitleSmall("Danger zone") - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { - screen = Screen.Default - pack.instance.delete(state, deleteDotMinecraft = false) - }) { - Text("Delete Instance from config") - } - OutlinedButton( - onClick = { - screen = Screen.Default - pack.instance.delete(state, deleteDotMinecraft = true) - }, - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ) - ) { - Text("Delete Instance and its .minecraft") - } - } - } - } -} - -@Composable -fun ModManagement() { - val state = LocalGameInstanceState - Scaffold( - containerColor = Color.Transparent, - bottomBar = { InfoBar() }, - ) { paddingValues -> - val userMods by remember { - mutableStateOf( - state.instance.userMods.listDirectoryEntries("*.jar").map { - Mod( - downloadDir = it, - modId = it.fileName.toString(), - info = ModConfig(name = it.fileName.toString()), - desiredHashes = null - ) - } - ) - } - Box(Modifier.padding(paddingValues)) { - Box(Modifier.padding(horizontal = SETTINGS_HORIZONTAL_PADDING)) { - val lazyListState = rememberLazyListState() - LazyColumn(Modifier.fillMaxSize().padding(end = 12.dp), lazyListState) { - item { Spacer(Modifier.height(4.dp)) } - items(state.modpack.mods.modGroups.toList()) { (group, mods) -> - ModGroup(group, mods) - } - if (userMods.isNotEmpty()) item { - ModGroup(Group("User mods", forceEnabled = true), userMods) - } - } - VerticalScrollbar( - modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd).padding(vertical = 2.dp), - adapter = rememberScrollbarAdapter(lazyListState) - ) - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/GithubUpdateChecker.kt b/src/main/kotlin/com/mineinabyss/launchy/updater/data/GithubUpdateChecker.kt similarity index 91% rename from src/main/kotlin/com/mineinabyss/launchy/logic/GithubUpdateChecker.kt rename to src/main/kotlin/com/mineinabyss/launchy/updater/data/GithubUpdateChecker.kt index ba42475..dd27cfd 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/GithubUpdateChecker.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/updater/data/GithubUpdateChecker.kt @@ -1,7 +1,8 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.updater.data -import com.mineinabyss.launchy.data.Constants -import com.mineinabyss.launchy.data.Formats +import com.mineinabyss.launchy.core.ui.Constants +import com.mineinabyss.launchy.downloads.data.Downloader +import com.mineinabyss.launchy.util.Formats import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/AppDispatchers.kt b/src/main/kotlin/com/mineinabyss/launchy/util/AppDispatchers.kt similarity index 66% rename from src/main/kotlin/com/mineinabyss/launchy/logic/AppDispatchers.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/AppDispatchers.kt index 4ba4034..eb6985b 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/AppDispatchers.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/AppDispatchers.kt @@ -1,21 +1,17 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.util -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.screens.dialog import kotlinx.coroutines.* import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext object AppDispatchers { - @OptIn(ExperimentalCoroutinesApi::class) - val IOContext = Dispatchers.IO.limitedParallelism(10) - /** IO Dispatcher that won't get cancelled when a composable goes off screen. */ - val IO = CoroutineScope(IOContext) + val IO = Dispatchers.IO @OptIn(ExperimentalCoroutinesApi::class) - val profileLaunch = CoroutineScope(IOContext.limitedParallelism(1)) - + val profileLaunch = CoroutineScope(IO.limitedParallelism(1)) fun CoroutineScope.launchOrShowDialog( context: CoroutineContext = EmptyCoroutineContext, diff --git a/src/main/kotlin/com/mineinabyss/launchy/util/Arch.kt b/src/main/kotlin/com/mineinabyss/launchy/util/Arch.kt new file mode 100644 index 0000000..39e4483 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/util/Arch.kt @@ -0,0 +1,25 @@ +package com.mineinabyss.launchy.util + +sealed class Arch( + val openJDKArch: String +) { + data object X64 : Arch("x64") + data object X86 : Arch("x86") + data object ARM64 : Arch("aarch64") + data object ARM32 : Arch("arm") + data object Unknown : Arch("unknown") + + + companion object { + fun get(): Arch { + val archString = System.getProperty("os.arch", "unknown") + return when (archString) { + "amd64", "x86_64" -> X64 + "x86" -> X86 + "aarch64" -> ARM64 + "arm" -> ARM32 + else -> Unknown + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/DesktopHelpers.kt b/src/main/kotlin/com/mineinabyss/launchy/util/DesktopHelpers.kt similarity index 93% rename from src/main/kotlin/com/mineinabyss/launchy/logic/DesktopHelpers.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/DesktopHelpers.kt index 9715889..260a16f 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/DesktopHelpers.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/DesktopHelpers.kt @@ -1,6 +1,5 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.util -import com.mineinabyss.launchy.util.OS import java.awt.Desktop import java.net.URI import java.nio.file.Path diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt b/src/main/kotlin/com/mineinabyss/launchy/util/Dirs.kt similarity index 89% rename from src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/Dirs.kt index 6bcd506..b7c85d0 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/Dirs.kt @@ -1,7 +1,6 @@ -package com.mineinabyss.launchy.data +package com.mineinabyss.launchy.util -import com.mineinabyss.launchy.data.config.GameInstance -import com.mineinabyss.launchy.util.OS +import com.mineinabyss.launchy.instance.data.InstanceModel import java.util.* import kotlin.io.path.* @@ -26,7 +25,7 @@ object Dirs { OS.LINUX -> home / ".config" } / "mineinabyss" - fun cacheDir(instance: GameInstance) = instance.configDir / "cache" + fun cacheDir(instance: InstanceModel) = instance.directory / "cache" val imageCache = config / "cache" / "images" diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/Formats.kt b/src/main/kotlin/com/mineinabyss/launchy/util/Formats.kt similarity index 89% rename from src/main/kotlin/com/mineinabyss/launchy/data/Formats.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/Formats.kt index daa98fc..d4e45e9 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/Formats.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/Formats.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.data +package com.mineinabyss.launchy.util import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt b/src/main/kotlin/com/mineinabyss/launchy/util/Helpers.kt similarity index 55% rename from src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/Helpers.kt index 0ef9c0c..ca77d49 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/Helpers.kt @@ -1,11 +1,14 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.util -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.screens.dialog import java.util.* fun Result.showDialogOnError(title: String? = null): Result { - onFailure { dialog = Dialog.fromException(it, title) } + onFailure { + dialog = Dialog.fromException(it, title) + it.printStackTrace() + } return this } diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/InProgressTask.kt b/src/main/kotlin/com/mineinabyss/launchy/util/InProgressTask.kt similarity index 90% rename from src/main/kotlin/com/mineinabyss/launchy/state/InProgressTask.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/InProgressTask.kt index 50324c3..e714791 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/InProgressTask.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/InProgressTask.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.state +package com.mineinabyss.launchy.util open class InProgressTask(val name: String) { class WithPercentage( diff --git a/src/main/kotlin/com/mineinabyss/launchy/util/KoinViewModel.kt b/src/main/kotlin/com/mineinabyss/launchy/util/KoinViewModel.kt new file mode 100644 index 0000000..fb09018 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/util/KoinViewModel.kt @@ -0,0 +1,12 @@ +package com.mineinabyss.launchy.util + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import org.koin.compose.currentKoinScope + +@Composable +inline fun koinViewModel(): T { + val scope = currentKoinScope() + return viewModel { scope.get() } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/util/OS.kt b/src/main/kotlin/com/mineinabyss/launchy/util/OS.kt index 339ff7d..f294ce0 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/util/OS.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/OS.kt @@ -24,27 +24,3 @@ sealed class OS( } } -sealed class Arch( - val openJDKArch: String -) { - data object X64: Arch("x64") - data object X86: Arch("x86") - data object ARM64: Arch("aarch64") - data object ARM32: Arch("arm") - data object Unknown: Arch("unknown") - - - - companion object { - fun get(): Arch { - val archString = System.getProperty("os.arch", "unknown") - return when(archString) { - "amd64", "x86_64" -> X64 - "x86" -> X86 - "aarch64" -> ARM64 - "arm" -> ARM32 - else -> Unknown - } - } - } -} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Progress.kt b/src/main/kotlin/com/mineinabyss/launchy/util/Progress.kt similarity index 82% rename from src/main/kotlin/com/mineinabyss/launchy/logic/Progress.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/Progress.kt index 5f80636..cb87667 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Progress.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/Progress.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.util data class Progress(val bytesDownloaded: Long, val totalBytes: Long, val timeElapsed: Long) { val percent: Float diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/SuggestedJVMArgs.kt b/src/main/kotlin/com/mineinabyss/launchy/util/SuggestedJVMArgs.kt similarity index 97% rename from src/main/kotlin/com/mineinabyss/launchy/logic/SuggestedJVMArgs.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/SuggestedJVMArgs.kt index ccbe96d..9ce3a31 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/SuggestedJVMArgs.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/SuggestedJVMArgs.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.util /** * Arguments recommended by diff --git a/src/main/kotlin/com/mineinabyss/launchy/util/Typealiases.kt b/src/main/kotlin/com/mineinabyss/launchy/util/Typealiases.kt new file mode 100644 index 0000000..1c2cbb8 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/util/Typealiases.kt @@ -0,0 +1,15 @@ +package com.mineinabyss.launchy.util + +import kotlinx.serialization.Serializable + +typealias ModName = String +typealias GroupName = String +typealias DownloadURL = String +typealias ConfigURL = String + + +typealias ModID = String + +@Serializable +@JvmInline +value class InstanceKey(val key: String) diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/UpdateResult.kt b/src/main/kotlin/com/mineinabyss/launchy/util/UpdateResult.kt similarity index 80% rename from src/main/kotlin/com/mineinabyss/launchy/logic/UpdateResult.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/UpdateResult.kt index 9c1b01f..3bb1fd6 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/UpdateResult.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/UpdateResult.kt @@ -1,4 +1,6 @@ -package com.mineinabyss.launchy.logic +package com.mineinabyss.launchy.util + +import com.mineinabyss.launchy.downloads.data.Downloader sealed interface UpdateResult { val headers: Downloader.ModifyHeaders diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/hashing/Hashing.kt b/src/main/kotlin/com/mineinabyss/launchy/util/hashing/Hashing.kt similarity index 94% rename from src/main/kotlin/com/mineinabyss/launchy/logic/hashing/Hashing.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/hashing/Hashing.kt index 68db3b7..73689d8 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/hashing/Hashing.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/hashing/Hashing.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.logic.hashing +package com.mineinabyss.launchy.util.hashing import java.io.InputStream import java.nio.file.Path diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/serializers/UUIDSerializer.kt b/src/main/kotlin/com/mineinabyss/launchy/util/serializers/UUIDSerializer.kt similarity index 93% rename from src/main/kotlin/com/mineinabyss/launchy/data/serializers/UUIDSerializer.kt rename to src/main/kotlin/com/mineinabyss/launchy/util/serializers/UUIDSerializer.kt index ce9c323..8bd1c23 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/serializers/UUIDSerializer.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/util/serializers/UUIDSerializer.kt @@ -1,4 +1,4 @@ -package com.mineinabyss.launchy.data.serializers +package com.mineinabyss.launchy.util.serializers import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind