From b973bbd6596b99ebb45e23b0a1051a0c04d90aa7 Mon Sep 17 00:00:00 2001 From: Danielle Voznyy Date: Sat, 9 Mar 2024 15:57:14 -0500 Subject: [PATCH] Feat: Check file checksums if provided Feat: Download mods to separate file and symlink them, allowing a separate user mods directory Fix: Handle mod updates correctly Fix: Replace provider for avatars --- build.gradle.kts | 2 - gradle.properties | 2 +- .../mineinabyss/launchy/data/Typealiases.kt | 3 + .../launchy/data/config/GameInstance.kt | 33 ++- .../launchy/data/config/ModpackUserConfig.kt | 44 ++- .../launchy/data/modpacks/ExtraPackInfo.kt | 2 +- ...kDependencies.kt => InstanceModLoaders.kt} | 2 +- .../mineinabyss/launchy/data/modpacks/Mod.kt | 19 +- .../modpacks/{ModInfo.kt => ModConfig.kt} | 6 +- .../launchy/data/modpacks/Modpack.kt | 2 +- .../data/modpacks/formats/ExtraInfoFormat.kt | 4 +- .../modpacks/formats/LaunchyPackFormat.kt | 19 +- .../data/modpacks/formats/ModDownloadPath.kt | 32 +++ .../modpacks/formats/ModrinthPackFormat.kt | 29 +- .../data/modpacks/formats/PackFormat.kt | 6 +- .../data/modpacks/source/PackSource.kt | 6 +- .../launchy/logic/AppDispatchers.kt | 31 +++ .../mineinabyss/launchy/logic/Downloader.kt | 17 +- .../mineinabyss/launchy/logic/Instances.kt | 22 +- .../com/mineinabyss/launchy/logic/Launcher.kt | 25 +- .../launchy/logic/ModDownloader.kt | 259 ++++++++++-------- .../launchy/logic/SuggestedJVMArgs.kt | 4 +- .../com/mineinabyss/launchy/logic/Tasks.kt | 6 + .../launchy/logic/hashing/Hashing.kt | 25 ++ .../mineinabyss/launchy/state/LaunchyState.kt | 15 +- .../state/modpack/DownloadQueueState.kt | 51 +++- .../launchy/state/modpack/DownloadState.kt | 9 +- .../launchy/state/modpack/ModTogglesState.kt | 37 +-- .../launchy/state/modpack/ModpackState.kt | 6 +- .../launchy/ui/dialogs/SelectJVMDialog.kt | 3 +- .../launchy/ui/elements/Buttons.kt | 22 +- .../screens/home/newinstance/NewInstance.kt | 6 +- .../screens/modpack/main/MainScreenImages.kt | 1 - .../screens/modpack/main/UpdateInfoButton.kt | 8 +- .../modpack/main/buttons/InstallButton.kt | 23 +- .../modpack/main/buttons/PlayButton.kt | 22 +- .../ui/screens/modpack/settings/InfoBar.kt | 20 +- .../ui/screens/modpack/settings/ModGroup.kt | 4 +- .../modpack/settings/ModInfoDisplay.kt | 43 ++- 39 files changed, 546 insertions(+), 324 deletions(-) rename src/main/kotlin/com/mineinabyss/launchy/data/modpacks/{PackDependencies.kt => InstanceModLoaders.kt} (96%) rename src/main/kotlin/com/mineinabyss/launchy/data/modpacks/{ModInfo.kt => ModConfig.kt} (74%) create mode 100644 src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModDownloadPath.kt create mode 100644 src/main/kotlin/com/mineinabyss/launchy/logic/AppDispatchers.kt create mode 100644 src/main/kotlin/com/mineinabyss/launchy/logic/Tasks.kt create mode 100644 src/main/kotlin/com/mineinabyss/launchy/logic/hashing/Hashing.kt diff --git a/build.gradle.kts b/build.gradle.kts index e430883..0ad25bd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import de.undercouch.gradle.tasks.download.Download import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.compose.desktop.application.dsl.TargetFormat @@ -9,7 +8,6 @@ plugins { alias(idofrontLibs.plugins.kotlinx.serialization) alias(idofrontLibs.plugins.compose) id("de.undercouch.download") version "5.3.1" - id("com.github.johnrengelman.shadow") version "8.1.1" } repositories { diff --git a/gradle.properties b/gradle.properties index 8ad8d93..0b3aa29 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ group=com.mineinabyss -version=2.0.0-alpha.8 +version=2.0.0-alpha.9 idofrontVersion=0.22.3 diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/Typealiases.kt b/src/main/kotlin/com/mineinabyss/launchy/data/Typealiases.kt index 5862780..c90f8f6 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/Typealiases.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/Typealiases.kt @@ -4,3 +4,6 @@ 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/config/GameInstance.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt index a802a90..884a11c 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/GameInstance.kt @@ -6,6 +6,7 @@ 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 @@ -26,13 +27,12 @@ class GameInstance( val overridesDir = configDir / "overrides" - init { - require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" } - } - - 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") val updateCheckerScope = CoroutineScope(Dispatchers.IO) @@ -43,17 +43,17 @@ class GameInstance( suspend fun createModpackState(state: LaunchyState): ModpackState? { val userConfig = ModpackUserConfig.load(userConfigFile).getOrNull() ?: ModpackUserConfig() - state.inProgressTasks["loadingModpack"] = InProgressTask("Loading modpack ${config.name}") - val modpack = config.source.loadInstance(this) - .showDialogOnError("Failed to read instance") - .getOrElse { - it.printStackTrace() - return null - } - state.inProgressTasks.remove("loadingModpack") + 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) state.ioScope.launch { + if (cloudUrl != null) AppDispatchers.IO.launch { val updates = Downloader.checkUpdates(cloudUrl) if (updates.result != UpdateResult.UpToDate) { updatesAvailable = true @@ -62,6 +62,11 @@ class GameInstance( return ModpackState(this, modpack, userConfig) } + init { + require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" } + userMods + } + companion object { fun create(state: LaunchyState, config: GameInstanceConfig) { val instanceDir = Dirs.modpackConfigDir(config.name) diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt b/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt index f11d03c..2fe9939 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/config/ModpackUserConfig.kt @@ -1,23 +1,57 @@ package com.mineinabyss.launchy.data.config import com.charleskorn.kaml.decodeFromStream -import com.mineinabyss.launchy.data.* -import com.mineinabyss.launchy.data.modpacks.PackDependencies +import com.mineinabyss.launchy.data.Formats +import com.mineinabyss.launchy.data.GroupName +import com.mineinabyss.launchy.data.ModID +import com.mineinabyss.launchy.data.ModName +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 ModpackUserConfig( - val userAgreedDeps: PackDependencies? = null, + 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 modDownloads: Map = mapOf(), - val modConfigs: Map = mapOf(), + val modDownloadInfo: Map = mapOf(), +// val configDownloadInfo: Map = mapOf(), val downloadUpdates: Boolean = true, ) { fun save(file: Path) { diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ExtraPackInfo.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ExtraPackInfo.kt index 9f215e5..e96c0f2 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ExtraPackInfo.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ExtraPackInfo.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable @Serializable class ModReference( val urlContains: String, - val info: ModInfo? = null, + val info: ModConfig? = null, ) @Serializable class ExtraPackInfo( diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/PackDependencies.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/InstanceModLoaders.kt similarity index 96% rename from src/main/kotlin/com/mineinabyss/launchy/data/modpacks/PackDependencies.kt rename to src/main/kotlin/com/mineinabyss/launchy/data/modpacks/InstanceModLoaders.kt index 293dcc9..84e50b8 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/PackDependencies.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/InstanceModLoaders.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable -data class PackDependencies( +data class InstanceModLoaders( val minecraft: String, @SerialName("fabric-loader") val fabricLoader: String? = null, diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mod.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mod.kt index 0a5ca7e..73cdc2c 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mod.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Mod.kt @@ -1,21 +1,24 @@ 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 -import kotlin.io.path.exists data class Mod( - val packDir: Path, - val info: ModInfo + private val downloadDir: Path, + val info: ModConfig, + val modId: String, + val desiredHashes: ModrinthPackFormat.Hashes?, ) { - val file = - if (info.downloadPath != null) packDir / info.downloadPath - else packDir / "mods" / "${info.name}.jar" + val absoluteDownloadDest = + if (info.downloadPath != null) downloadDir / info.downloadPath.validated + else downloadDir / "mods" / "${info.id ?: info.name}.jar" - val config = Dirs.tmp / "${info.name}-config.zip" + val downloadUrl: Url = Url(info.url) - val isDownloaded get() = file.exists() + val config = Dirs.tmp / "${info.name}-config.zip" fun compatibleWith(other: Mod) = diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModInfo.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModConfig.kt similarity index 74% rename from src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModInfo.kt rename to src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModConfig.kt index 9f0c047..9057ae2 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModInfo.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/ModConfig.kt @@ -1,9 +1,11 @@ package com.mineinabyss.launchy.data.modpacks +import com.mineinabyss.launchy.data.modpacks.formats.ModDownloadPath import kotlinx.serialization.Serializable @Serializable -data class ModInfo( +data class ModConfig( + val id: String? = null, val name: String, val license: String = "", val homepage: String = "", @@ -14,6 +16,6 @@ data class ModInfo( val forceConfigDownload: Boolean = false, val dependency: Boolean = false, val incompatibleWith: List = emptyList(), - val downloadPath: String? = null, + val downloadPath: ModDownloadPath? = null, val requires: List = emptyList(), ) diff --git a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Modpack.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Modpack.kt index e5f3621..2b3f87a 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Modpack.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/Modpack.kt @@ -3,7 +3,7 @@ package com.mineinabyss.launchy.data.modpacks import java.nio.file.Path class Modpack( - val dependencies: PackDependencies, + val modLoaders: InstanceModLoaders, val mods: Mods, val overridesPaths: List = listOf(), ) 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 index 5bf1af9..3ff04cf 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ExtraInfoFormat.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ExtraInfoFormat.kt @@ -11,8 +11,8 @@ data class ExtraInfoFormat( val format: PackFormat, val extraInfoPack: ExtraPackInfo, ) : PackFormat by format { - override fun toGenericMods(minecraftDir: Path): Mods { - val originalMods = format.toGenericMods(minecraftDir) + 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 } } 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 index f3305c2..94dfa6a 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/LaunchyPackFormat.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/LaunchyPackFormat.kt @@ -10,16 +10,25 @@ data class LaunchyPackFormat( val fabricVersion: String? = null, val minecraftVersion: String, val groups: Set, - private val modGroups: Map>, + private val modGroups: Map>, ) : PackFormat { - override fun toGenericMods(minecraftDir: Path): Mods { + override fun toGenericMods(downloadsDir: Path): Mods { return Mods(modGroups .mapKeys { (name, _) -> groups.single { it.name == name } } - .mapValues { (_, mods) -> mods.map { Mod(minecraftDir, it) }.toSet() }) + .mapValues { (_, mods) -> + mods.map { + Mod( + downloadDir = downloadsDir, + info = it, + modId = it.id ?: it.name, + desiredHashes = null, + ) + }.toSet() + }) } - override fun getDependencies(minecraftDir: Path): PackDependencies { - return PackDependencies(minecraft = minecraftVersion, fabricLoader = fabricVersion) + 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/ModDownloadPath.kt b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModDownloadPath.kt new file mode 100644 index 0000000..2b421fc --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModDownloadPath.kt @@ -0,0 +1,32 @@ +package com.mineinabyss.launchy.data.modpacks.formats + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.nio.file.Path +import kotlin.io.path.Path + +@Serializable(with = ValidModPathSerializer::class) +class ModDownloadPath( + val validated: Path +) + +object ValidModPathSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("modPath", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): ModDownloadPath { + val pathString = decoder.decodeString() + if (pathString.contains("..")) error("Mod path cannot contain ..") + val path = Path(pathString) + if (path.isAbsolute) error("Path cannot be absolute") + return ModDownloadPath(path) + } + + override fun serialize(encoder: Encoder, value: ModDownloadPath) { + encoder.encodeString(value.validated.toString()) + } + +} 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 index 051285b..f711224 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModrinthPackFormat.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/ModrinthPackFormat.kt @@ -1,16 +1,16 @@ 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.ModInfo +import com.mineinabyss.launchy.data.modpacks.ModConfig import com.mineinabyss.launchy.data.modpacks.Mods -import com.mineinabyss.launchy.data.modpacks.PackDependencies import kotlinx.serialization.Serializable import java.nio.file.Path import kotlin.io.path.div @Serializable data class ModrinthPackFormat( - val dependencies: PackDependencies, + val dependencies: InstanceModLoaders, val files: List, val formatVersion: Int, val name: String, @@ -20,25 +20,34 @@ data class ModrinthPackFormat( data class PackFile( val downloads: List, val fileSize: Long, - val path: String, + val path: ModDownloadPath, + val hashes: Hashes, ) { fun toMod(packDir: Path) = Mod( packDir, - ModInfo( - name = path.removePrefix("mods/").removeSuffix(".jar"), + 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, ) } - override fun getDependencies(minecraftDir: Path): PackDependencies { + @Serializable + data class Hashes( + val sha1: String, + val sha512: String, + ) + + override fun getModLoaders(): InstanceModLoaders { return dependencies } - override fun toGenericMods(minecraftDir: Path) = - Mods.withSingleGroup(files.map { it.toMod(minecraftDir) }) + 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 index 9cc86cd..3d723a1 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/PackFormat.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/formats/PackFormat.kt @@ -1,13 +1,13 @@ package com.mineinabyss.launchy.data.modpacks.formats +import com.mineinabyss.launchy.data.modpacks.InstanceModLoaders import com.mineinabyss.launchy.data.modpacks.Mods -import com.mineinabyss.launchy.data.modpacks.PackDependencies import java.nio.file.Path sealed interface PackFormat { - fun toGenericMods(minecraftDir: Path): Mods + fun toGenericMods(downloadsDir: Path): Mods - fun getDependencies(minecraftDir: Path): PackDependencies + 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 index ae2d1c4..1d43b77 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/data/modpacks/source/PackSource.kt @@ -15,9 +15,9 @@ sealed class PackSource { 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.minecraftDir) - val dependencies = format.getDependencies(instance.minecraftDir) - Modpack(dependencies, mods, format.getOverridesPaths(instance.configDir)) + val mods = format.toGenericMods(instance.downloadsDir) + val modLoaders = format.getModLoaders() + Modpack(modLoaders, mods, format.getOverridesPaths(instance.configDir)) } override suspend fun updateInstance(instance: GameInstance): Result { diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/AppDispatchers.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/AppDispatchers.kt new file mode 100644 index 0000000..4ba4034 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/AppDispatchers.kt @@ -0,0 +1,31 @@ +package com.mineinabyss.launchy.logic + +import com.mineinabyss.launchy.ui.screens.Dialog +import com.mineinabyss.launchy.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) + + @OptIn(ExperimentalCoroutinesApi::class) + val profileLaunch = CoroutineScope(IOContext.limitedParallelism(1)) + + + fun CoroutineScope.launchOrShowDialog( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Job { + return launch(context + SupervisorJob(), start, block).apply { + invokeOnCompletion { + if (it != null) dialog = Dialog.fromException(it) + } + } + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt index 85d5ddb..074bccb 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt @@ -27,7 +27,7 @@ object Downloader { } suspend fun downloadAvatar(uuid: UUID) { - download("https://crafatar.com/avatars/$uuid?size=16&overlay", Dirs.avatar(uuid)) + download("https://mc-heads.net/avatar/$uuid", Dirs.avatar(uuid)) } class CacheInfo(val result: UpdateResult, val cacheKey: String, val cacheFile: Path) @@ -51,6 +51,7 @@ object Downloader { url: String, writeTo: Path, override: Boolean = true, + skipDownloadIfCached: Boolean = true, whenChanged: () -> Unit = {}, onProgressUpdate: (progress: Progress) -> Unit = {}, ): Result { @@ -58,12 +59,14 @@ object Downloader { if (!override && writeTo.exists()) return@runCatching val startTime = System.currentTimeMillis() writeTo.createParentDirectories() - val updates = checkUpdates(url) - if (writeTo.exists() && updates.result == UpdateResult.UpToDate) return@runCatching - updates.cacheFile.apply { - createParentDirectories() - deleteIfExists() - createFile().writeText(updates.cacheKey) + if (skipDownloadIfCached) { + val updates = checkUpdates(url) + if (writeTo.exists() && updates.result == UpdateResult.UpToDate) return@runCatching + updates.cacheFile.apply { + createParentDirectories() + deleteIfExists() + createFile().writeText(updates.cacheKey) + } } httpClient.prepareGet(url) { diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt index 80e3a6f..7e17cbe 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt @@ -13,15 +13,12 @@ import kotlin.io.path.* object Instances { @OptIn(ExperimentalPathApi::class) fun GameInstance.delete(state: LaunchyState, deleteDotMinecraft: Boolean) { - try { - state.inProgressTasks["deleteInstance"] = InProgressTask("Deleting instance ${config.name}") - state.gameInstances.remove(this) - state.ioScope.launch { + state.gameInstances.remove(this) + state.runTask("deleteInstance", InProgressTask("Deleting instance ${config.name}")) { + AppDispatchers.IO.launch { if (deleteDotMinecraft) minecraftDir.deleteRecursively() configDir.deleteRecursively() } - } finally { - state.inProgressTasks.remove("deleteInstance") } } @@ -29,12 +26,11 @@ object Instances { state: LaunchyState, onSuccess: () -> Unit = {}, ) { - state.inProgressTasks["updateInstance"] = InProgressTask("Updating instance: ${config.name}") - try { - screen = Screen.Default - enabled = false - val index = state.gameInstances.indexOf(this) - state.ioScope.launch { + 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 newCloudInstance = Dirs.tmpCloudInstance(cloudUrl) @@ -65,8 +61,6 @@ object Instances { onSuccess() } } - } finally { - state.inProgressTasks.remove("updateInstance") } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Launcher.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Launcher.kt index b064f3f..9e8c33e 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/Launcher.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Launcher.kt @@ -1,6 +1,6 @@ package com.mineinabyss.launchy.logic -import com.mineinabyss.launchy.data.modpacks.PackDependencies +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.ModpackState @@ -23,11 +23,9 @@ import org.to2mbn.jmccc.option.JavaEnvironment import org.to2mbn.jmccc.option.LaunchOption import org.to2mbn.jmccc.option.MinecraftDirectory import org.to2mbn.jmccc.version.Version -import java.io.File import java.nio.file.Path import java.util.* import kotlin.io.path.createParentDirectories -import kotlin.io.path.exists import kotlin.io.path.notExists @@ -36,7 +34,7 @@ object Launcher { val dir = MinecraftDirectory(pack.instance.minecraftDir.toFile()) val launcher = LauncherBuilder.buildDefault() val javaPath = state.jvm.javaPath - if(javaPath == null || javaPath.notExists()) { + if (javaPath == null || javaPath.notExists()) { dialog = Dialog.ChooseJVMPath return@coroutineScope } @@ -48,7 +46,7 @@ object Launcher { else -> state.setProcessFor(pack.instance, launcher.launch( LaunchOption( - pack.modpack.dependencies.fullVersionName, + pack.modpack.modLoaders.fullVersionName, Authenticator { return@Authenticator AuthInfo( session.mcProfile.name, @@ -67,7 +65,7 @@ object Launcher { extraJvmArguments().addAll(state.jvm.jvmArgs.split(" ")) javaEnvironment = JavaEnvironment(javaPath.toFile()) }, - object: ProcessListener { + object : ProcessListener { override fun onLog(p0: String?) { System.out.println(p0) } @@ -87,17 +85,17 @@ object Launcher { } fun download( - deps: PackDependencies, + modLoaders: InstanceModLoaders, minecraftDir: Path, - onStartDownload: (String) -> Unit, - onFinishDownload: (String) -> Unit - ): Job { + onStartDownload: (String) -> Unit = {}, + onFinishDownload: (String) -> Unit = {} + ): Job = AppDispatchers.IO.launch { val downloadJob = Job() minecraftDir.createParentDirectories() val dir = MinecraftDirectory(minecraftDir.toFile()) val downloader = when { - deps.fabricLoader != null -> fabricDownloader() + modLoaders.fabricLoader != null -> fabricDownloader() else -> vanillaDownloader() } val callback = object : CallbackAdapter() { @@ -114,12 +112,11 @@ object Launcher { } override fun taskStart(task: DownloadTask?): DownloadCallback? { - onStartDownload(deps.fullVersionName) + onStartDownload(modLoaders.fullVersionName) return null } } - downloader.downloadIncrementally(dir, deps.fullVersionName, callback) - return downloadJob + downloader.downloadIncrementally(dir, modLoaders.fullVersionName, callback) } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt index 7eea68d..0cbb3d6 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/ModDownloader.kt @@ -1,123 +1,134 @@ package com.mineinabyss.launchy.logic +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.data.modpacks.PackDependencies import com.mineinabyss.launchy.state.InProgressTask import com.mineinabyss.launchy.state.LaunchyState import com.mineinabyss.launchy.state.modpack.ModpackState -import kotlinx.coroutines.* -import org.rauschig.jarchivelib.ArchiverFactory +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.Serializable import java.util.concurrent.CancellationException -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.copyToRecursively -import kotlin.io.path.deleteIfExists -import kotlin.io.path.extension +import kotlin.io.path.* object ModDownloader { - val installModLoadersId = "installMCAndModLoaders" - val copyOverridesId = "copyOverrides" - suspend fun ModpackState.installMCAndModLoaders(state: LaunchyState, dependencies: PackDependencies) { - try { - downloads.installingProfile = true + + suspend fun ModpackState.installMCAndModLoaders(state: LaunchyState, modLoaders: InstanceModLoaders) { + state.runTask(Tasks.installModLoadersId, InProgressTask("Installing ${modLoaders.fabricLoader}")) { Launcher.download( - dependencies, + modLoaders, instance.minecraftDir, - onStartDownload = { - state.inProgressTasks[installModLoadersId] = InProgressTask("Installing $it") - }, onFinishDownload = { println("Finished installing: $it") }, ).join() - } finally { - state.inProgressTasks.remove(installModLoadersId) - downloads.installingProfile = false } } - suspend fun ModpackState.download(state: LaunchyState, mod: Mod) { - val name = mod.info.name - val taskKey = "modDownload${mod.info.url}" - runCatching { - if (mod !in toggles.upToDateMods) { - try { - println("Starting download of $name") - downloads.inProgressMods[mod] = Progress(0, 0, 0) // set progress to 0 - Downloader.download(url = mod.info.url, writeTo = mod.file) progress@{ - downloads.inProgressMods[mod] = it - state.inProgressTasks[taskKey] = InProgressTask.bytes( - "Downloading $name", it.bytesDownloaded, it.totalBytes - ) - } - toggles.downloadURLs[mod] = mod.info.url - saveToConfig() - println("Successfully downloaded $name") - } catch (ex: CancellationException) { - throw ex // Must let the CancellationException propagate - } catch (e: Exception) { - println("Failed to download $name") - e.printStackTrace() - downloads.failed += mod - } finally { - println("Finished download of $name") - downloads.inProgressMods -= mod - } - } + @Serializable + sealed interface DownloadResult { + @Serializable + data object Success : DownloadResult - if (mod.info.configUrl.isNotBlank() && (mod in toggles.enabledConfigs) && mod !in toggles.upToDateConfigs) { - try { - println("Starting download of $name config") - downloads.inProgressConfigs[mod] = Progress(0, 0, 0) // set progress to 0 - val config = mod.config - Downloader.download(url = mod.info.configUrl, writeTo = config) { - downloads.inProgressConfigs[mod] = it - } - toggles.downloadConfigURLs[mod] = mod.info.configUrl - ArchiverFactory.createArchiver(config.extension) - .extract(config.toFile(), instance.overridesDir.toFile()) - config.deleteIfExists() - saveToConfig() - println("Successfully downloaded $name config") - } catch (ex: CancellationException) { - throw ex // Must let the CancellationException propagate - } catch (e: Exception) { - println("Failed to download $name config") - downloads.failed += mod - e.printStackTrace() - } finally { - println("Finished download of $name config") - downloads.inProgressConfigs -= mod - } - } - }.onFailure { - if (it !is CancellationException) { - it.printStackTrace() + @Serializable + data object Failed : DownloadResult + } + + suspend fun ModpackState.download(mod: Mod, ignoreCachedCheck: Boolean): DownloadResult { + val name = mod.info.name + try { + println("Starting download of $name") + downloads.inProgressMods[mod] = Progress(0, 0, 0) // set progress to 0 + Downloader.download( + url = mod.info.url, + writeTo = mod.absoluteDownloadDest, + skipDownloadIfCached = !ignoreCachedCheck + ) progress@{ + downloads.inProgressMods[mod] = it } -// Badge { -// Text("Failed to download ${mod.name}: ${it.localizedMessage}!"/*, "OK"*/) -// } -// scaffoldState.snackbarHostState.showSnackbar( -// "Failed to download ${mod.name}: ${it.localizedMessage}!", "OK" -// ) + return DownloadResult.Success + } catch (ex: CancellationException) { + throw ex // Must let the CancellationException propagate + } catch (e: Exception) { + println("Failed to download $name") + e.printStackTrace() + return DownloadResult.Failed + } finally { + println("Finished download of $name") + downloads.inProgressMods -= mod } - state.inProgressTasks.remove(taskKey) } +// if (mod.info.configUrl.isNotBlank() && (mod in toggles.enabledConfigs) && mod !in toggles.upToDateConfigs) { +// try { +// println("Starting download of $name config") +// downloads.inProgressConfigs[mod] = Progress(0, 0, 0) // set progress to 0 +// val config = mod.config +// Downloader.download(url = mod.info.configUrl, writeTo = config) { +// downloads.inProgressConfigs[mod] = it +// } +// toggles.downloadConfigURLs[mod] = mod.info.configUrl +// ArchiverFactory.createArchiver(config.extension) +// .extract(config.toFile(), instance.overridesDir.toFile()) +// config.deleteIfExists() +// saveToConfig() +// println("Successfully downloaded $name config") +// } catch (ex: CancellationException) { +// throw ex // Must let the CancellationException propagate +// } catch (e: Exception) { +// println("Failed to download $name config") +// downloads.failed += mod +// e.printStackTrace() +// } finally { +// println("Finished download of $name config") +// downloads.inProgressConfigs -= mod +// } +// } + /** * Ensures dependencies the user definitely wants are installed, * does not install any mod updates or new dep versions if they changed in the modpack. * Primarily the mod loader/minecraft version. */ - suspend fun ModpackState.ensureCurrentDepsInstalled(state: LaunchyState) { + suspend fun ModpackState.ensureDependenciesReady(state: LaunchyState) = coroutineScope { val currentDeps = userAgreedDeps if (currentDeps == null) { - userAgreedDeps = modpack.dependencies + userAgreedDeps = modpack.modLoaders + } + installMCAndModLoaders(state, currentDeps ?: modpack.modLoaders) + } + + fun ModpackState.copyMods() { + // Clear mods folder + val existingEntries = instance.modsDir.useDirectoryEntries { files -> + files.filter { it.isSymbolicLink() || it.isRegularFile() }.toList() } - installMCAndModLoaders(state, currentDeps ?: modpack.dependencies) + + val userMods = instance.userMods.listDirectoryEntries("*.jar") + .map { it.absolute() to Path("mods") / it.relativeTo(instance.userMods) } + val downloadedMods = toggles.enabledMods + .map { it.absoluteDownloadDest to it.absoluteDownloadDest.relativeTo(instance.downloadsDir) } + val linked = (downloadedMods + userMods) + .map { (absolute, relative) -> + val linkDest = (instance.minecraftDir / relative) + if (!linkDest.isSymbolicLink()) linkDest.deleteIfExists() + if (linkDest.notExists()) + linkDest.createSymbolicLinkPointingTo(absolute.relativeTo(linkDest.parent)) + linkDest + } + .toSet() + (existingEntries - linked).forEach { it.deleteIfExists() } + } + + suspend fun ModpackState.prepareWithoutChangingInstalledMods(state: LaunchyState) { + ensureDependenciesReady(state) + copyMods() } @OptIn(ExperimentalPathApi::class) fun ModpackState.copyOverrides(state: LaunchyState) { - try { - state.inProgressTasks[copyOverridesId] = InProgressTask("Copying overrides") + state.runTask(Tasks.copyOverridesId, InProgressTask("Copying overrides")) { modpack.overridesPaths.forEach { it.copyToRecursively( target = instance.minecraftDir, @@ -125,43 +136,59 @@ object ModDownloader { overwrite = true, ) } - } finally { - state.inProgressTasks.remove(copyOverridesId) } } /** * Updates mod loader versions and mods to latest modpack definition. */ - suspend fun ModpackState.install(state: LaunchyState): Job = coroutineScope { - launch { - userAgreedDeps = modpack.dependencies - runCatching { ensureCurrentDepsInstalled(state) }.getOrShowDialog() ?: return@launch - runCatching { copyOverrides(state) }.getOrShowDialog() ?: return@launch - toggles.checkNonDownloadedMods() - val modDownloads = launch { - queued.downloads.map { mod -> - state.ioScope.launch { - download(state, mod) - toggles.checkNonDownloadedMods() - } - }.joinAll() + suspend fun ModpackState.startInstall(state: LaunchyState, ignoreCachedCheck: Boolean = false) = coroutineScope { + userAgreedDeps = modpack.modLoaders + ensureDependenciesReady(state) + copyOverrides(state) + val downloads = queued.needsInstall.map { mod -> + async(AppDispatchers.IOContext) { + mod to download(mod, ignoreCachedCheck) } - val modDeletions = launch { - queued.deletions.map { mod -> - launch(Dispatchers.IO) { - try { - mod.file.deleteIfExists() - } catch (e: FileSystemException) { - return@launch - } finally { - queued.deleted++ + }.awaitAll() + + downloads.forEach { (mod, result) -> + queued.modDownloadInfo[mod.modId] = DownloadInfo( + url = mod.downloadUrl.toString(), + path = mod.absoluteDownloadDest.relativeTo(instance.minecraftDir).toString(), + desiredHash = mod.desiredHashes?.sha1, + hashCheck = HashCheck.UNKNOWN, + result = result + ) + } + + // Check hashes + val updatedHashes = queued.modDownloadInfo + .filterValues { it.hashCheck == HashCheck.UNKNOWN || it.hashCheck == HashCheck.FAILED } + .map { (modId, info) -> + async(AppDispatchers.IOContext) { + val check = runCatching { info.calculateSha1Hash(instance.minecraftDir) }.getOrNull() + modId to info.copy( + hashCheck = when { + check == info.desiredHash -> HashCheck.VERIFIED + else -> HashCheck.FAILED } - } - }.joinAll() - } - modDownloads.join() - modDeletions.join() + ) + } + }.awaitAll() + + updatedHashes.forEach { (modId, newInfo) -> + queued.modDownloadInfo[modId] = newInfo } + + saveToConfig() + + if (queued.modDownloadInfo.any { it.value.hashCheck != HashCheck.VERIFIED }) { + error("Hash check failed on one or more downloads downloads, please re-run the installer!") + } + + copyMods() + + saveToConfig() } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/SuggestedJVMArgs.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/SuggestedJVMArgs.kt index dc5a8bb..ccbe96d 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/logic/SuggestedJVMArgs.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/SuggestedJVMArgs.kt @@ -7,9 +7,9 @@ package com.mineinabyss.launchy.logic object SuggestedJVMArgs { val memory: Int = 2048 val baseFlags = - "-XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -XX:+AlwaysActAsServerClassMachine -XX:+AlwaysPreTouch -XX:+DisableExplicitGC -XX:+UseNUMA -XX:NmethodSweepActivity=1 -XX:ReservedCodeCacheSize=400M -XX:NonNMethodCodeHeapSize=12M -XX:ProfiledCodeHeapSize=194M -XX:NonProfiledCodeHeapSize=194M -XX:-DontCompileHugeMethods -XX:MaxNodeLimit=240000 -XX:NodeLimitFudgeFactor=8000 -XX:+UseVectorCmov -XX:+PerfDisableSharedMem -XX:+UseFastUnorderedTimeStamps -XX:+UseCriticalJavaThreadPriority -XX:ThreadPriorityPolicy=1 -XX:AllocatePrefetchStyle=3" + "-XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -XX:+AlwaysActAsServerClassMachine -XX:+AlwaysPreTouch -XX:+DisableExplicitGC -XX:+UseNUMA -XX:NmethodSweepActivity=1 -XX:ReservedCodeCacheSize=400M -XX:NonNMethodCodeHeapSize=12M -XX:ProfiledCodeHeapSize=194M -XX:NonProfiledCodeHeapSize=194M -XX:-DontCompileHugeMethods -XX:MaxNodeLimit=240000 -XX:NodeLimitFudgeFactor=8000 -XX:+UseVectorCmov -XX:+PerfDisableSharedMem -XX:+UseFastUnorderedTimeStamps -XX:AllocatePrefetchStyle=3" val graalVMBaseFlags = - "-XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -XX:+AlwaysActAsServerClassMachine -XX:+AlwaysPreTouch -XX:+DisableExplicitGC -XX:+UseNUMA -XX:AllocatePrefetchStyle=3 -XX:NmethodSweepActivity=1 -XX:ReservedCodeCacheSize=400M -XX:NonNMethodCodeHeapSize=12M -XX:ProfiledCodeHeapSize=194M -XX:NonProfiledCodeHeapSize=194M -XX:-DontCompileHugeMethods -XX:+PerfDisableSharedMem -XX:+UseFastUnorderedTimeStamps -XX:+UseCriticalJavaThreadPriority -XX:+EagerJVMCI -Dgraal.TuneInlinerExploration=1 -Dgraal.CompilerConfiguration=enterprise" + "-XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -XX:+AlwaysActAsServerClassMachine -XX:+AlwaysPreTouch -XX:+DisableExplicitGC -XX:+UseNUMA -XX:AllocatePrefetchStyle=3 -XX:NmethodSweepActivity=1 -XX:ReservedCodeCacheSize=400M -XX:NonNMethodCodeHeapSize=12M -XX:ProfiledCodeHeapSize=194M -XX:NonProfiledCodeHeapSize=194M -XX:-DontCompileHugeMethods -XX:+PerfDisableSharedMem -XX:+UseFastUnorderedTimeStamps -XX:+EagerJVMCI -Dgraal.TuneInlinerExploration=1 -Dgraal.CompilerConfiguration=enterprise" val clientG1GC = "-XX:+UseG1GC -XX:MaxGCPauseMillis=37 -XX:+PerfDisableSharedMem -XX:G1HeapRegionSize=16M -XX:G1NewSizePercent=23 -XX:G1ReservePercent=20 -XX:SurvivorRatio=32 -XX:G1MixedGCCountTarget=3 -XX:G1HeapWastePercent=20 -XX:InitiatingHeapOccupancyPercent=10 -XX:G1RSetUpdatingPauseTimePercent=0 -XX:MaxTenuringThreshold=1 -XX:G1SATBBufferEnqueueingThresholdPercent=30 -XX:G1ConcMarkStepDurationMillis=5.0 -XX:G1ConcRSHotCardLimit=16 -XX:G1ConcRefinementServiceIntervalMillis=150 -XX:GCTimeRatio=99" } diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/Tasks.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/Tasks.kt new file mode 100644 index 0000000..5761c81 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/Tasks.kt @@ -0,0 +1,6 @@ +package com.mineinabyss.launchy.logic + +object Tasks { + val installModLoadersId = "installMCAndModLoaders" + val copyOverridesId = "copyOverrides" +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/logic/hashing/Hashing.kt b/src/main/kotlin/com/mineinabyss/launchy/logic/hashing/Hashing.kt new file mode 100644 index 0000000..68db3b7 --- /dev/null +++ b/src/main/kotlin/com/mineinabyss/launchy/logic/hashing/Hashing.kt @@ -0,0 +1,25 @@ +package com.mineinabyss.launchy.logic.hashing + +import java.io.InputStream +import java.nio.file.Path +import java.security.MessageDigest +import kotlin.io.path.inputStream + +object Hashing { + const val STREAM_BUFFER_LENGTH = 1024 + + @OptIn(ExperimentalStdlibApi::class) + fun Path.checksum(digest: MessageDigest): String = inputStream().use { stream -> + updateDigest(digest, stream).digest().toHexString() + } + + private fun updateDigest(digest: MessageDigest, data: InputStream): MessageDigest { + val buffer = ByteArray(STREAM_BUFFER_LENGTH) + var read = data.read(buffer, 0, STREAM_BUFFER_LENGTH) + while (read > -1) { + digest.update(buffer, 0, read) + read = data.read(buffer, 0, STREAM_BUFFER_LENGTH) + } + return digest + } +} diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt index ccfa71f..20e75d8 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/state/LaunchyState.kt @@ -4,9 +4,6 @@ import androidx.compose.runtime.* import com.mineinabyss.launchy.data.config.Config import com.mineinabyss.launchy.data.config.GameInstance import com.mineinabyss.launchy.state.modpack.ModpackState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import java.util.* class LaunchyState( @@ -14,9 +11,6 @@ class LaunchyState( private val config: Config, private val instances: List ) { - @OptIn(ExperimentalCoroutinesApi::class) - val ioContext = Dispatchers.IO.limitedParallelism(10) - val ioScope = CoroutineScope(ioContext) val profile = ProfileState(config) var modpackState: ModpackState? by mutableStateOf(null) private val launchedProcesses = mutableStateMapOf() @@ -54,6 +48,15 @@ class LaunchyState( preferHue = preferHue, ).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/modpack/DownloadQueueState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadQueueState.kt index 3c47b03..f8c1e00 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadQueueState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadQueueState.kt @@ -2,28 +2,53 @@ package com.mineinabyss.launchy.state.modpack import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateMapOf +import com.mineinabyss.launchy.data.ModID +import com.mineinabyss.launchy.data.config.DownloadInfo +import com.mineinabyss.launchy.data.config.ModpackUserConfig import com.mineinabyss.launchy.data.modpacks.Modpack class DownloadQueueState( + private val userConfig: ModpackUserConfig, val modpack: Modpack, val toggles: ModTogglesState ) { - val downloads by derivedStateOf { (toggles.enabledMods - toggles.upToDateMods.toSet()) + (toggles.enabledModsWithConfig - toggles.upToDateConfigs.toSet()) } - val updates by derivedStateOf { downloads.filter { it.isDownloaded }.toSet() } - val installs by derivedStateOf { downloads - updates } + /** Live mod download info, including mods that have been removed from the latest modpack version. */ + val modDownloadInfo = mutableStateMapOf().apply { + putAll(userConfig.modDownloadInfo) + } + + /** 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 { - deleted // Depend on state - toggles.disabledMods - .filter { it.isDownloaded } - .also { if (it.isEmpty()) toggles.checkNonDownloadedMods() } + (modpack.mods.mods - toggles.enabledMods).filter { modDownloadInfo.contains(it.modId) } } + val needsInstall by derivedStateOf { updates + newDownloads + failures } + val areUpdatesQueued by derivedStateOf { updates.isNotEmpty() } - val areInstallsQueued by derivedStateOf { installs.isNotEmpty() } + val areNewDownloadsQueued by derivedStateOf { newDownloads.isNotEmpty() } val areDeletionsQueued by derivedStateOf { deletions.isNotEmpty() } - val areOperationsQueued by derivedStateOf { areUpdatesQueued || areInstallsQueued || areDeletionsQueued } - - internal var deleted by mutableStateOf(0) + val areOperationsQueued by derivedStateOf { + areUpdatesQueued || areNewDownloadsQueued || areDeletionsQueued + } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadState.kt index 760f2b4..96208ab 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/DownloadState.kt @@ -1,16 +1,16 @@ package com.mineinabyss.launchy.state.modpack -import androidx.compose.runtime.* +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.state.mutableStateSetOf class DownloadState { val inProgressMods = mutableStateMapOf() val inProgressConfigs = mutableStateMapOf() - val failed = mutableStateSetOf() - val isDownloading by derivedStateOf { inProgressMods.isNotEmpty() || inProgressConfigs.isNotEmpty() || installingProfile } + val isDownloading by derivedStateOf { inProgressMods.isNotEmpty() || inProgressConfigs.isNotEmpty() } // Caclculate the speed of the download val downloadSpeed by derivedStateOf { @@ -19,5 +19,4 @@ class DownloadState { if (time == 0L) 0 else total / time } - var installingProfile by mutableStateOf(false) } diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModTogglesState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModTogglesState.kt index f0103ff..df37399 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModTogglesState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModTogglesState.kt @@ -1,8 +1,7 @@ package com.mineinabyss.launchy.state.modpack -import androidx.compose.runtime.* -import com.mineinabyss.launchy.data.ConfigURL -import com.mineinabyss.launchy.data.DownloadURL +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import com.mineinabyss.launchy.data.config.ModpackUserConfig import com.mineinabyss.launchy.data.modpacks.Mod import com.mineinabyss.launchy.data.modpacks.Modpack @@ -13,24 +12,6 @@ class ModTogglesState( val modpack: Modpack, val modpackConfig: ModpackUserConfig ) { - val downloadURLs = mutableStateMapOf().apply { - putAll(modpackConfig.modDownloads - .mapNotNull { modpack.mods.getMod(it.key)?.to(it.value) } - .toMap() - ) - } - - val downloadConfigURLs = mutableStateMapOf().apply { - putAll(modpackConfig.modConfigs - .mapNotNull { modpack.mods.getMod(it.key)?.to(it.value) } - .toMap() - ) - } - - var nonDownloadedMods by mutableStateOf(setOf()) - private set - - val enabledMods = mutableStateSetOf().apply { addAll(modpackConfig.toggledMods.mapNotNull { modpack.mods.getMod(it) }) val defaultEnabled = modpack.mods.groups @@ -57,22 +38,8 @@ class ModTogglesState( addAll(modpackConfig.toggledConfigs.mapNotNull { modpack.mods.getMod(it) }) } - - val upToDateMods by derivedStateOf { - enabledMods.filter { it in downloadURLs && downloadURLs[it] == it.info.url && it !in nonDownloadedMods } - } - - val upToDateConfigs by derivedStateOf { - enabledMods.filter { it in downloadConfigURLs && downloadConfigURLs[it] == it.info.configUrl } - } - - fun checkNonDownloadedMods() { - downloadURLs.filter { !it.key.isDownloaded }.keys.also { nonDownloadedMods = it } - } - init { // trigger update incase we have dependencies enabledMods.forEach { setModEnabled(it, true) } - checkNonDownloadedMods() } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModpackState.kt b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModpackState.kt index e74a871..b3792eb 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModpackState.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/state/modpack/ModpackState.kt @@ -13,7 +13,7 @@ class ModpackState( private val userConfig: ModpackUserConfig ) { val toggles: ModTogglesState = ModTogglesState(modpack, userConfig) - val queued = DownloadQueueState(modpack, toggles) + val queued = DownloadQueueState(userConfig, modpack, toggles) val downloads = DownloadState() var userAgreedDeps by mutableStateOf(userConfig.userAgreedDeps) @@ -27,8 +27,8 @@ class ModpackState( toggledConfigs = toggles.enabledConfigs.mapTo(mutableSetOf()) { it.info.name } + toggles.enabledMods.filter { it.info.forceConfigDownload } .mapTo(mutableSetOf()) { it.info.name }, seenGroups = modpack.mods.groups.map { it.name }.toSet(), - modDownloads = toggles.downloadURLs.mapKeys { it.key.info.name }, - modConfigs = toggles.downloadConfigURLs.mapKeys { it.key.info.name }, + modDownloadInfo = queued.modDownloadInfo, +// configDownloadInfo = toggles.downloadConfigURLs.mapKeys { it.key.info.name }, ).save(instance.userConfigFile) } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt index e77f544..489c601 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/dialogs/SelectJVMDialog.kt @@ -5,6 +5,7 @@ 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 @@ -21,7 +22,7 @@ fun SelectJVMDialog() { title = { Text("Install java", style = LocalTextStyle.current) }, onAccept = { dialog = Dialog.None - state.ioScope.launch { + AppDispatchers.IO.launch { val jdkPath = runCatching { Downloader.installJDK(state) }.getOrElse { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Buttons.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Buttons.kt index 63589ad..137f807 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Buttons.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/elements/Buttons.kt @@ -1,9 +1,6 @@ package com.mineinabyss.launchy.ui.elements -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -56,3 +53,20 @@ fun SecondaryButton( modifier = modifier, ) { content() } } + +@Composable +fun OutlinedRedButton( + enabled: Boolean = true, + onClick: () -> Unit = {}, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + OutlinedButton( + enabled = enabled, + onClick = onClick, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = modifier, + ) { + content() + } +} 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 index f6f9a23..d4d7861 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -98,9 +99,10 @@ fun ImportTab(visible: Boolean, onGetInstance: (GameInstanceConfig) -> Unit = {} val taskKey = "importCloudInstance" val downloadPath = Dirs.tmpCloudInstance(urlText) downloadPath.deleteIfExists() - state.ioScope.launch(Dispatchers.IO) { + AppDispatchers.IO.launch(Dispatchers.IO) { state.inProgressTasks[taskKey] = InProgressTask("Importing cloud instance") - val cloudInstance = Downloader.download(urlText, downloadPath).mapCatching { + val cloudInstance = + Downloader.download(urlText, downloadPath, skipDownloadIfCached = false).mapCatching { GameInstanceConfig.read(downloadPath) .showDialogOnError("Failed to read cloud instance") .getOrThrow() diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt index f95e906..b7f3fbb 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/main/MainScreenImages.kt @@ -24,7 +24,6 @@ import com.mineinabyss.launchy.ui.screens.LocalModpackState @Composable fun BoxScope.BackgroundImage(windowScope: WindowScope) { val pack = LocalModpackState - val state = LocalLaunchyState val background by pack.instance.config.getBackground() AnimatedVisibility(background != null, enter = fadeIn(), exit = fadeOut()) { if (background == null) return@AnimatedVisibility 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 index d3efce3..f2891d9 100644 --- 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 @@ -9,11 +9,9 @@ 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.Button import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -34,7 +32,7 @@ fun UpdateInfoButton() { Row { Icon(Icons.Rounded.Update, contentDescription = "Updates") - Text("${queued.downloads.size + queued.deletions.size} Updates") + Text("${queued.newDownloads.size + queued.deletions.size} Updates") } AnimatedVisibility( @@ -50,10 +48,10 @@ fun UpdateInfoButton() { extra = queued.updates.size.toString() ) InfoText( - shown = queued.areInstallsQueued, + shown = queued.areNewDownloadsQueued, icon = Icons.Rounded.Download, desc = "Download", - extra = queued.installs.size.toString() + extra = queued.newDownloads.size.toString() ) InfoText( shown = queued.areDeletionsQueued, 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 index 6d05bff..65d6243 100644 --- 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 @@ -12,11 +12,28 @@ 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.ModDownloader.install +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.LocalModpackState import kotlinx.coroutines.launch +@Composable +fun RetryFailedButton(enabled: Boolean) { + val state = LocalLaunchyState + val packState = LocalModpackState + 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 @@ -24,8 +41,8 @@ fun InstallButton(enabled: Boolean, modifier: Modifier = Modifier) { PrimaryButton( enabled = enabled, onClick = { - state.ioScope.launch { - packState.install(state) + AppDispatchers.profileLaunch.launch { + packState.startInstall(state, ignoreCachedCheck = true) } }, modifier = modifier.width(150.dp) 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 index fb5a409..f4cace7 100644 --- 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 @@ -18,9 +18,11 @@ 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.Launcher -import com.mineinabyss.launchy.logic.ModDownloader.ensureCurrentDepsInstalled -import com.mineinabyss.launchy.logic.ModDownloader.install +import com.mineinabyss.launchy.logic.ModDownloader.prepareWithoutChangingInstalledMods +import com.mineinabyss.launchy.logic.ModDownloader.startInstall import com.mineinabyss.launchy.state.modpack.ModpackState import com.mineinabyss.launchy.ui.elements.PrimaryButtonColors import com.mineinabyss.launchy.ui.elements.SecondaryButtonColors @@ -65,8 +67,8 @@ fun PlayButton( when { // Assume this means not launched before packState.userAgreedDeps == null -> { - state.ioScope.launch { - packState.install(state).join() + AppDispatchers.profileLaunch.launchOrShowDialog { + packState.startInstall(state) Launcher.launch(state, packState, state.profile) } } @@ -78,14 +80,14 @@ fun PlayButton( acceptText = "Install", declineText = "Skip", onAccept = { - state.ioScope.launch { - packState.install(state).join() + AppDispatchers.profileLaunch.launch { + packState.startInstall(state) Launcher.launch(state, packState, state.profile) } }, onDecline = { - state.ioScope.launch { - packState.ensureCurrentDepsInstalled(state) + AppDispatchers.profileLaunch.launch { + packState.prepareWithoutChangingInstalledMods(state) Launcher.launch(state, packState, state.profile) } } @@ -93,8 +95,8 @@ fun PlayButton( } else -> { - coroutineScope.launch(Dispatchers.IO) { - packState.ensureCurrentDepsInstalled(state) + AppDispatchers.profileLaunch.launchOrShowDialog { + packState.prepareWithoutChangingInstalledMods(state) println("Launching now!") Launcher.launch(state, packState, state.profile) } 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 index ca01eb2..69cd102 100644 --- 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 @@ -10,7 +10,6 @@ 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.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,6 +24,7 @@ import com.mineinabyss.launchy.data.Constants.SETTINGS_HORIZONTAL_PADDING import com.mineinabyss.launchy.ui.elements.Tooltip import com.mineinabyss.launchy.ui.screens.LocalModpackState 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 @@ -42,7 +42,8 @@ fun InfoBar(modifier: Modifier = Modifier) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(horizontal = SETTINGS_HORIZONTAL_PADDING, vertical = 6.dp) + .padding(horizontal = SETTINGS_HORIZONTAL_PADDING, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { InstallButton( state.processFor(packState.instance) == null @@ -51,7 +52,10 @@ fun InfoBar(modifier: Modifier = Modifier) { && state.inProgressTasks.isEmpty(), Modifier.width(Constants.SETTINGS_PRIMARY_BUTTON_WIDTH) ) - Spacer(Modifier.width(12.dp)) + val failures = packState.queued.failures.isNotEmpty() + AnimatedVisibility(failures) { + RetryFailedButton(failures) + } ActionButton( shown = packState.queued.areUpdatesQueued, icon = Icons.Rounded.Update, @@ -59,10 +63,10 @@ fun InfoBar(modifier: Modifier = Modifier) { count = packState.queued.updates.size ) ActionButton( - shown = packState.queued.areInstallsQueued, + shown = packState.queued.areNewDownloadsQueued, icon = Icons.Rounded.Download, desc = "Queued downloads for new mods", - count = packState.queued.installs.size + count = packState.queued.newDownloads.size ) ActionButton( shown = packState.queued.areDeletionsQueued, @@ -70,11 +74,6 @@ fun InfoBar(modifier: Modifier = Modifier) { desc = "Queued mod deletions", count = packState.queued.deletions.size ) - - if (packState.downloads.failed.isNotEmpty()) Text( - text = "Failed downloads: ${packState.downloads.failed.size}", - style = MaterialTheme.typography.bodySmall, - ) } } } @@ -96,7 +95,6 @@ fun ActionButton(shown: Boolean, icon: ImageVector, desc: String, count: Int? = } } } - Spacer(Modifier.width(12.dp)) } } } diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModGroup.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModGroup.kt index f4d473a..262d194 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModGroup.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModGroup.kt @@ -33,7 +33,9 @@ fun ModGroup(group: Group, mods: Collection) { val arrowRotationState by animateFloatAsState(targetValue = if (expanded) 180f else 0f) val state = LocalModpackState - val modsChanged = mods.any { it in state.queued.deletions || it in state.queued.downloads } + val modsChanged = mods.any { + it in state.queued.deletions || it in state.queued.newDownloads || it in state.queued.failures + } val tonalElevation by animateDpAsState(if (expanded) 1.6.dp else 1.dp) Column { diff --git a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModInfoDisplay.kt b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModInfoDisplay.kt index 309bfd1..371b5b0 100644 --- a/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModInfoDisplay.kt +++ b/src/main/kotlin/com/mineinabyss/launchy/ui/screens/modpack/settings/ModInfoDisplay.kt @@ -8,9 +8,7 @@ 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.rounded.Info -import androidx.compose.material.icons.rounded.OpenInNew -import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -18,6 +16,7 @@ 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 @@ -36,14 +35,24 @@ fun ModInfoDisplay(group: Group, mod: Mod) { var configExpanded by remember { mutableStateOf(false) } val configTabState by animateFloatAsState(targetValue = if (configExpanded) 180f else 0f) + 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().padding(end = 8.dp), - color = when (mod) { - in state.downloads.failed -> MaterialTheme.colorScheme.error - in state.queued.deletions -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f) - in state.queued.installs -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.25f)//Color(105, 240, 174, alpha = 25) - else -> MaterialTheme.colorScheme.surface - }, + modifier = Modifier.fillMaxWidth(), + color = surfaceColor, onClick = { if (!group.forceEnabled && !group.forceDisabled) state.toggles.setModEnabled(mod, !modEnabled) } ) { if (state.downloads.inProgressMods.containsKey(mod) || state.downloads.inProgressConfigs.containsKey(mod)) { @@ -56,10 +65,11 @@ fun ModInfoDisplay(group: Group, mod: Mod) { color = MaterialTheme.colorScheme.primaryContainer ) } - Column { + Column() { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(end = 8.dp) ) { Checkbox( enabled = !group.forceEnabled && !group.forceDisabled, @@ -67,6 +77,8 @@ fun ModInfoDisplay(group: Group, mod: Mod) { onCheckedChange = { state.toggles.setModEnabled(mod, !modEnabled) } ) + if (infoIcon != null) Icon(infoIcon, "Mod Information", modifier = Modifier.padding(end = 8.dp)) + Row(Modifier.weight(6f)) { Text(mod.info.name, style = MaterialTheme.typography.bodyLarge) // build list of mods that are incompatible with this mod @@ -146,7 +158,12 @@ fun ModInfoDisplay(group: Group, mod: Mod) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .clickable { if (!mod.info.forceConfigDownload) state.toggles.setModConfigEnabled(mod, !configEnabled) } + .clickable { + if (!mod.info.forceConfigDownload) state.toggles.setModConfigEnabled( + mod, + !configEnabled + ) + } .fillMaxWidth() ) { Spacer(Modifier.width(20.dp))