Files
Suwayomi-Server/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt
Mitchell Syer 6fef27bb56 Wait until WebUI is ready to open in browser (#2010)
* Wait until WebUI is ready

* Changelog

* Move openInBrowser out of timeout
2026-05-09 18:15:43 -04:00

861 lines
30 KiB
Kotlin

package suwayomi.tachidesk.server.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import android.app.Application
import android.content.Context
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.lang.launchIO
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.reactivecircus.cache4k.Cache
import io.javalin.config.JavalinConfig
import io.javalin.http.staticfiles.AliasCheck
import io.javalin.http.staticfiles.Location
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import net.lingala.zip4j.ZipFile
import suwayomi.tachidesk.graphql.types.AboutWebUI
import suwayomi.tachidesk.graphql.types.UpdateState
import suwayomi.tachidesk.graphql.types.UpdateState.DOWNLOADING
import suwayomi.tachidesk.graphql.types.UpdateState.ERROR
import suwayomi.tachidesk.graphql.types.UpdateState.FINISHED
import suwayomi.tachidesk.graphql.types.UpdateState.IDLE
import suwayomi.tachidesk.graphql.types.WebUIChannel
import suwayomi.tachidesk.graphql.types.WebUIFlavor
import suwayomi.tachidesk.graphql.types.WebUIUpdateInfo
import suwayomi.tachidesk.graphql.types.WebUIUpdateStatus
import suwayomi.tachidesk.server.ApplicationDirs
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.util.ExitCode.WebUISetupFailure
import suwayomi.tachidesk.util.HAScheduler
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URI
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
private val applicationDirs: ApplicationDirs by injectLazy()
private val tmpDir = System.getProperty("java.io.tmpdir")
private fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
class BundledWebUIMissing : Exception("No bundled webUI version found")
fun WebUIFlavor.isDefault(): Boolean = this == WebUIFlavor.default
object WebInterfaceManager {
private val logger = KotlinLogging.logger {}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private const val LAST_WEBUI_UPDATE_CHECK_KEY = "lastWebUIUpdateCheck"
private const val SERVED_WEBUI_FLAVOR_KEY = "servedWebUIFlavor"
private const val VERSION_UPDATE_TIMESTAMP_KEY = "webUIVersionUpdateTimestamp"
private val preferences = Injekt.get<Application>().getSharedPreferences("server_util", Context.MODE_PRIVATE)
private var currentUpdateTaskId: String = ""
val isSetupComplete = MutableStateFlow(false)
private val json: Json by injectLazy()
private val network: NetworkHelper by injectLazy()
private val CACHE_DURATION = 5.minutes
private val versionMappingCache = Cache.Builder<String, JsonArray>().expireAfterWrite(CACHE_DURATION).build()
private val previewVersionCache = Cache.Builder<String, String>().expireAfterWrite(CACHE_DURATION).build()
private val notifyFlow = MutableSharedFlow<WebUIUpdateStatus?>()
private val statusFlow = MutableSharedFlow<WebUIUpdateStatus>()
val status =
statusFlow.stateIn(
scope,
SharingStarted.Eagerly,
getStatus(),
)
init {
scope.launch {
@OptIn(FlowPreview::class)
notifyFlow.sample(1.seconds).collect {
if (it != null) {
logger.debug { "notifyFlow: sampling $it" }
statusFlow.emit(it)
}
}
}
serverConfig.subscribeTo(
combine(serverConfig.webUIUpdateCheckInterval, serverConfig.webUIFlavor) { interval, flavor ->
Pair(
interval,
flavor,
)
},
::scheduleWebUIUpdateCheck,
ignoreInitialValue = false,
)
}
fun getAboutInfo(): AboutWebUI {
val currentVersion = getLocalVersion()
val failedToGetVersion = currentVersion === "r-1"
if (failedToGetVersion) {
throw Exception("Failed to get current version")
}
return AboutWebUI(
channel = serverConfig.webUIChannel.value,
tag = currentVersion,
updateTimestamp = preferences.getLong(VERSION_UPDATE_TIMESTAMP_KEY, System.currentTimeMillis()),
)
}
fun getStatus(
version: String = "",
state: UpdateState = IDLE,
progress: Int = 0,
): WebUIUpdateStatus =
WebUIUpdateStatus(
info =
WebUIUpdateInfo(
channel = serverConfig.webUIChannel.value,
tag = version,
),
state,
progress,
)
fun resetStatus() {
emitStatus("", IDLE, 0, immediate = true)
}
private var serveWebUI: () -> Unit = {}
fun setup(config: JavalinConfig) {
if (!serverConfig.webUIEnabled.value) {
return
}
File(applicationDirs.webUIServe).mkdirs()
config.staticFiles.add { staticFiles ->
if (ServerSubpath.isDefined()) staticFiles.hostedPath = ServerSubpath.normalized()
// Use canonical path to avoid Jetty alias issues
staticFiles.directory = File(applicationDirs.webUIServe).canonicalPath
staticFiles.location = Location.EXTERNAL
staticFiles.aliasCheck = AliasCheck { _, _ -> true }
}
serveWebUI = {
val updatedServableRoot = createServableRoot()
config.spaRoot.addFile(ServerSubpath.asRootPath(), "$updatedServableRoot/index.html", Location.EXTERNAL)
logger.info {
"Serving SPA files for ${serverConfig.webUIFlavor.value}" +
if (ServerSubpath.isDefined()) " under subpath '${ServerSubpath.normalized()}'" else ""
}
}
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launchIO {
setupWebUI()
isSetupComplete.value = true
}
}
private fun createServableRoot(): String {
val tempWebUIRoot = createServableDirectory()
val orgIndexHtml = File("$tempWebUIRoot/index.html")
if (ServerSubpath.isDefined() && orgIndexHtml.exists()) {
val originalIndexHtml = orgIndexHtml.readText()
val subpathInjectionBaseTag = "<base href=\"${ServerSubpath.asRootPath()}\">"
val indexHtmlWithSubpathInjection =
originalIndexHtml.replace(
"<head>",
"<head>$subpathInjectionBaseTag",
)
orgIndexHtml.writeText(indexHtmlWithSubpathInjection)
}
return tempWebUIRoot
}
private fun createServableDirectory(): String {
val originalWebUIRoot = applicationDirs.webUIRoot
val tempWebUIRoot = applicationDirs.webUIServe
File(tempWebUIRoot).deleteRecursively()
File(tempWebUIRoot).mkdirs()
File(originalWebUIRoot).copyRecursively(File(tempWebUIRoot), overwrite = true)
logger.debug { "Created servable WebUI directory at: $tempWebUIRoot" }
// Return canonical path to avoid Jetty alias issues
return File(tempWebUIRoot).canonicalPath
}
private fun updateServedWebUIInfo(flavor: WebUIFlavor) {
preferences.edit().putString(SERVED_WEBUI_FLAVOR_KEY, flavor.uiName).apply()
preferences.edit().putLong(VERSION_UPDATE_TIMESTAMP_KEY, System.currentTimeMillis()).apply()
}
private fun getServedWebUIFlavor(): WebUIFlavor =
WebUIFlavor.from(preferences.getString(SERVED_WEBUI_FLAVOR_KEY, WebUIFlavor.default.uiName)!!)
private fun isAutoUpdateEnabled(): Boolean = serverConfig.webUIUpdateCheckInterval.value.toInt() > 0
private fun scheduleWebUIUpdateCheck() {
HAScheduler.descheduleCron(currentUpdateTaskId)
val isAutoUpdateDisabled = !isAutoUpdateEnabled() || serverConfig.webUIFlavor.value == WebUIFlavor.CUSTOM
if (isAutoUpdateDisabled) {
return
}
val updateInterval =
serverConfig.webUIUpdateCheckInterval.value.hours
.coerceAtLeast(1.hours)
.coerceAtMost(23.hours)
val lastAutomatedUpdate = preferences.getLong(LAST_WEBUI_UPDATE_CHECK_KEY, System.currentTimeMillis())
val task = {
if (isSetupComplete.value) {
val log =
KotlinLogging.logger(
"${logger.name}::scheduleWebUIUpdateCheck(" +
"flavor= ${WebUIFlavor.current.uiName}, " +
"channel= ${serverConfig.webUIChannel.value}, " +
"interval= ${serverConfig.webUIUpdateCheckInterval.value}h, " +
"lastAutomatedUpdate= ${
Date(
lastAutomatedUpdate,
)
})",
)
log.debug { "called" }
runBlocking {
try {
checkForUpdate(WebUIFlavor.current)
} catch (e: Exception) {
log.error(e) { "failed due to" }
}
}
}
}
val wasPreviousUpdateCheckTriggered =
(System.currentTimeMillis() - lastAutomatedUpdate) < updateInterval.inWholeMilliseconds
if (!wasPreviousUpdateCheckTriggered) {
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
task()
}
}
currentUpdateTaskId =
HAScheduler.scheduleCron(task, "0 */${updateInterval.inWholeHours} * * *", "webUI-update-checker")
}
suspend fun setupWebUI() {
if (serverConfig.webUIFlavor.value == WebUIFlavor.CUSTOM) {
serveWebUI()
return
}
val servedFlavor = getServedWebUIFlavor()
val log =
KotlinLogging.logger(
"${logger.name} setupWebUI(flavor= ${WebUIFlavor.current.uiName}, servedFlavor= ${servedFlavor.uiName}, channel= ${serverConfig.webUIChannel})",
)
val flavor =
if (serverConfig.webUIChannel.value == WebUIChannel.BUNDLED) {
if (serverConfig.webUIFlavor.value != WebUIFlavor.default) {
log.warn {
"Changed flavor to ${WebUIFlavor.default.uiName}. Channel \"${WebUIChannel.BUNDLED}\" only works with the default flavor"
}
}
WebUIFlavor.default
} else {
WebUIFlavor.current
}
if (doesLocalWebUIExist(applicationDirs.webUIRoot)) {
val currentVersion = getLocalVersion()
log.info { "found webUI files - version= $currentVersion" }
serveWebUI()
val hasFlavorChanged = flavor.uiName != servedFlavor.uiName
if (hasFlavorChanged) {
try {
doInitialSetup(flavor)
serveWebUI()
return
} catch (e: Exception) {
log.warn(e) { "Failed to install the version of the new flavor, proceeding with version of previous flavor" }
}
}
val flavorToValidate = if (hasFlavorChanged) servedFlavor else flavor
if (!isLocalWebUIValid(flavorToValidate, applicationDirs.webUIRoot)) {
try {
doInitialSetup(flavorToValidate, isInvalid = true)
serveWebUI()
} catch (e: Exception) {
log.warn(e) { "WebUI is invalid and failed to install a valid version, proceeding with invalid version" }
}
return
}
if (isAutoUpdateEnabled()) {
checkForUpdate(flavor)
}
// check if the bundled webUI version is a newer version than the current used version
// this could be the case in case no compatible webUI version is available and a newer server version was installed
val shouldUpdateToBundledVersion =
flavor.isDefault() && extractVersion(getLocalVersion()) < extractVersion(BuildConfig.WEBUI_TAG)
if (shouldUpdateToBundledVersion) {
log.debug { "update to bundled version \"${BuildConfig.WEBUI_TAG}\"" }
try {
setupBundledWebUI()
serveWebUI()
} catch (e: Exception) {
log.error(e) { "failed the update to the bundled webUI" }
}
}
return
}
log.warn { "no webUI files found, starting download..." }
try {
doInitialSetup(flavor)
serveWebUI()
} catch (e: Exception) {
log.error(e) {
"Failed to setup the webUI. Unable to start the server with a served webUI, change the settings to start" +
"without one. Stopping the server now..."
}
shutdownApp(WebUISetupFailure)
}
}
/**
* Tries to download the latest compatible version for the selected webUI and falls back to the default webUI in case of errors.
*/
private suspend fun doInitialSetup(
flavor: WebUIFlavor,
isInvalid: Boolean = false,
) {
val log =
KotlinLogging.logger("${logger.name} doInitialSetup(flavor= ${flavor.uiName})")
val isLocalWebUIValid = !isInvalid && isLocalWebUIValid(flavor, applicationDirs.webUIRoot)
/**
* Performs the download and returns if the download was successful.
*
* In case the download failed but the local webUI is valid the download is considered a success to prevent the fallback logic
*/
val doDownload: suspend (getVersion: suspend () -> String) -> Boolean = { getVersion ->
try {
downloadVersion(flavor, getVersion())
true
} catch (_: Exception) {
false
} ||
isLocalWebUIValid
}
// download the latest compatible version for the current selected webUI
val fallbackToDefaultWebUI = !doDownload { getLatestCompatibleVersion(flavor) }
if (!fallbackToDefaultWebUI) {
return
}
if (!flavor.isDefault()) {
log.warn { "fallback to default webUI \"${WebUIFlavor.default.uiName}\"" }
serverConfig.webUIFlavor.value = WebUIFlavor.default
val fallbackToBundledVersion = !doDownload { getLatestCompatibleVersion(flavor) }
if (!fallbackToBundledVersion) {
return
}
}
log.warn { "fallback to bundled default webUI \"${WebUIFlavor.default.uiName}\"" }
try {
setupBundledWebUI()
} catch (_: Exception) {
throw Exception("Unable to setup a webUI")
}
}
private suspend fun setupBundledWebUI() {
try {
extractBundledWebUI()
updateServedWebUIInfo(WebUIFlavor.default)
return
} catch (e: BundledWebUIMissing) {
logger.warn(e) { "setupBundledWebUI: fallback to downloading the version of the bundled webUI" }
}
downloadVersion(WebUIFlavor.default, BuildConfig.WEBUI_TAG)
}
private fun extractBundledWebUI() {
val resourceWebUI: InputStream =
BuildConfig::class.java.getResourceAsStream("/WebUI.zip") ?: throw BundledWebUIMissing()
logger.info { "extractBundledWebUI: Using the bundled WebUI zip..." }
val webUIZip = WebUIFlavor.default.baseFileName
val webUIZipPath = "$tmpDir/$webUIZip"
val webUIZipFile = File(webUIZipPath)
resourceWebUI.use { input ->
webUIZipFile.outputStream().use { output ->
input.copyTo(output)
}
}
File(applicationDirs.webUIRoot).deleteRecursively()
extractDownload(webUIZipPath, applicationDirs.webUIRoot)
}
private suspend fun checkForUpdate(flavor: WebUIFlavor) {
preferences.edit().putLong(LAST_WEBUI_UPDATE_CHECK_KEY, System.currentTimeMillis()).apply()
val localVersion = getLocalVersion()
val log =
KotlinLogging.logger("${logger.name} checkForUpdate(flavor= ${flavor.uiName}, localVersion= $localVersion)")
if (!isUpdateAvailable(flavor, localVersion).second) {
log.debug { "local version is the latest one" }
return
}
log.info { "An update is available, starting download..." }
try {
downloadVersion(flavor, getLatestCompatibleVersion(flavor))
serveWebUI()
} catch (e: Exception) {
log.warn(e) { "failed due to" }
}
}
private fun getDownloadUrlFor(
flavor: WebUIFlavor,
version: String,
): String {
val baseReleasesUrl = "${flavor.repoUrl}/releases"
val downloadSpecificVersionBaseUrl = "$baseReleasesUrl/download"
return "$downloadSpecificVersionBaseUrl/$version"
}
private fun getLocalVersion(path: String = applicationDirs.webUIRoot): String =
try {
File("$path/revision").readText().trim()
} catch (_: Exception) {
"r-1"
}
private fun doesLocalWebUIExist(path: String): Boolean {
// check if we have webUI installed and is correct version
val webUIRevisionFile = File("$path/revision")
return webUIRevisionFile.exists()
}
private suspend fun isLocalWebUIValid(
flavor: WebUIFlavor,
path: String,
): Boolean {
if (!doesLocalWebUIExist(path)) {
return false
}
val log =
KotlinLogging.logger("${logger.name} isLocalWebUIValid(flavor= ${flavor.uiName}, path= $path)")
log.info { "Verifying WebUI files..." }
val currentVersion = getLocalVersion(path)
val localMD5Sum = getLocalMD5Sum(path)
val currentVersionMD5Sum = fetchMD5SumFor(flavor, currentVersion)
val validationSucceeded = currentVersionMD5Sum == localMD5Sum
log.info {
"Validation " +
"${if (validationSucceeded) "succeeded" else "failed"} - " +
"md5: local= $localMD5Sum; expected= $currentVersionMD5Sum"
}
return validationSucceeded
}
private fun getLocalMD5Sum(fileDir: String): String {
var sum = ""
File(fileDir).walk().toList().sortedBy { it.path }.forEach { file ->
if (file.isFile) {
val md5 = MessageDigest.getInstance("MD5")
md5.update(file.readBytes())
val digest = md5.digest()
sum += digest.toHex()
}
}
val md5 = MessageDigest.getInstance("MD5")
md5.update(sum.toByteArray(StandardCharsets.UTF_8))
val digest = md5.digest()
return digest.toHex()
}
private suspend fun <T> executeWithRetry(
log: KLogger,
execute: suspend () -> T,
maxRetries: Int = 3,
retryCount: Int = 0,
timeout: Duration = 2.seconds,
): T {
try {
return execute()
} catch (e: Exception) {
log.warn(e) { "(retry $retryCount/$maxRetries) failed due to" }
if (retryCount < maxRetries) {
delay(timeout.times(retryCount + 1))
return executeWithRetry(log, execute, maxRetries, retryCount + 1)
}
throw e
}
}
private suspend fun fetchMD5SumFor(
flavor: WebUIFlavor,
version: String,
): String =
try {
executeWithRetry(KotlinLogging.logger("${logger.name} fetchMD5SumFor(flavor= ${flavor.uiName}, version= $version)"), {
network.client
.newCall(GET("${getDownloadUrlFor(flavor, version)}/md5sum"))
.awaitSuccess()
.body
.string()
.trim()
})
} catch (_: Exception) {
""
}
private fun extractVersion(versionString: String): Int {
// version string is of format "r<number>"
return versionString.substring(1).toInt()
}
private suspend fun fetchPreviewVersion(flavor: WebUIFlavor): String =
previewVersionCache.get(flavor.uiName) {
executeWithRetry(
KotlinLogging.logger("${logger.name} fetchPreviewVersion(${flavor.uiName})"),
{
val releaseInfoJson =
network.client
.newCall(GET(flavor.latestReleaseInfoUrl))
.awaitSuccess()
.body
.string()
Json.decodeFromString<JsonObject>(releaseInfoJson)["tag_name"]?.jsonPrimitive?.content
?: throw Exception("Failed to get the preview version tag")
},
)
}
private suspend fun fetchServerMappingFile(flavor: WebUIFlavor): JsonArray =
versionMappingCache.get(flavor.uiName) {
executeWithRetry(
KotlinLogging.logger("$logger fetchServerMappingFile(${flavor.uiName})"),
{
json
.parseToJsonElement(
network.client
.newCall(GET(flavor.versionMappingUrl))
.awaitSuccess()
.body
.string(),
).jsonArray
},
)
}
private suspend fun getLatestCompatibleVersion(flavor: WebUIFlavor): String {
if (serverConfig.webUIChannel.value == WebUIChannel.BUNDLED) {
logger.debug { "getLatestCompatibleVersion: Channel is \"${WebUIChannel.BUNDLED}\", do not check for update" }
return BuildConfig.WEBUI_TAG
}
val currentServerVersionNumber =
BuildConfig.VERSION
.split(".")
.last()
.toInt()
val webUIToServerVersionMappings = fetchServerMappingFile(flavor)
logger.debug {
"getLatestCompatibleVersion: " +
"flavor= ${flavor.uiName}, " +
"webUIChannel= ${serverConfig.webUIChannel.value}, " +
"currentServerVersion= ${BuildConfig.VERSION}, " +
"mappingFile= $webUIToServerVersionMappings"
}
for (i in 0 until webUIToServerVersionMappings.size) {
val webUIToServerVersionEntry = webUIToServerVersionMappings[i].jsonObject
var webUIVersion =
webUIToServerVersionEntry["uiVersion"]?.jsonPrimitive?.content
?: throw Exception("Invalid mappingFile")
val minServerVersionString =
webUIToServerVersionEntry["serverVersion"]
?.jsonPrimitive
?.content
?: throw Exception("Invalid mappingFile")
val minServerVersionNumber = extractVersion(minServerVersionString)
// is a STABLE webUI release, without a specified webUI version, which requires same handling as the PREVIEW release
val isUnknownStableVersion = webUIVersion == "STABLEPREVIEW"
if (serverConfig.webUIChannel.value != WebUIChannel.from(webUIVersion)) {
// allow only STABLE versions for STABLE channel
if (serverConfig.webUIChannel.value == WebUIChannel.STABLE && !isUnknownStableVersion) {
continue
}
// allow all versions for PREVIEW channel
}
if (webUIVersion == WebUIChannel.PREVIEW.name || isUnknownStableVersion) {
webUIVersion = fetchPreviewVersion(flavor)
}
val isNewerThanBundled =
!flavor.isDefault() || extractVersion(webUIVersion) >= extractVersion(BuildConfig.WEBUI_TAG)
val isCompatibleVersion = minServerVersionNumber <= currentServerVersionNumber && isNewerThanBundled
if (isCompatibleVersion) {
return webUIVersion
}
}
throw Exception("No compatible webUI version found")
}
private fun emitStatus(
version: String,
state: UpdateState,
progress: Int,
immediate: Boolean = false,
) {
scope.launch {
val status = getStatus(version, state, progress)
if (immediate) {
notifyFlow.emit(null)
statusFlow.emit(status)
return@launch
}
notifyFlow.emit(status)
}
}
fun startDownloadInScope(
flavor: WebUIFlavor,
version: String,
) {
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launchIO {
downloadVersion(flavor, version)
serveWebUI()
}
}
private suspend fun downloadVersion(
flavor: WebUIFlavor,
version: String,
) {
emitStatus(version, DOWNLOADING, 0, immediate = true)
try {
val webUIZip = "${flavor.baseFileName}-$version.zip"
val webUIZipPath = "$tmpDir/$webUIZip"
val webUIZipURL = "${getDownloadUrlFor(flavor, version)}/$webUIZip"
val log =
KotlinLogging.logger("${logger.name} downloadVersion(version= $version, flavor= ${flavor.uiName})")
log.info { "Downloading WebUI zip from the Internet..." }
executeWithRetry(log, {
downloadVersionZipFile(flavor, webUIZipURL, webUIZipPath) { progress ->
emitStatus(
version,
DOWNLOADING,
progress,
)
}
})
File(applicationDirs.webUIRoot).deleteRecursively()
// extract webUI zip
log.info { "Extracting WebUI zip..." }
extractDownload(webUIZipPath, applicationDirs.webUIRoot)
log.info { "Extracting WebUI zip Done." }
updateServedWebUIInfo(flavor)
emitStatus(version, FINISHED, 100, immediate = true)
} catch (e: Exception) {
emitStatus(version, ERROR, 0, immediate = true)
throw e
}
}
private suspend fun downloadVersionZipFile(
flavor: WebUIFlavor,
url: String,
filePath: String,
updateProgress: (progress: Int) -> Unit,
) {
val zipFile = File(filePath)
zipFile.delete()
val data = ByteArray(1024)
zipFile.outputStream().use { webUIZipFileOut ->
val connection = URI.create(url).toURL().openConnection() as HttpURLConnection
connection.connect()
val contentLength = connection.contentLength
connection.inputStream.buffered().use { inp ->
var totalCount = 0
print("downloadVersionZipFile(${flavor.uiName}): Download progress: % 00")
while (true) {
val count = inp.read(data, 0, 1024)
if (count == -1) {
break
}
totalCount += count
val percentage = (totalCount.toFloat() / contentLength * 100).toInt()
val percentageStr = percentage.toString().padStart(2, '0')
print("\b\b$percentageStr")
webUIZipFileOut.write(data, 0, count)
updateProgress(percentage)
}
println()
logger.info { "downloadVersionZipFile(${flavor.uiName}): Downloading WebUI Done." }
}
}
if (!isDownloadValid(flavor, filePath)) {
throw Exception("Download is invalid")
}
}
private suspend fun isDownloadValid(
flavor: WebUIFlavor,
zipFilePath: String,
): Boolean {
val tempUnzippedWebUIFolderPath = zipFilePath.replace(".zip", "")
extractDownload(zipFilePath, tempUnzippedWebUIFolderPath)
val isDownloadValid = isLocalWebUIValid(flavor, tempUnzippedWebUIFolderPath)
File(tempUnzippedWebUIFolderPath).deleteRecursively()
return isDownloadValid
}
private fun extractDownload(
zipFilePath: String,
targetPath: String,
) {
File(targetPath).mkdirs()
ZipFile(zipFilePath).use { it.extractAll(targetPath) }
}
suspend fun isUpdateAvailable(
flavor: WebUIFlavor,
currentVersion: String = getLocalVersion(),
raiseError: Boolean = false,
): Pair<String, Boolean> =
try {
val isServedWebUIForCurrentFlavor = flavor.uiName == getServedWebUIFlavor().uiName
val latestCompatibleVersion = getLatestCompatibleVersion(flavor)
val isVersionUpdateAvailable = latestCompatibleVersion != currentVersion
val isUpdateAvailable = !isServedWebUIForCurrentFlavor || isVersionUpdateAvailable
Pair(latestCompatibleVersion, isUpdateAvailable)
} catch (e: Exception) {
logger.warn(e) { "isUpdateAvailable: check failed due to" }
if (raiseError) {
throw e
}
Pair("", false)
}
}