@@ -23,6 +23,7 @@ import zed.rainxch.core.domain.model.GithubRelease
2323import zed.rainxch.core.domain.model.InstallSource
2424import zed.rainxch.core.domain.model.InstalledApp
2525import zed.rainxch.core.domain.repository.InstalledAppsRepository
26+ import zed.rainxch.core.domain.repository.ThemesRepository
2627import zed.rainxch.core.domain.system.Installer
2728
2829class InstalledAppsRepositoryImpl (
@@ -31,6 +32,7 @@ class InstalledAppsRepositoryImpl(
3132 private val historyDao : UpdateHistoryDao ,
3233 private val installer : Installer ,
3334 private val httpClient : HttpClient ,
35+ private val themesRepository : ThemesRepository ,
3436) : InstalledAppsRepository {
3537 override suspend fun <R > executeInTransaction (block : suspend () -> R ): R =
3638 database.useWriterConnection { transactor ->
@@ -76,6 +78,8 @@ class InstalledAppsRepositoryImpl(
7678 repo : String ,
7779 ): GithubRelease ? {
7880 return try {
81+ val includePreReleases = themesRepository.getIncludePreReleases().first()
82+
7983 val releases =
8084 httpClient
8185 .executeRequest<List <ReleaseNetwork >> {
@@ -88,7 +92,8 @@ class InstalledAppsRepositoryImpl(
8892 val latest =
8993 releases
9094 .asSequence()
91- .filter { (it.draft != true ) && (it.prerelease != true ) }
95+ .filter { it.draft != true }
96+ .filter { includePreReleases || it.prerelease != true }
9297 .maxByOrNull { it.publishedAt ? : it.createdAt ? : " " }
9398 ? : return null
9499
@@ -119,7 +124,13 @@ class InstalledAppsRepositoryImpl(
119124 }
120125 val primaryAsset = installer.choosePrimaryAsset(installableAssets)
121126
122- val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag
127+ // Only flag as update if the latest version is actually newer
128+ // (not just different — avoids false "downgrade" notifications)
129+ val isUpdateAvailable = if (normalizedInstalledTag == normalizedLatestTag) {
130+ false
131+ } else {
132+ isVersionNewer(normalizedLatestTag, normalizedInstalledTag)
133+ }
123134
124135 Logger .d {
125136 " Update check for ${app.appName} : " +
@@ -226,4 +237,89 @@ class InstalledAppsRepositoryImpl(
226237 }
227238
228239 private fun normalizeVersion (version : String ): String = version.removePrefix(" v" ).removePrefix(" V" ).trim()
240+
241+ /* *
242+ * Compare two version strings and return true if [candidate] is newer than [current].
243+ * Handles semantic versioning (1.2.3), pre-release suffixes (1.2.3-beta.1),
244+ * and falls back to lexicographic comparison for non-standard formats.
245+ *
246+ * Pre-release versions are considered older than their stable counterparts:
247+ * 1.2.3-beta < 1.2.3 (per semver spec)
248+ *
249+ * This prevents false "downgrade" notifications when a user has a pre-release
250+ * installed and the latest stable version has a lower or equal base version.
251+ */
252+ private fun isVersionNewer (candidate : String , current : String ): Boolean {
253+ val candidateParsed = parseSemanticVersion(candidate)
254+ val currentParsed = parseSemanticVersion(current)
255+
256+ if (candidateParsed != null && currentParsed != null ) {
257+ // Compare major.minor.patch
258+ for (i in 0 until maxOf(candidateParsed.numbers.size, currentParsed.numbers.size)) {
259+ val c = candidateParsed.numbers.getOrElse(i) { 0 }
260+ val r = currentParsed.numbers.getOrElse(i) { 0 }
261+ if (c > r) return true
262+ if (c < r) return false
263+ }
264+ // Numbers are equal; compare pre-release suffixes
265+ // No pre-release > has pre-release (e.g., 1.0.0 > 1.0.0-beta)
266+ return when {
267+ candidateParsed.preRelease == null && currentParsed.preRelease != null -> true
268+ candidateParsed.preRelease != null && currentParsed.preRelease == null -> false
269+ candidateParsed.preRelease != null && currentParsed.preRelease != null ->
270+ comparePreRelease(candidateParsed.preRelease, currentParsed.preRelease) > 0
271+ else -> false // both null, versions are equal
272+ }
273+ }
274+
275+ // Fallback: lexicographic comparison (better than just "not equal")
276+ return candidate > current
277+ }
278+
279+ private data class SemanticVersion (
280+ val numbers : List <Int >,
281+ val preRelease : String? ,
282+ )
283+
284+ private fun parseSemanticVersion (version : String ): SemanticVersion ? {
285+ // Split off pre-release suffix: "1.2.3-beta.1" -> "1.2.3" and "beta.1"
286+ val hyphenIndex = version.indexOf(' -' )
287+ val numberPart = if (hyphenIndex >= 0 ) version.substring(0 , hyphenIndex) else version
288+ val preRelease = if (hyphenIndex >= 0 ) version.substring(hyphenIndex + 1 ) else null
289+
290+ val parts = numberPart.split(" ." )
291+ val numbers = parts.mapNotNull { it.toIntOrNull() }
292+
293+ // Only valid if we could parse at least one number and all parts were valid numbers
294+ if (numbers.isEmpty() || numbers.size != parts.size) return null
295+
296+ return SemanticVersion (numbers, preRelease)
297+ }
298+
299+ /* *
300+ * Compare pre-release identifiers per semver spec:
301+ * Identifiers consisting of only digits are compared numerically.
302+ * Identifiers with letters are compared lexically.
303+ * Numeric identifiers always have lower precedence than alphanumeric.
304+ * A larger set of pre-release fields has higher precedence if all preceding are equal.
305+ */
306+ private fun comparePreRelease (a : String , b : String ): Int {
307+ val aParts = a.split(" ." )
308+ val bParts = b.split(" ." )
309+
310+ for (i in 0 until minOf(aParts.size, bParts.size)) {
311+ val aNum = aParts[i].toIntOrNull()
312+ val bNum = bParts[i].toIntOrNull()
313+
314+ val cmp = when {
315+ aNum != null && bNum != null -> aNum.compareTo(bNum)
316+ aNum != null -> - 1 // numeric < alphanumeric
317+ bNum != null -> 1
318+ else -> aParts[i].compareTo(bParts[i])
319+ }
320+ if (cmp != 0 ) return cmp
321+ }
322+
323+ return aParts.size.compareTo(bParts.size)
324+ }
229325}
0 commit comments