feat(update): refactor update checking mechanism and implement new dialog for app and plugin updates

This commit is contained in:
Kingkor Roy Tirtho 2026-09-30 09:58:04 +06:00
parent 3a85ad0896
commit 4e2afeaea4
11 changed files with 376 additions and 69 deletions

View File

@ -31,6 +31,7 @@
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
## UI component patterns
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.

View File

@ -198,4 +198,12 @@
<string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string>
<string name="update_action_ignore">Ignore</string>
<string name="update_action_update">Update</string>
<string name="app_update_dialog_title">Spotube %1$s is available</string>
<string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
<string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
</resources>

View File

@ -22,30 +22,20 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
@ -53,7 +43,10 @@ import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
import dev.krtirtho.spotube.modules.update.PluginUpdateService
import dev.krtirtho.spotube.modules.webview.WebViewScreen
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
@ -69,7 +62,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI
interface NavigationItem {
@ -143,8 +135,10 @@ fun App(
) {
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
val appUpdateService: AppUpdateService = koinInject()
val pluginUpdateService: PluginUpdateService = koinInject()
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
val navigationState = rememberNavigationState(
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
@ -182,42 +176,27 @@ fun App(
}
}
content()
if (availableUpdate != null) {
val update = availableUpdate!!
ThemedDialog(
onDismissRequest = updateCheckerViewModel::dismissUpdate,
title = {
Text(
text = "Spotube ${update.tag} is available",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
availableAppUpdate?.let { update ->
AppUpdateDialog(
update = update,
onDismiss = appUpdateService::dismissUpdate,
onIgnore = appUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
appUpdateService.dismissUpdate()
},
actions = {
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
Text("Ignore")
}
PrimaryButton(
onClick = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
updateCheckerViewModel.dismissUpdate()
},
) {
Text("Update")
}
)
}
availablePluginUpdate?.let { update ->
PluginUpdateDialog(
update = update,
onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate()
},
) {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(
content = update.releaseNotesMarkdown,
modifier = Modifier.padding(bottom = 8.dp),
)
}
} else {
Text("See what's new in this release on the Spotube downloads page.")
}
}
)
}
}
}

View File

@ -73,7 +73,8 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateService
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
@ -136,7 +137,10 @@ val sharedModules = module {
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
viewModelOf(::UpdateCheckerViewModel)
// Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
// Downloads
singleOf(::DownloadManager)

View File

@ -87,7 +87,8 @@ data class UserSettings(
// Updates
val autoCheckForUpdates: Boolean = true,
val ignoredUpdateVersion: String? = null,
val ignoredAppUpdateVersion: String? = null,
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
)
/**

View File

@ -0,0 +1,80 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.update
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.app_update_dialog_no_notes
import spotube.composeapp.generated.resources.app_update_dialog_title
import spotube.composeapp.generated.resources.update_action_ignore
import spotube.composeapp.generated.resources.update_action_update
/**
* Prompts the user that a newer Spotube release is available on GitHub. [onUpdate]
* should take the user to the downloads page; [onIgnore] persistently silences this
* specific release tag so the check moves on to the next newer one.
*/
@Composable
fun AppUpdateDialog(
update: AvailableAppUpdate,
onDismiss: () -> Unit,
onIgnore: () -> Unit,
onUpdate: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(Res.string.app_update_dialog_title, update.tag),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = onIgnore) {
Text(stringResource(Res.string.update_action_ignore))
}
PrimaryButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
},
) {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(
content = update.releaseNotesMarkdown,
modifier = Modifier.padding(bottom = 8.dp),
)
}
} else {
Text(stringResource(Res.string.app_update_dialog_no_notes))
}
}
}

View File

@ -17,8 +17,6 @@
package dev.krtirtho.spotube.modules.update
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dev.krtirtho.spotube.AppBuildInfo
import dev.krtirtho.spotube.modules.settings.SettingsRepository
@ -30,6 +28,9 @@ import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -38,27 +39,35 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent
data class AvailableUpdate(
data class AvailableAppUpdate(
val tag: String,
val releaseNotesMarkdown: String,
)
class UpdateCheckerViewModel(
/**
* Checks GitHub for a newer app release once on startup and exposes it for the UI to
* surface via [dev.krtirtho.spotube.modules.update.AppUpdateDialog]. This is an
* app-scoped singleton (not a ViewModel) since the check runs once for the whole
* process, independent of any screen's lifecycle.
*/
class AppUpdateService(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val logger = Logger.withTag("UpdateChecker")
) : KoinComponent, AutoCloseable {
private val logger = Logger.withTag("AppUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
private val _availableUpdate = MutableStateFlow<AvailableUpdate?>(null)
val availableUpdate: StateFlow<AvailableUpdate?> = _availableUpdate.asStateFlow()
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
init {
viewModelScope.launch {
scope.launch {
checkForUpdate()
}
}
@ -80,8 +89,8 @@ class UpdateCheckerViewModel(
val currentVersion = parseVersion(AppBuildInfo.VERSION)
?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" }
if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) {
_availableUpdate.value = AvailableUpdate(
if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) {
_availableUpdate.value = AvailableAppUpdate(
tag = release.tagName,
releaseNotesMarkdown = release.body.orEmpty(),
)
@ -94,10 +103,10 @@ class UpdateCheckerViewModel(
fun ignoreUpdate() {
val update = _availableUpdate.value ?: return
_availableUpdate.value = null
viewModelScope.launch {
scope.launch {
runCatching {
val settings = settingsRepository.getCurrentSettings()
settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag))
settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag))
}.onFailure { e ->
logger.e(e) { "Failed to persist ignored app version" }
}
@ -112,7 +121,7 @@ class UpdateCheckerViewModel(
SemVer.parse(version)
}.getOrNull()
override fun onCleared() {
override fun close() {
httpClient.close()
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.update
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_update_dialog_current_version
import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes
import spotube.composeapp.generated.resources.plugin_update_dialog_title
import spotube.composeapp.generated.resources.update_action_ignore
import spotube.composeapp.generated.resources.update_action_update
/**
* Prompts the user that a newer version of one of their installed plugins is
* available. [onUpdate] should open the plugin's direct download URL; [onIgnore]
* persistently silences this specific plugin version so the check moves on to the
* next newer one (or another plugin).
*/
@Composable
fun PluginUpdateDialog(
update: AvailablePluginUpdate,
onDismiss: () -> Unit,
onIgnore: () -> Unit,
onUpdate: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(
Res.string.plugin_update_dialog_title,
update.pluginName,
update.info.latestVersion,
),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = onIgnore) {
Text(stringResource(Res.string.update_action_ignore))
}
PrimaryButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
},
) {
Text(
text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
if (update.info.releaseNotes.isNotBlank()) {
SelectionContainer {
Markdown(content = update.info.releaseNotes)
}
} else {
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
}
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.update
import co.touchlab.kermit.Logger
import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo
import dev.krtirtho.spotube.core.zipline.PluginService
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent
data class AvailablePluginUpdate(
val pluginId: String,
val pluginName: String,
val currentVersion: String,
val info: PluginUpdateInfo,
)
/**
* Checks the currently active plugins for updates through their own [PluginService.use]
* -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and
* exposes at most one pending update at a time for the UI to surface via
* [dev.krtirtho.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped
* singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager]
* independent of any screen's lifecycle.
*/
class PluginUpdateService(
private val pluginManager: PluginManager,
private val settingsRepository: SettingsRepository,
) : KoinComponent {
private val logger = Logger.withTag("PluginUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _availableUpdate = MutableStateFlow<AvailablePluginUpdate?>(null)
val availableUpdate: StateFlow<AvailablePluginUpdate?> = _availableUpdate.asStateFlow()
init {
scope.launch {
pluginManager.ziplineServices.filterNotNull().collect { services ->
checkForUpdates(services.values.filterNotNull())
}
}
}
private suspend fun checkForUpdates(services: List<PluginService>) {
// Only surface one plugin update prompt at a time; re-checks happen the
// next time the selected plugin set changes.
if (_availableUpdate.value != null) return
val settings = settingsRepository.getCurrentSettings()
if (!settings.autoCheckForUpdates) return
for (service in services.distinctBy { it.pluginId }) {
val pluginEntry = findPluginEntry(service.pluginId) ?: continue
val currentVersion = parseVersion(pluginEntry.version) ?: continue
try {
val updateInfo = service.use { coreAPI.checkPluginUpdates(currentVersion) } ?: continue
val latestVersion = parseVersion(updateInfo.latestVersion)
if (latestVersion == null) {
logger.w { "Ignoring plugin update with invalid SemVer: ${updateInfo.latestVersion}" }
continue
}
val ignoredVersion = settings.ignoredPluginUpdateVersions[pluginEntry.id]
if (latestVersion > currentVersion && updateInfo.latestVersion != ignoredVersion) {
_availableUpdate.value = AvailablePluginUpdate(
pluginId = pluginEntry.id,
pluginName = pluginEntry.name,
currentVersion = pluginEntry.version,
info = updateInfo,
)
return
}
} catch (e: Exception) {
logger.w(e) { "Failed to check updates for plugin ${pluginEntry.name}" }
}
}
}
private fun findPluginEntry(pluginId: String): PluginEntry? {
val state = pluginManager.state.value as? PluginManagerStates.Data ?: return null
return state.plugins.find { it.id == pluginId }
}
fun ignoreUpdate() {
val update = _availableUpdate.value ?: return
_availableUpdate.value = null
scope.launch {
runCatching {
val settings = settingsRepository.getCurrentSettings()
settingsRepository.updateSettings(
settings.copy(
ignoredPluginUpdateVersions = settings.ignoredPluginUpdateVersions +
(update.pluginId to update.info.latestVersion)
)
)
}.onFailure { e ->
logger.e(e) { "Failed to persist ignored plugin update version" }
}
}
}
fun dismissUpdate() {
_availableUpdate.value = null
}
private fun parseVersion(version: String): SemVer? = runCatching {
SemVer.parse(version)
}.getOrNull()
}

View File

@ -26,5 +26,4 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac6
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@ -50,7 +50,7 @@ multiplatformMarkdownRenderer = "0.45.0"
murmurhash = "0.4.2"
newpipeextractor = "v0.26.5"
newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.6"
nucleusNucleusApplication = "2.5.18"
semver = "2.1.0"
vlcj = "4.12.1"
vlcjNative = "4.12.0"
@ -61,8 +61,8 @@ kotlinStdlib = "2.4.10"
runner = "1.7.0"
core = "1.7.0"
filekit = "0.14.2"
compose-webview = "1.0.1"
composeNativeTray = "2.0.3"
compose-webview = "1.0.3"
composeNativeTray = "2.1.6"
koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-alpha05"