Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions SeforimApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
}
Expand Down
Binary file modified SeforimApp/desktopAppIcons/appx/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed SeforimApp/desktopAppIcons/appx/Square44x44Logo.png
Binary file not shown.
Binary file removed SeforimApp/desktopAppIcons/appx/StoreLogo.png
Binary file not shown.
Binary file modified SeforimApp/desktopAppIcons/appx/Wide310x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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),
) {
Expand All @@ -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()) {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 `<pkg>.asc` was downloaded next to it;
* without it Nucleus falls back to `pkexec`, so prompt now rather than ask for a password on close.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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) {
Expand All @@ -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
},
)
}
}
Original file line number Diff line number Diff line change
@@ -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<StoreUpdateProgress>
}

/** 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<StoreUpdateProgress> {
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(".")
Loading
Loading