Skip to content

Commit

Permalink
Fix: Image reading state could cause errors when switching menus fast…
Browse files Browse the repository at this point in the history
… on downloading for the first time

Fix: Not creating configs on first launch
Fix: Handle more I/O exceptions instead of outright crashing
Feat: Support deleting instances
  • Loading branch information
0ffz committed Mar 7, 2024
1 parent 27fa279 commit ec6c1a2
Show file tree
Hide file tree
Showing 27 changed files with 258 additions and 158 deletions.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
group=com.mineinabyss
version=2.0.0-alpha.4
version=2.0.0-alpha.5
idofrontVersion=0.22.3
3 changes: 2 additions & 1 deletion src/main/kotlin/com/mineinabyss/launchy/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ fun main() {
val icon = painterResource("icon.png")
val launchyState by produceState<LaunchyState?>(null) {
Dirs.createDirs()
val config = Config.read()
Dirs.createConfigFiles()
val config = Config.read().getOrElse { Config() }
val instances = GameInstance.readAll(Dirs.modpackConfigsDir)
value = LaunchyState(config, instances)
}
Expand Down
5 changes: 1 addition & 4 deletions src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.mineinabyss.launchy.data

import com.mineinabyss.launchy.util.OS
import java.util.UUID
import java.util.*
import kotlin.io.path.*

object Dirs {
Expand Down Expand Up @@ -36,7 +36,6 @@ object Dirs {
fun avatar(uuid: UUID) = imageCache / "avatar-$uuid"

val configFile = config / "mia-launcher.yml"
val versionsFile = config / "mia-versions.yml"

val modpackConfigsDir = (config / "modpacks")

Expand All @@ -56,7 +55,5 @@ object Dirs {
fun createConfigFiles() {
if (configFile.notExists())
configFile.createFile().writeText("{}")
if (versionsFile.notExists())
versionsFile.createFile().writeText("{}")
}
}
40 changes: 4 additions & 36 deletions src/main/kotlin/com/mineinabyss/launchy/data/config/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import com.mineinabyss.launchy.data.Dirs
import com.mineinabyss.launchy.data.Formats
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import java.io.*
import java.nio.file.Path
import java.util.zip.ZipFile
import kotlin.io.path.*
import kotlin.io.path.inputStream
import kotlin.io.path.writeText


@Serializable
Expand All @@ -26,38 +24,8 @@ data class Config(
}

companion object {
fun read() =
fun read(): Result<Config> = runCatching {
Formats.yaml.decodeFromStream(serializer(), Dirs.configFile.inputStream())
}.onFailure { it.printStackTrace() }
}
}

@Throws(IOException::class)
fun unzip(zipFilePath: Path, destDirectory: Path) {
if (destDirectory.notExists()) destDirectory.createDirectories()

ZipFile(zipFilePath.toFile()).use { zip ->
zip.entries().asSequence().forEach { entry ->
zip.getInputStream(entry).use { input ->
val filePath = destDirectory / entry.name
filePath.createParentDirectories()
if (!entry.isDirectory) extractFile(input, filePath)
else {
if (filePath.notExists()) filePath.createDirectory()
}
}
}
}
}

@Throws(IOException::class)
fun extractFile(inputStream: InputStream, destFilePath: Path) {
val bufferSize = 4096
val buffer = BufferedOutputStream(destFilePath.outputStream())
val bytes = ByteArray(bufferSize)
var read: Int
while (inputStream.read(bytes).also { read = it } != -1) {
buffer.write(bytes, 0, read)
}
buffer.close()
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.mineinabyss.launchy.data.config

import com.charleskorn.kaml.decodeFromStream
import com.charleskorn.kaml.encodeToStream
import com.mineinabyss.launchy.data.Dirs
import com.mineinabyss.launchy.data.Formats
Expand All @@ -20,16 +19,14 @@ class GameInstance(
require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" }
}

val config: GameInstanceConfig = GameInstanceConfig.read(configDir / "instance.yml")
val config: GameInstanceConfig = GameInstanceConfig.read(configDir / "instance.yml").getOrThrow()

val minecraftDir = config.overrideMinecraftDir?.let { Path(it) } ?: Dirs.modpackDir(configDir.name)

val userConfigFile = (configDir / "config.yml")

suspend fun createModpackState(): ModpackState? {
val userConfig =
if (userConfigFile.exists()) Formats.yaml.decodeFromStream<ModpackUserConfig>(userConfigFile.inputStream())
else ModpackUserConfig()
val userConfig = ModpackUserConfig.load(userConfigFile).getOrNull() ?: ModpackUserConfig()
val modpack = config.source.loadInstance(this)
.getOrElse {
dialog = Dialog.Error(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
package com.mineinabyss.launchy.data.config

import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.res.loadImageBitmap
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.state.LaunchyState
import kotlinx.coroutines.delay
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.time.Duration.Companion.seconds

@Serializable
@OptIn(ExperimentalStdlibApi::class)
Expand All @@ -37,43 +36,47 @@ data class GameInstanceConfig(
val logoPath = Dirs.imageCache / "icon-${logoURL.hashCode().toHexString()}"

@Transient
private var cachedBackground: BitmapPainter? = null
private var cachedBackground = mutableStateOf<BitmapPainter?>(null)

@Transient
private var cachedLogo: BitmapPainter? = null
private var cachedLogo = mutableStateOf<BitmapPainter?>(null)

suspend fun loadBackgroundImage(): BitmapPainter {
cachedBackground?.let { return it }
Downloader.download(backgroundURL, backgroundPath, override = false)
val painter = BitmapPainter(loadImageBitmap(backgroundPath.inputStream()))
cachedBackground = painter
return painter

private suspend fun loadBackground() {
if (cachedBackground.value != null) return
runCatching {
Downloader.download(backgroundURL, backgroundPath, override = false)
val painter = BitmapPainter(loadImageBitmap(backgroundPath.inputStream()))
cachedBackground.value = painter
}.onFailure { it.printStackTrace() }
}

suspend fun loadLogo(): BitmapPainter {
cachedLogo?.let { return it }
Downloader.download(logoURL, logoPath, override = false)
val painter = BitmapPainter(loadImageBitmap(logoPath.inputStream()))
cachedLogo = painter
return painter
private suspend fun loadLogo() {
if (cachedLogo.value != null) return
runCatching {
Downloader.download(logoURL, logoPath, override = false)
val painter = BitmapPainter(loadImageBitmap(logoPath.inputStream()))
cachedLogo.value = painter
}.onFailure { it.printStackTrace() }
}

@Composable
fun produceBackgroundState(state: LaunchyState) = produceState(cachedBackground) {
state.downloadContext.launch {
value = loadBackgroundImage()
fun getBackground(state: LaunchyState) = remember {
cachedBackground.also {
if (it.value == null) state.ioScope.launch { loadBackground() }
}
}

@Composable
fun produceLogoState(state: LaunchyState) = produceState(cachedLogo) {
state.downloadContext.launch {
value = loadLogo()
fun getLogo(state: LaunchyState) = remember {
cachedLogo.also {
if (it.value == null) state.ioScope.launch { loadLogo() }
}
}

companion object {
fun read(path: Path) =
fun read(path: Path) = runCatching {
Formats.yaml.decodeFromStream(serializer(), path.inputStream())
}.onFailure { it.printStackTrace() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ data class ModpackUserConfig(
}

companion object {
fun load(packConfigDir: Path): ModpackUserConfig {
val file = packConfigDir / "config.yml"
return if (file.exists()) Formats.yaml.decodeFromStream<ModpackUserConfig>(file.inputStream())
fun load(file: Path): Result<ModpackUserConfig> = runCatching {
return@runCatching if (file.exists()) Formats.yaml.decodeFromStream<ModpackUserConfig>(file.inputStream())
else ModpackUserConfig()
}
}.onFailure { it.printStackTrace() }
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
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
Expand All @@ -9,6 +13,7 @@ import com.mineinabyss.launchy.logic.Downloader
import com.mineinabyss.launchy.state.LaunchyState
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.*
import kotlin.io.path.inputStream

Expand All @@ -17,11 +22,18 @@ data class PlayerProfile(
val name: String,
val uuid: @Serializable(with = UUIDSerializer::class) UUID,
) {
suspend fun getAvatar(state: LaunchyState): BitmapPainter {
val avatarPath = Dirs.avatar(uuid)
state.downloadContext.launch {
Downloader.downloadAvatar(uuid)
}.join()
return BitmapPainter(loadImageBitmap(avatarPath.inputStream()), filterQuality = FilterQuality.None)
@Transient
private val avatar = mutableStateOf<BitmapPainter?>(null)

@Composable
fun getAvatar(state: LaunchyState): MutableState<BitmapPainter?> = remember {
avatar.also {
if (it.value != null) return@also
state.ioScope.launch {
Downloader.downloadAvatar(uuid)
it.value =
BitmapPainter(loadImageBitmap(Dirs.avatar(uuid).inputStream()), filterQuality = FilterQuality.None)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ object Downloader {
}

suspend fun downloadAvatar(uuid: UUID) {
download("https://crafatar.com/avatars/$uuid?size=16&overlay", Dirs.avatar(uuid), override = false)
download("https://crafatar.com/avatars/$uuid?size=16&overlay", Dirs.avatar(uuid))
}

@OptIn(ExperimentalStdlibApi::class)
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/mineinabyss/launchy/logic/Helpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package com.mineinabyss.launchy.logic
import com.mineinabyss.launchy.ui.screens.Dialog
import com.mineinabyss.launchy.ui.screens.dialog

fun <T> Result<T>.showDialogOnError(): Result<T> {
onFailure { dialog = Dialog.fromException(it) }
fun <T> Result<T>.showDialogOnError(title: String? = null): Result<T> {
onFailure { dialog = Dialog.fromException(it, title) }
return this
}

Expand Down
23 changes: 23 additions & 0 deletions src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.mineinabyss.launchy.logic

import com.mineinabyss.launchy.data.config.GameInstance
import com.mineinabyss.launchy.state.InProgressTask
import com.mineinabyss.launchy.state.LaunchyState
import kotlinx.coroutines.launch
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.deleteRecursively

object Instances {
@OptIn(ExperimentalPathApi::class)
fun GameInstance.delete(state: LaunchyState) {
try {
state.inProgressTasks["deleteInstance"] = InProgressTask("Deleting instance ${config.name}")
state.gameInstances.remove(this)
state.ioScope.launch {
configDir.deleteRecursively()
}
} finally {
state.inProgressTasks.remove("deleteInstance")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ object ModDownloader {
toggles.checkNonDownloadedMods()
val modDownloads = launch {
queued.downloads.map { mod ->
state.downloadContext.launch {
state.ioScope.launch {
download(state, mod)
toggles.checkNonDownloadedMods()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ class LaunchyState(
private val instances: List<GameInstance>
) {
@OptIn(ExperimentalCoroutinesApi::class)
val downloadContext = CoroutineScope(Dispatchers.IO.limitedParallelism(10))
val ioContext = Dispatchers.IO.limitedParallelism(10)
val ioScope = CoroutineScope(ioContext)
val profile = ProfileState(config)
var modpackState: ModpackState? by mutableStateOf(null)
private val launchedProcesses = mutableStateMapOf<String, Process>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@ package com.mineinabyss.launchy.ui.dialogs
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand All @@ -20,7 +17,6 @@ import androidx.compose.ui.unit.sp
import com.mineinabyss.launchy.LocalLaunchyState
import com.mineinabyss.launchy.logic.Browser
import com.mineinabyss.launchy.ui.elements.LaunchyDialog
import com.mineinabyss.launchy.ui.elements.PrimaryIconButtonColors
import com.mineinabyss.launchy.ui.screens.Dialog
import com.mineinabyss.launchy.ui.screens.dialog

Expand Down Expand Up @@ -57,7 +53,6 @@ fun AuthDialog(
) {
append("microsoft.com/link")
}
appendInlineContent("copyIcon", "[copy]")
pop()

append(" and enter the code ${state.profile.authCode}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import com.mineinabyss.launchy.LocalLaunchyState
import com.mineinabyss.launchy.logic.Downloader
import com.mineinabyss.launchy.ui.elements.LaunchyDialog
import com.mineinabyss.launchy.ui.screens.*
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
Expand All @@ -18,7 +21,7 @@ fun SelectJVMDialog() {
title = { Text("Install java", style = LocalTextStyle.current) },
onAccept = {
dialog = Dialog.None
state.downloadContext.launch {
state.ioScope.launch {
val jdkPath = runCatching {
Downloader.installJDK(state)
}.getOrElse {
Expand Down
Loading

0 comments on commit ec6c1a2

Please sign in to comment.