Skip to content

Commit

Permalink
Improve cloud updates and other fixes
Browse files Browse the repository at this point in the history
Feat: Check for updates in the background
Feat: Add option to update instead of doing it automatically
Feat: Update instance details from cloud urls (ex background image, desc...)
Fix: Window dragging breaking when in fullscreen
Fix: Add double tap to toggle maximized state
Fix: Some Scaffolds not being transparent causing weird transitions
Update: Improve dialog asking to install changed mods before launch
Fix: Always updating mods even if skipping updates
  • Loading branch information
0ffz committed Mar 7, 2024
1 parent e60f729 commit 99d23c7
Show file tree
Hide file tree
Showing 25 changed files with 451 additions and 197 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.6
version=2.0.0-alpha.7
idofrontVersion=0.22.3
3 changes: 3 additions & 0 deletions src/main/kotlin/com/mineinabyss/launchy/data/Dirs.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mineinabyss.launchy.data

import com.mineinabyss.launchy.logic.urlToFileName
import com.mineinabyss.launchy.util.OS
import java.util.*
import kotlin.io.path.*
Expand Down Expand Up @@ -34,6 +35,8 @@ object Dirs {

val accounts = config / "accounts"

fun tmpCloudInstance(url: String) = tmp / "cloudInstances" / "${urlToFileName(url)}.yml"

fun avatar(uuid: UUID) = imageCache / "avatar-$uuid"

val configFile = config / "mia-launcher.yml"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,64 @@
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.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.ModpackState
import com.mineinabyss.launchy.ui.screens.Dialog
import com.mineinabyss.launchy.ui.screens.dialog
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"

init {
require(configDir.isDirectory()) { "Game instance at $configDir must be a directory" }
}

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 updateCheckerScope = CoroutineScope(Dispatchers.IO)

var updatesAvailable by mutableStateOf(false)
var enabled: Boolean by mutableStateOf(true)

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 {
dialog = Dialog.Error(
"Failed read instance", it
.stackTraceToString()
.split("\n").take(5).joinToString("\n")
)
it.printStackTrace()
return null
}
state.inProgressTasks.remove("loadingModpack")

val cloudUrl = config.cloudInstanceURL
if (cloudUrl != null) state.ioScope.launch {
val updates = Downloader.checkUpdates(cloudUrl)
if (updates.result != UpdateResult.UpToDate) {
updatesAvailable = true
}
}
return ModpackState(this, modpack, userConfig)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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.charleskorn.kaml.encodeToStream
import com.mineinabyss.launchy.data.Dirs
import com.mineinabyss.launchy.data.Formats
import com.mineinabyss.launchy.data.modpacks.source.PackSource
Expand All @@ -19,6 +20,7 @@ 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
@OptIn(ExperimentalStdlibApi::class)
Expand Down Expand Up @@ -78,6 +80,10 @@ data class GameInstanceConfig(
}
}

fun saveTo(path: Path) = runCatching {
Formats.yaml.encodeToStream(this, path.outputStream())
}

companion object {
fun read(path: Path) = runCatching {
Formats.yaml.decodeFromStream(serializer(), path.inputStream())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ 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.Downloader
import com.mineinabyss.launchy.logic.UpdateResult
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

Expand All @@ -17,19 +19,44 @@ sealed class PackSource {
val dependencies = format.getDependencies(instance.minecraftDir)
Modpack(dependencies, mods, format.getOverridesPaths(instance.configDir))
}

override suspend fun updateInstance(instance: GameInstance): Result<GameInstance> {
return runCatching { GameInstance(instance.configDir) }
}
}

@SerialName("downloadFromURL")
@Serializable
class DownloadFromURL(val url: String, val type: PackType) : PackSource() {
override suspend fun loadInstance(instance: GameInstance): Result<Modpack> {
val downloadTo = type.getFilePath(instance.configDir)
Downloader.download(url, downloadTo, onFinishDownloadWhenChanged = {
type.afterDownload(instance.configDir)
})
if (!type.isDownloaded(instance.configDir))
Downloader.download(url, downloadTo, whenChanged = {
type.afterDownload(instance.configDir)
})
else {
instance.updateCheckerScope.launch {
val updates = Downloader.checkUpdates(url)
if (updates.result != UpdateResult.UpToDate) {
instance.updatesAvailable = true
}
}
}
return LocalFile(type).loadInstance(instance)
}

override suspend fun updateInstance(instance: GameInstance): Result<GameInstance> {
return runCatching {
val downloadTo = type.getFilePath(instance.configDir)
Downloader.download(url, downloadTo, whenChanged = {
type.afterDownload(instance.configDir)
})
GameInstance(instance.configDir)
}
}
}

abstract suspend fun loadInstance(instance: GameInstance): Result<Modpack>

abstract suspend fun updateInstance(instance: GameInstance): Result<GameInstance>
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import kotlin.io.path.*
enum class PackType {
Launchy, Modrinth;

fun isDownloaded(configDir: Path) = getFilePath(configDir).isRegularFile()

fun getFilePath(configDir: Path): Path {
val ext = when (this) {
Launchy -> "yml"
Expand Down
36 changes: 25 additions & 11 deletions src/main/kotlin/com/mineinabyss/launchy/logic/Downloader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,41 @@ object Downloader {
download("https://crafatar.com/avatars/$uuid?size=16&overlay", Dirs.avatar(uuid))
}

class CacheInfo(val result: UpdateResult, val cacheKey: String, val cacheFile: Path)

@OptIn(ExperimentalStdlibApi::class)
suspend fun checkUpdates(url: String): CacheInfo {
val headers = httpClient.head(url).headers
val lastModified = headers["Last-Modified"]?.fromHttpToGmtDate()?.timestamp?.toHexString()
val length = headers["Content-Length"]?.toLongOrNull()?.toHexString()
val cache = "Last-Modified: $lastModified, Content-Length: $length"
val cacheFile = Dirs.cacheDir / "${urlToFileName(url)}.cache"
val result = when {
cacheFile.notExists() -> UpdateResult.NotCached
cacheFile.readText() == cache -> UpdateResult.UpToDate
else -> UpdateResult.HasUpdates
}
return CacheInfo(result, cache, cacheFile)
}

suspend fun download(
url: String,
writeTo: Path,
override: Boolean = true,
onFinishDownloadWhenChanged: () -> Unit = {},
whenChanged: () -> Unit = {},
onProgressUpdate: (progress: Progress) -> Unit = {},
): Result<Unit> {
return runCatching {
if (!override && writeTo.exists()) return@runCatching
val startTime = System.currentTimeMillis()
writeTo.createParentDirectories()
val headers = httpClient.head(url).headers
val lastModified = headers["Last-Modified"]?.fromHttpToGmtDate()?.timestamp?.toHexString()
val length = headers["Content-Length"]?.toLongOrNull()?.toHexString()
val cache = "Last-Modified: $lastModified, Content-Length: $length"
val cacheFile = Dirs.cacheDir / "${urlToFileName(url)}.cache"
if (writeTo.exists() && cacheFile.exists() && cacheFile.readText() == cache) return@runCatching
cacheFile.createParentDirectories()
cacheFile.deleteIfExists()
cacheFile.createFile().writeText(cache)
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) {
timeout {
Expand All @@ -76,7 +90,7 @@ object Downloader {
writeTo.appendBytes(bytes)
}
}
onFinishDownloadWhenChanged()
whenChanged()
}
}.onFailure {
it.printStackTrace()
Expand Down
52 changes: 50 additions & 2 deletions src/main/kotlin/com/mineinabyss/launchy/logic/Instances.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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.ExperimentalPathApi
import kotlin.io.path.deleteRecursively
import kotlin.io.path.*

object Instances {
@OptIn(ExperimentalPathApi::class)
Expand All @@ -21,4 +24,49 @@ object Instances {
state.inProgressTasks.remove("deleteInstance")
}
}

fun GameInstance.updateInstance(
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 {
val cloudUrl = config.cloudInstanceURL
if (cloudUrl != null) {
val newCloudInstance = Dirs.tmpCloudInstance(cloudUrl)
Downloader.download(cloudUrl, newCloudInstance, whenChanged = {
instanceFile.copyTo(configDir / "instance-backup.yml", overwrite = true)
GameInstanceConfig.read(newCloudInstance).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()
}
}
} finally {
state.inProgressTasks.remove("updateInstance")
}
}
}
7 changes: 7 additions & 0 deletions src/main/kotlin/com/mineinabyss/launchy/logic/UpdateResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.mineinabyss.launchy.logic

sealed interface UpdateResult {
object UpToDate : UpdateResult
object HasUpdates : UpdateResult
object NotCached : UpdateResult
}
12 changes: 10 additions & 2 deletions src/main/kotlin/com/mineinabyss/launchy/ui/TopBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package com.mineinabyss.launchy.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
Expand All @@ -20,9 +20,11 @@ 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 com.mineinabyss.launchy.ui.elements.BetterWindowDraggableArea
import com.mineinabyss.launchy.ui.state.TopBarState

@Composable
Expand All @@ -44,7 +46,13 @@ fun AppTopBar(
showTitle: Boolean,
showBackButton: Boolean,
onBackButtonClicked: (() -> Unit),
) = state.windowScope.WindowDraggableArea {
) = state.windowScope.BetterWindowDraggableArea(
Modifier.pointerInput(Unit) {
detectTapGestures(onDoubleTap = {
state.toggleMaximized()
})
}
) {
Box(
Modifier.fillMaxWidth().height(40.dp)
) {
Expand Down
Loading

0 comments on commit 99d23c7

Please sign in to comment.