diff --git a/SeforimApp/build.gradle.kts b/SeforimApp/build.gradle.kts index d3d94d2d0..41bd5e362 100644 --- a/SeforimApp/build.gradle.kts +++ b/SeforimApp/build.gradle.kts @@ -172,6 +172,8 @@ kotlin { implementation(libs.nucleus.decorated.window.jewel) implementation(libs.nucleus.graalvm.runtime) implementation(libs.nucleus.updater.runtime) + // Updates of the Microsoft Store install, from the Store itself + implementation(libs.nucleus.microsoft.store) implementation(libs.nucleus.native.http) implementation(libs.nucleus.energy.manager) implementation(libs.nucleus.launcher.macos) @@ -333,8 +335,9 @@ nucleus.application { backgroundColor = "transparent" minVersion = "10.0.17763.0" maxVersionTested = "10.0.26100.0" - storeLogo.set(project.file("desktopAppIcons/appx/StoreLogo.png")) - square44x44Logo.set(project.file("desktopAppIcons/appx/Square44x44Logo.png")) + // Sources at least 4x their size: Nucleus makes every scale and target size from them + storeLogo.set(project.file("desktopAppIcons/LinuxIcon.png")) + square44x44Logo.set(project.file("desktopAppIcons/LinuxIcon.png")) square150x150Logo.set(project.file("desktopAppIcons/appx/Square150x150Logo.png")) wide310x150Logo.set(project.file("desktopAppIcons/appx/Wide310x150Logo.png")) } diff --git a/SeforimApp/desktopAppIcons/appx/Square150x150Logo.png b/SeforimApp/desktopAppIcons/appx/Square150x150Logo.png index 6d9d8a180..282fad4f0 100644 Binary files a/SeforimApp/desktopAppIcons/appx/Square150x150Logo.png and b/SeforimApp/desktopAppIcons/appx/Square150x150Logo.png differ diff --git a/SeforimApp/desktopAppIcons/appx/Square44x44Logo.png b/SeforimApp/desktopAppIcons/appx/Square44x44Logo.png deleted file mode 100644 index b6aaaa89b..000000000 Binary files a/SeforimApp/desktopAppIcons/appx/Square44x44Logo.png and /dev/null differ diff --git a/SeforimApp/desktopAppIcons/appx/StoreLogo.png b/SeforimApp/desktopAppIcons/appx/StoreLogo.png deleted file mode 100644 index aa8aa801d..000000000 Binary files a/SeforimApp/desktopAppIcons/appx/StoreLogo.png and /dev/null differ diff --git a/SeforimApp/desktopAppIcons/appx/Wide310x150Logo.png b/SeforimApp/desktopAppIcons/appx/Wide310x150Logo.png index e4ca667ec..d33ec740f 100644 Binary files a/SeforimApp/desktopAppIcons/appx/Wide310x150Logo.png and b/SeforimApp/desktopAppIcons/appx/Wide310x150Logo.png differ diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateService.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateService.kt index dbdf73e18..2f70cecdb 100644 --- a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateService.kt +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/AppUpdateService.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.Stable import dev.nucleusframework.core.runtime.ExecutableRuntime import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.nativehttp.NativeHttpClient +import dev.nucleusframework.store.microsoft.StoreUpdateCheck +import dev.nucleusframework.store.microsoft.StoreUpdateState import dev.nucleusframework.updater.DownloadProgress import dev.nucleusframework.updater.NucleusUpdater import dev.nucleusframework.updater.UpdateInfo @@ -232,6 +234,8 @@ data class AppUpdateConfig( * [installPendingOnClose] silently on close. * - Other Linux PATCH → pre-download at startup, then prompt (icon + dialog) since `pkexec` needs interaction. * - Any MINOR/MAJOR → prompt; download on user action; warns about a possible ~3.5 GB DB update. + * - The Microsoft Store install ([storeUpdates]) → asks the Store instead of the GitHub feed, always prompts (the + * Store installs behind its own dialog and Windows relaunches the app), and opens the dialog for a mandatory one. * * [checkOnStartup] is suspending (driven by the startup `LaunchedEffect`); [startDownload] launches * on the app-lifetime [scope] so a user-triggered download survives the dialog being closed. @@ -242,6 +246,7 @@ class AppUpdateService( private val config: AppUpdateConfig, private val os: Platform, private val linuxPasswordless: Boolean = false, + private val storeUpdates: StoreUpdates? = null, // App-lifetime scope so a user-triggered download keeps running after the dialog is closed. private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) { @@ -261,6 +266,7 @@ class AppUpdateService( if (config.fakeState != null) return // state pre-seeded by initialFakeState() if (_state.value != UpdateUiState.Idle) return _state.value = UpdateUiState.Checking + storeUpdates?.let { return checkStore(it) } // Building the updater reads the system trust store: not on the UI thread this startup check is called from val updater = withContext(Dispatchers.IO) { updater } when (val result = updater.checkForUpdates()) { @@ -309,6 +315,10 @@ class AppUpdateService( val current = _state.value if (current !is UpdateUiState.Available) return if (downloadJob?.isActive == true) return + storeUpdates?.let { + downloadJob = launchStoreInstall(scope, it, current) + return + } val info = pendingInfo ?: return downloadJob = launchDownload(scope, info, current.level, current.mode, current.needsDbWarning) } @@ -348,6 +358,67 @@ class AppUpdateService( } } + private suspend fun checkStore(store: StoreUpdates) { + try { + when (val check = store.check()) { + StoreUpdateCheck.UpToDate -> _state.value = UpdateUiState.UpToDate + is StoreUpdateCheck.Available -> { + val level = storeUpdateLevel(store.currentVersion, check.version) + val version = check.version?.let(::storeSemanticVersion).orEmpty() + infoln { "[update] Store has v$version level=$level mandatory=${check.isMandatory}" } + val available = UpdateUiState.Available(version, level, UpdateMode.PROMPT, needsDbWarning(level)) + _state.value = available + if (check.isMandatory) openDialog() + } + } + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + errorln { "[update] Store check failed: ${e.message}" } + _state.value = UpdateUiState.Error(e.message ?: "update check failed") + } + } + + private fun launchStoreInstall( + @StructuredScope scope: CoroutineScope, + store: StoreUpdates, + available: UpdateUiState.Available, + ): Job = scope.launch { installFromStore(store, available) } + + /** Windows closes the app during [StoreUpdateState.Installing]; the states after it only show if it does not. */ + private suspend fun installFromStore( + store: StoreUpdates, + available: UpdateUiState.Available, + ) { + try { + infoln { "[update] Store install start v${available.version}" } + store.install().collect { progress -> + _state.value = + when (progress.state) { + StoreUpdateState.Completed, StoreUpdateState.UpToDate -> UpdateUiState.UpToDate + StoreUpdateState.Cancelled -> available + else -> + UpdateUiState.Downloading( + available.version, + available.level, + available.mode, + available.needsDbWarning, + (progress.progress * PERCENT).toInt(), + ) + } + } + } catch (e: CancellationException) { + throw e + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + errorln { "[update] Store install failed: ${e.message}" } + _state.value = UpdateUiState.Error(e.message ?: "install failed") + } + } + /** * The Linux helper only installs a package whose detached `.asc` was downloaded next to it; * without it Nucleus falls back to `pkexec`, so prompt now rather than ask for a password on close. @@ -402,7 +473,7 @@ class AppUpdateService( _dialogVisible.value = false } - fun isUpdateSupported(): Boolean = config.fakeState != null || updater.isUpdateSupported() + fun isUpdateSupported(): Boolean = config.fakeState != null || storeUpdates != null || updater.isUpdateSupported() private fun initialFakeState(): UpdateUiState? { val fake = config.fakeState ?: return null @@ -430,6 +501,8 @@ class AppUpdateService( companion object { const val DOWNLOAD_URL = "https://kdroidfilter.github.io/Zayit/download" + private const val PERCENT = 100 + private const val GITHUB_OWNER = "kdroidFilter" private const val GITHUB_REPO = "Zayit" @@ -443,7 +516,7 @@ class AppUpdateService( // Releases ship several assets per platform; force the auto-update format so the // feed picks the silent installer (NSIS on Windows, .zip on macOS) and not the // user-facing Rust wrapper exe / .dmg. The env override still wins when set. The Store's - // package (AppX) keeps its own type: the Store updates it, Nucleus then reports no update support. + // package (AppX) keeps its own type: Nucleus then reports no update support, storeUpdates has them. val resolvedExecutableType = config.executableType ?: when (os) { @@ -467,6 +540,13 @@ class AppUpdateService( config = config, os = os, linuxPasswordless = os == Platform.Linux && hasLinuxUpdateHelper(), + // The Store's install, unless a feed is forced for QA + storeUpdates = + if (os == Platform.Windows && ExecutableRuntime.isAppX() && config.feedUrl == null) { + MicrosoftStoreUpdatesAdapter(dryRun = config.dryRun) + } else { + null + }, ) } } diff --git a/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/StoreUpdates.kt b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/StoreUpdates.kt new file mode 100644 index 000000000..1d654e638 --- /dev/null +++ b/SeforimApp/src/jvmMain/kotlin/io/github/kdroidfilter/seforimapp/framework/update/StoreUpdates.kt @@ -0,0 +1,58 @@ +package io.github.kdroidfilter.seforimapp.framework.update + +import dev.nucleusframework.core.runtime.NucleusApp +import dev.nucleusframework.store.microsoft.MicrosoftStore +import dev.nucleusframework.store.microsoft.StoreUpdateCheck +import dev.nucleusframework.store.microsoft.StoreUpdateProgress +import dev.nucleusframework.updater.UpdateLevel +import dev.nucleusframework.updater.Version +import io.github.kdroidfilter.seforimapp.logger.infoln +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +/** + * The updates of the Microsoft Store install (AppX): the Store, not the GitHub feed, has its new versions, + * and installs them itself. Thin seam over [MicrosoftStore.updates] so [AppUpdateService] can be tested with a fake. + */ +interface StoreUpdates { + val currentVersion: String + + /** Whether the Store has a newer version; silent. */ + suspend fun check(): StoreUpdateCheck + + /** Downloads and installs it behind the Store's dialog; Windows closes the app to install, then relaunches it. */ + fun install(): Flow +} + +/** Real [StoreUpdates] backed by Nucleus' Microsoft Store module; honours dry-run by logging instead of installing. */ +class MicrosoftStoreUpdatesAdapter( + private val dryRun: Boolean, +) : StoreUpdates { + override val currentVersion: String get() = NucleusApp.version.orEmpty() + + override suspend fun check(): StoreUpdateCheck = MicrosoftStore.updates.check() + + override fun install(): Flow { + if (dryRun) { + infoln { "[update][dry-run] Microsoft Store install" } + return emptyFlow() + } + return MicrosoftStore.updates.install() + } +} + +/** + * The level of the Store's [storeVersion] over [currentVersion], to warn about the database update a MINOR/MAJOR + * may need. The Store gives `major.minor.build.revision`, kept to its first three parts; a version it does not give + * counts as MINOR, so the warning shows rather than not. + */ +fun storeUpdateLevel( + currentVersion: String, + storeVersion: String?, +): UpdateLevel { + if (storeVersion == null) return UpdateLevel.MINOR + return Version.fromString(storeSemanticVersion(storeVersion)).levelFrom(Version.fromString(currentVersion)) +} + +/** The Store's `major.minor.build.revision` as the app's `major.minor.patch`: the revision is always 0. */ +fun storeSemanticVersion(storeVersion: String): String = storeVersion.split('.').take(3).joinToString(".") diff --git a/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/update/StoreUpdatesTest.kt b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/update/StoreUpdatesTest.kt new file mode 100644 index 000000000..117f1d2e8 --- /dev/null +++ b/SeforimApp/src/jvmTest/kotlin/io/github/kdroidfilter/seforimapp/framework/update/StoreUpdatesTest.kt @@ -0,0 +1,174 @@ +package io.github.kdroidfilter.seforimapp.framework.update + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.store.microsoft.StoreUpdateCheck +import dev.nucleusframework.store.microsoft.StoreUpdateProgress +import dev.nucleusframework.store.microsoft.StoreUpdateState +import dev.nucleusframework.updater.DownloadProgress +import dev.nucleusframework.updater.UpdateInfo +import dev.nucleusframework.updater.UpdateLevel +import dev.nucleusframework.updater.UpdateResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail + +/** The Microsoft Store install: updates from the Store, never the GitHub feed. */ +class StoreUpdatesTest { + private class FakeStore( + private val check: () -> StoreUpdateCheck, + private val progress: List = emptyList(), + ) : StoreUpdates { + var installs = 0 + + /** The service's state right after each step the Store reported, read once the service handled it. */ + val observed = mutableListOf() + var stateOf: () -> UpdateUiState = { UpdateUiState.Idle } + + override val currentVersion = "0.6.12" + + override suspend fun check(): StoreUpdateCheck = check.invoke() + + override fun install(): Flow { + installs++ + return flow { + for (step in progress) { + emit(step) + observed += stateOf() + } + } + } + } + + /** The GitHub feed must not be asked for a Store install. */ + private object NoFeed : Updater { + override val currentVersion = "0.6.12" + + override fun isUpdateSupported() = false + + override suspend fun checkForUpdates(): UpdateResult = fail("the feed was checked") + + override fun downloadUpdate(info: UpdateInfo): Flow = flow { fail("the feed was downloaded") } + + override fun installAndRestart(file: File) = fail("an installer was run") + + override fun installAndQuit(file: File) = fail("an installer was run") + } + + private fun TestScope.service(store: StoreUpdates) = + AppUpdateService( + updaterProvider = { NoFeed }, + config = AppUpdateConfig(), + os = Platform.Windows, + storeUpdates = store, + scope = this, + ) + + private fun progress( + state: StoreUpdateState, + fraction: Double, + ) = StoreUpdateProgress(state, fraction, 0, 0) + + @Test + fun `the level is read from the Store's four-part version`() { + assertEquals(UpdateLevel.PATCH, storeUpdateLevel("0.6.12", "0.6.13.0")) + assertEquals(UpdateLevel.MINOR, storeUpdateLevel("0.6.12", "0.7.0.0")) + assertEquals(UpdateLevel.MAJOR, storeUpdateLevel("0.6.12", "1.0.0.0")) + assertEquals(UpdateLevel.MINOR, storeUpdateLevel("0.6.12", null)) + } + + @Test + fun `a patch on the Store prompts and is not pre-downloaded`() = + runTest { + val store = FakeStore({ StoreUpdateCheck.Available("0.6.13.0", isMandatory = false) }) + val svc = service(store) + + svc.checkOnStartup() + advanceUntilIdle() + + val state = svc.state.value + assertIs(state) + assertEquals("0.6.13", state.version) + assertEquals(UpdateLevel.PATCH, state.level) + assertEquals(UpdateMode.PROMPT, state.mode) + assertFalse(state.needsDbWarning) + assertTrue(state.showTitleBarIcon) + assertEquals(0, store.installs) + assertFalse(svc.dialogVisible.value) + assertFalse(svc.installPendingOnClose()) + assertTrue(svc.isUpdateSupported()) + } + + @Test + fun `a mandatory update opens the dialog`() = + runTest { + val svc = service(FakeStore({ StoreUpdateCheck.Available("0.7.0.0", isMandatory = true) })) + + svc.checkOnStartup() + + assertTrue(svc.dialogVisible.value) + assertTrue((svc.state.value as UpdateUiState.Available).needsDbWarning) + } + + @Test + fun `installing reports the Store's progress`() = + runTest { + val store = + FakeStore( + check = { StoreUpdateCheck.Available("0.6.13.0", isMandatory = false) }, + progress = + listOf( + progress(StoreUpdateState.Downloading, 0.4), + progress(StoreUpdateState.Installing, 0.9), + progress(StoreUpdateState.Completed, 1.0), + ), + ) + val svc = service(store) + store.stateOf = { svc.state.value } + svc.checkOnStartup() + + svc.startDownload() + advanceUntilIdle() + + assertEquals(1, store.installs) + assertEquals(listOf(40, 90), store.observed.filterIsInstance().map { it.percent }) + assertEquals(UpdateUiState.UpToDate, svc.state.value) + } + + @Test + fun `declining the Store's dialog keeps the update available`() = + runTest { + val store = + FakeStore( + check = { StoreUpdateCheck.Available("0.6.13.0", isMandatory = false) }, + progress = listOf(progress(StoreUpdateState.Cancelled, 0.0)), + ) + val svc = service(store) + svc.checkOnStartup() + + svc.startDownload() + advanceUntilIdle() + + assertIs(svc.state.value) + } + + @Test + fun `up to date, and a failing Store, are reported`() = + runTest { + val upToDate = service(FakeStore({ StoreUpdateCheck.UpToDate })) + upToDate.checkOnStartup() + assertEquals(UpdateUiState.UpToDate, upToDate.state.value) + + val failing = service(FakeStore({ error("Store unreachable") })) + failing.checkOnStartup() + assertEquals(UpdateUiState.Error("Store unreachable"), failing.state.value) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 879942c2e..34a14872c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ filekitCore = "0.16.0" hebrewNumerals = "0.2.6" jsoup = "1.23.2" jvmToolchain = "25" -nucleus = "3.0.0-dev-202610272317" +nucleus = "3.0.0-dev-202610312124" koalaplotCore = "0.12.1" kotlin = "2.4.20" compose = "1.12.1" @@ -79,6 +79,7 @@ nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorate nucleus-decorated-window-jewel = { module = "dev.nucleusframework:nucleus.decorated-window-jewel", version.ref = "nucleus" } nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleus" } nucleus-updater-runtime = { module = "dev.nucleusframework:nucleus.updater-runtime", version.ref = "nucleus" } +nucleus-microsoft-store = { module = "dev.nucleusframework:nucleus.microsoft-store", version.ref = "nucleus" } nucleus-native-ssl = { module = "dev.nucleusframework:nucleus.native-ssl", version.ref = "nucleus" } nucleus-native-http = { module = "dev.nucleusframework:nucleus.native-http", version.ref = "nucleus" } nucleus-energy-manager = { module = "dev.nucleusframework:nucleus.energy-manager", version.ref = "nucleus"}