From 9edbc7f1d7b810ae9ac223a90686208b56a9693f Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 17 Feb 2024 17:23:01 +0100 Subject: [PATCH] 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 --- .../graphql/mutations/InfoMutation.kt | 7 +- .../tachidesk/graphql/queries/InfoQuery.kt | 3 +- .../server/util/WebInterfaceManager.kt | 195 ++++++++++++------ 3 files changed, 139 insertions(+), 66 deletions(-) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt index f9c3b4542..dc6f2689f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt @@ -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 } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt index 160614abf..5973bd0cd 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt @@ -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 { 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, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt index 94826a56d..b60f85364 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/WebInterfaceManager.kt @@ -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().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" } + + val hasFlavorChanged = flavor.uiName != servedFlavor.uiName + if (hasFlavorChanged) { + try { + doInitialSetup(flavor) + return + } catch (e: Exception) { + log.warn(e) { "Failed to install the version of the new flavor, proceeding with version of previous flavor" } + } + } - if (!isLocalWebUIValid(applicationDirs.webUIRoot)) { + val flavorToValidate = if (hasFlavorChanged) servedFlavor else flavor + if (!isLocalWebUIValid(flavorToValidate, applicationDirs.webUIRoot)) { try { - doInitialSetup() + doInitialSetup(flavorToValidate, isInvalid = true) } catch (e: Exception) { - logger.warn(e) { "WebUI is invalid and failed to install a valid version, proceeding with invalid version" } + 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(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 { 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" }