Feature/support different webui flavors (#863)

* Run functions for specific webui flavor

* Set default flavor of WebUIFlavor enum

* Consider flavor of served webUI when checking for update

In case the flavor was changed and the served webui files are still for the previous flavor, the update check could incorrectly detect no update

* Skip validation during initial setup

In case initial setup is triggered because of an invalid local webUI, doing a validation again is unnecessary

* Handle changed flavor on startup
This commit is contained in:
schroda
2024-02-17 17:23:01 +01:00
committed by GitHub
parent 8aa75be0d3
commit 9edbc7f1d7
3 changed files with 139 additions and 66 deletions
@@ -8,6 +8,7 @@ import suwayomi.tachidesk.graphql.types.UpdateState.IDLE
import suwayomi.tachidesk.graphql.types.WebUIUpdateStatus
import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.util.WebInterfaceManager
import suwayomi.tachidesk.server.util.WebUIFlavor
import java.util.concurrent.CompletableFuture
import kotlin.time.Duration.Companion.seconds
@@ -28,7 +29,9 @@ class InfoMutation {
return@withTimeout WebUIUpdatePayload(input.clientMutationId, WebInterfaceManager.status.value)
}
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable()
val flavor = WebUIFlavor.current
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(flavor)
if (!updateAvailable) {
val didUpdateCheckFail = version.isEmpty()
@@ -39,7 +42,7 @@ class InfoMutation {
)
}
try {
WebInterfaceManager.startDownloadInScope(version)
WebInterfaceManager.startDownloadInScope(flavor, version)
} catch (e: Exception) {
// ignore since we use the status anyway
}
@@ -8,6 +8,7 @@ import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.util.WebInterfaceManager
import suwayomi.tachidesk.server.util.WebUIFlavor
import java.util.concurrent.CompletableFuture
class InfoQuery {
@@ -60,7 +61,7 @@ class InfoQuery {
fun checkForWebUIUpdate(): CompletableFuture<WebUIUpdateCheck> {
return future {
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(raiseError = true)
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(WebUIFlavor.current, raiseError = true)
WebUIUpdateCheck(
channel = serverConfig.webUIChannel.value,
tag = version,
@@ -119,7 +119,12 @@ enum class WebUIFlavor(
;
companion object {
fun from(value: String): WebUIFlavor = entries.find { it.uiName == value } ?: WEBUI
val default: WebUIFlavor = WEBUI
fun from(value: String): WebUIFlavor = entries.find { it.uiName == value } ?: default
val current: WebUIFlavor
get() = from(serverConfig.webUIFlavor.value)
}
}
@@ -128,6 +133,7 @@ object WebInterfaceManager {
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 val preferences = Injekt.get<Application>().getSharedPreferences("server_util", Context.MODE_PRIVATE)
private var currentUpdateTaskId: String = ""
@@ -206,6 +212,14 @@ object WebInterfaceManager {
this.serveWebUI = serveWebUI
}
private fun setServedWebUIFlavor(flavor: WebUIFlavor) {
preferences.edit().putString(SERVED_WEBUI_FLAVOR_KEY, flavor.uiName).apply()
}
private fun getServedWebUIFlavor(): WebUIFlavor {
return WebUIFlavor.from(preferences.getString(SERVED_WEBUI_FLAVOR_KEY, WebUIFlavor.default.uiName)!!)
}
private fun isAutoUpdateEnabled(): Boolean {
return serverConfig.webUIUpdateCheckInterval.value.toInt() != 0
}
@@ -224,6 +238,7 @@ object WebInterfaceManager {
val task = {
logger.debug {
"Checking for webUI update (" +
"flavor= ${WebUIFlavor.current.uiName}, " +
"channel= ${serverConfig.webUIChannel.value}, " +
"interval= ${serverConfig.webUIUpdateCheckInterval.value}h, " +
"lastAutomatedUpdate= ${
@@ -234,7 +249,7 @@ object WebInterfaceManager {
}
runBlocking {
checkForUpdate()
checkForUpdate(WebUIFlavor.current)
}
}
@@ -253,49 +268,66 @@ object WebInterfaceManager {
return
}
val flavor = WebUIFlavor.current
val servedFlavor = getServedWebUIFlavor()
val log =
KotlinLogging.logger("${logger.name} setupWebUI(flavor= ${flavor.uiName}, servedFlavor= ${servedFlavor.uiName})")
if (doesLocalWebUIExist(applicationDirs.webUIRoot)) {
val currentVersion = getLocalVersion()
logger.info { "setupWebUI: found webUI files - flavor= ${serverConfig.webUIFlavor.value}, version= $currentVersion" }
log.info { "found webUI files - version= $currentVersion" }
if (!isLocalWebUIValid(applicationDirs.webUIRoot)) {
val hasFlavorChanged = flavor.uiName != servedFlavor.uiName
if (hasFlavorChanged) {
try {
doInitialSetup()
doInitialSetup(flavor)
return
} catch (e: Exception) {
logger.warn(e) { "WebUI is invalid and failed to install a valid version, proceeding with invalid version" }
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)
} catch (e: Exception) {
log.warn(e) { "WebUI is invalid and failed to install a valid version, proceeding with invalid version" }
}
return
}
if (isAutoUpdateEnabled()) {
checkForUpdate()
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 =
serverConfig.webUIFlavor.value == WebUIFlavor.WEBUI.uiName && extractVersion(getLocalVersion()) <
flavor.uiName == WebUIFlavor.default.uiName && extractVersion(getLocalVersion()) <
extractVersion(
BuildConfig.WEBUI_TAG,
)
if (shouldUpdateToBundledVersion) {
logger.debug { "setupWebUI: update to bundled version \"${BuildConfig.WEBUI_TAG}\"" }
log.debug { "update to bundled version \"${BuildConfig.WEBUI_TAG}\"" }
try {
setupBundledWebUI()
} catch (e: Exception) {
logger.error(e) { "setupWebUI: failed the update to the bundled webUI" }
log.error(e) { "failed the update to the bundled webUI" }
}
}
return
}
logger.warn { "setupWebUI: no webUI files found, starting download..." }
log.warn { "no webUI files found, starting download..." }
try {
doInitialSetup()
doInitialSetup(flavor)
} catch (e: Exception) {
logger.error(e) {
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..."
}
@@ -306,8 +338,14 @@ object WebInterfaceManager {
/**
* 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() {
val isLocalWebUIValid = isLocalWebUIValid(applicationDirs.webUIRoot)
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.
@@ -316,7 +354,7 @@ object WebInterfaceManager {
*/
val doDownload: suspend (getVersion: suspend () -> String) -> Boolean = { getVersion ->
try {
downloadVersion(getVersion())
downloadVersion(flavor, getVersion())
true
} catch (e: Exception) {
false
@@ -324,23 +362,23 @@ object WebInterfaceManager {
}
// download the latest compatible version for the current selected webUI
val fallbackToDefaultWebUI = !doDownload { getLatestCompatibleVersion() }
val fallbackToDefaultWebUI = !doDownload { getLatestCompatibleVersion(flavor) }
if (!fallbackToDefaultWebUI) {
return
}
if (serverConfig.webUIFlavor.value != WebUIFlavor.WEBUI.uiName) {
logger.warn { "doInitialSetup: fallback to default webUI \"${WebUIFlavor.WEBUI.uiName}\"" }
if (flavor.uiName != WebUIFlavor.default.uiName) {
log.warn { "fallback to default webUI \"${WebUIFlavor.default.uiName}\"" }
serverConfig.webUIFlavor.value = WebUIFlavor.WEBUI.uiName
serverConfig.webUIFlavor.value = WebUIFlavor.default.uiName
val fallbackToBundledVersion = !doDownload { getLatestCompatibleVersion() }
val fallbackToBundledVersion = !doDownload { getLatestCompatibleVersion(flavor) }
if (!fallbackToBundledVersion) {
return
}
}
logger.warn { "doInitialSetup: fallback to bundled default webUI \"${WebUIFlavor.WEBUI.uiName}\"" }
log.warn { "fallback to bundled default webUI \"${WebUIFlavor.default.uiName}\"" }
try {
setupBundledWebUI()
@@ -352,12 +390,13 @@ object WebInterfaceManager {
private suspend fun setupBundledWebUI() {
try {
extractBundledWebUI()
setServedWebUIFlavor(WebUIFlavor.default)
return
} catch (e: BundledWebUIMissing) {
logger.warn(e) { "setupBundledWebUI: fallback to downloading the version of the bundled webUI" }
}
downloadVersion(BuildConfig.WEBUI_TAG)
downloadVersion(WebUIFlavor.default, BuildConfig.WEBUI_TAG)
}
private fun extractBundledWebUI() {
@@ -366,7 +405,7 @@ object WebInterfaceManager {
logger.info { "extractBundledWebUI: Using the bundled WebUI zip..." }
val webUIZip = WebUIFlavor.WEBUI.baseFileName
val webUIZip = WebUIFlavor.default.baseFileName
val webUIZipPath = "$tmpDir/$webUIZip"
val webUIZipFile = File(webUIZipPath)
resourceWebUI.use { input ->
@@ -379,25 +418,31 @@ object WebInterfaceManager {
extractDownload(webUIZipPath, applicationDirs.webUIRoot)
}
private suspend fun checkForUpdate() {
private suspend fun checkForUpdate(flavor: WebUIFlavor) {
preferences.edit().putLong(LAST_WEBUI_UPDATE_CHECK_KEY, System.currentTimeMillis()).apply()
val localVersion = getLocalVersion()
if (!isUpdateAvailable(localVersion).second) {
logger.debug { "checkForUpdate(${serverConfig.webUIFlavor.value}, $localVersion): local version is the latest one" }
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
}
logger.info { "checkForUpdate(${serverConfig.webUIFlavor.value}, $localVersion): An update is available, starting download..." }
log.info { "An update is available, starting download..." }
try {
downloadVersion(getLatestCompatibleVersion())
downloadVersion(flavor, getLatestCompatibleVersion(flavor))
} catch (e: Exception) {
logger.warn(e) { "checkForUpdate: failed due to" }
log.warn(e) { "failed due to" }
}
}
private fun getDownloadUrlFor(version: String): String {
val baseReleasesUrl = "${WebUIFlavor.WEBUI.repoUrl}/releases"
private fun getDownloadUrlFor(
flavor: WebUIFlavor,
version: String,
): String {
val baseReleasesUrl = "${flavor.repoUrl}/releases"
val downloadSpecificVersionBaseUrl = "$baseReleasesUrl/download"
return "$downloadSpecificVersionBaseUrl/$version"
@@ -417,20 +462,25 @@ object WebInterfaceManager {
return webUIRevisionFile.exists()
}
private suspend fun isLocalWebUIValid(path: String): Boolean {
private suspend fun isLocalWebUIValid(
flavor: WebUIFlavor,
path: String,
): Boolean {
if (!doesLocalWebUIExist(path)) {
return false
}
logger.info { "isLocalWebUIValid: Verifying WebUI files..." }
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(currentVersion)
val currentVersionMD5Sum = fetchMD5SumFor(flavor, currentVersion)
val validationSucceeded = currentVersionMD5Sum == localMD5Sum
logger.info {
"isLocalWebUIValid: Validation " +
log.info {
"Validation " +
"${if (validationSucceeded) "succeeded" else "failed"} - " +
"md5: local= $localMD5Sum; expected= $currentVersionMD5Sum"
}
@@ -474,10 +524,13 @@ object WebInterfaceManager {
}
}
private suspend fun fetchMD5SumFor(version: String): String {
private suspend fun fetchMD5SumFor(
flavor: WebUIFlavor,
version: String,
): String {
return try {
executeWithRetry(KotlinLogging.logger("${logger.name} fetchMD5SumFor($version)"), {
network.client.newCall(GET("${getDownloadUrlFor(version)}/md5sum")).awaitSuccess().body.string().trim()
executeWithRetry(KotlinLogging.logger("${logger.name} fetchMD5SumFor(flavor= ${flavor.uiName}, version= $version)"), {
network.client.newCall(GET("${getDownloadUrlFor(flavor, version)}/md5sum")).awaitSuccess().body.string().trim()
})
} catch (e: Exception) {
""
@@ -489,36 +542,37 @@ object WebInterfaceManager {
return versionString.substring(1).toInt()
}
private suspend fun fetchPreviewVersion(): String {
return executeWithRetry(KotlinLogging.logger("${logger.name} fetchPreviewVersion"), {
val releaseInfoJson = network.client.newCall(GET(WebUIFlavor.WEBUI.latestReleaseInfoUrl)).awaitSuccess().body.string()
private suspend fun fetchPreviewVersion(flavor: WebUIFlavor): String {
return 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(): JsonArray {
private suspend fun fetchServerMappingFile(flavor: WebUIFlavor): JsonArray {
return executeWithRetry(
KotlinLogging.logger("$logger fetchServerMappingFile"),
KotlinLogging.logger("$logger fetchServerMappingFile(${flavor.uiName})"),
{
json.parseToJsonElement(
network.client.newCall(GET(WebUIFlavor.WEBUI.versionMappingUrl)).awaitSuccess().body.string(),
network.client.newCall(GET(flavor.versionMappingUrl)).awaitSuccess().body.string(),
).jsonArray
},
)
}
private suspend fun getLatestCompatibleVersion(): String {
private suspend fun getLatestCompatibleVersion(flavor: WebUIFlavor): String {
if (WebUIChannel.doesConfigChannelEqual(WebUIChannel.BUNDLED)) {
logger.debug { "getLatestCompatibleVersion: Channel is \"${WebUIChannel.BUNDLED}\", do not check for update" }
return BuildConfig.WEBUI_TAG
}
val currentServerVersionNumber = extractVersion(BuildConfig.REVISION)
val webUIToServerVersionMappings = fetchServerMappingFile()
val webUIToServerVersionMappings = fetchServerMappingFile(flavor)
logger.debug {
"getLatestCompatibleVersion: " +
"flavor= ${flavor.uiName}, " +
"webUIChannel= ${serverConfig.webUIChannel.value}, " +
"currentServerVersion= ${BuildConfig.REVISION}, " +
"mappingFile= $webUIToServerVersionMappings"
@@ -545,7 +599,7 @@ object WebInterfaceManager {
}
if (webUIVersion == WebUIChannel.PREVIEW.name) {
webUIVersion = fetchPreviewVersion()
webUIVersion = fetchPreviewVersion(flavor)
}
val isCompatibleVersion =
@@ -579,26 +633,32 @@ object WebInterfaceManager {
}
}
fun startDownloadInScope(version: String) {
fun startDownloadInScope(
flavor: WebUIFlavor,
version: String,
) {
scope.launch {
downloadVersion(version)
downloadVersion(flavor, version)
}
}
suspend fun downloadVersion(version: String) {
suspend fun downloadVersion(
flavor: WebUIFlavor,
version: String,
) {
emitStatus(version, DOWNLOADING, 0, immediate = true)
try {
val webUIZip = "${WebUIFlavor.WEBUI.baseFileName}-$version.zip"
val webUIZip = "${flavor.baseFileName}-$version.zip"
val webUIZipPath = "$tmpDir/$webUIZip"
val webUIZipURL = "${getDownloadUrlFor(version)}/$webUIZip"
val webUIZipURL = "${getDownloadUrlFor(flavor, version)}/$webUIZip"
val log =
KotlinLogging.logger("${logger.name} downloadVersion(version= $version, flavor= ${serverConfig.webUIFlavor.value})")
KotlinLogging.logger("${logger.name} downloadVersion(version= $version, flavor= ${flavor.uiName})")
log.info { "Downloading WebUI zip from the Internet..." }
executeWithRetry(log, {
downloadVersionZipFile(webUIZipURL, webUIZipPath) { progress ->
downloadVersionZipFile(flavor, webUIZipURL, webUIZipPath) { progress ->
emitStatus(
version,
DOWNLOADING,
@@ -613,6 +673,8 @@ object WebInterfaceManager {
extractDownload(webUIZipPath, applicationDirs.webUIRoot)
log.info { "Extracting WebUI zip Done." }
setServedWebUIFlavor(flavor)
emitStatus(version, FINISHED, 100, immediate = true)
serveWebUI()
@@ -623,6 +685,7 @@ object WebInterfaceManager {
}
private suspend fun downloadVersionZipFile(
flavor: WebUIFlavor,
url: String,
filePath: String,
updateProgress: (progress: Int) -> Unit,
@@ -641,7 +704,7 @@ object WebInterfaceManager {
connection.inputStream.buffered().use { inp ->
var totalCount = 0
print("downloadVersionZipFile: Download progress: % 00")
print("downloadVersionZipFile(${flavor.uiName}): Download progress: % 00")
while (true) {
val count = inp.read(data, 0, 1024)
@@ -659,21 +722,24 @@ object WebInterfaceManager {
updateProgress(percentage)
}
println()
logger.info { "downloadVersionZipFile: Downloading WebUI Done." }
logger.info { "downloadVersionZipFile(${flavor.uiName}): Downloading WebUI Done." }
}
}
if (!isDownloadValid(filePath)) {
if (!isDownloadValid(flavor, filePath)) {
throw Exception("Download is invalid")
}
}
private suspend fun isDownloadValid(zipFilePath: String): Boolean {
private suspend fun isDownloadValid(
flavor: WebUIFlavor,
zipFilePath: String,
): Boolean {
val tempUnzippedWebUIFolderPath = zipFilePath.replace(".zip", "")
extractDownload(zipFilePath, tempUnzippedWebUIFolderPath)
val isDownloadValid = isLocalWebUIValid(tempUnzippedWebUIFolderPath)
val isDownloadValid = isLocalWebUIValid(flavor, tempUnzippedWebUIFolderPath)
File(tempUnzippedWebUIFolderPath).deleteRecursively()
@@ -689,13 +755,16 @@ object WebInterfaceManager {
}
suspend fun isUpdateAvailable(
flavor: WebUIFlavor,
currentVersion: String = getLocalVersion(),
raiseError: Boolean = false,
): Pair<String, Boolean> {
return try {
val latestCompatibleVersion = getLatestCompatibleVersion()
val isUpdateAvailable = latestCompatibleVersion != currentVersion
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" }