Skip to content

Commit 5d34993

Browse files
authored
feat: Patch sources follows latest (stable/dev) instead of to last used version (#185)
On startup, the gui automatically gets the latest version of dev/stable if available based on user's previous choice and shifts to it. An older version is pinned only if the user had previously pinned an older version. !Important: Existing sources are moved onto the latest of their channel. Anyone who had deliberately pinned an older version will be bumped to latest. Users will need to re-pin for now.
1 parent 6085b6f commit 5d34993

5 files changed

Lines changed: 151 additions & 65 deletions

File tree

src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,57 @@ val DEFAULT_PATCH_SOURCE = PatchSource(
2525
deletable = false
2626
)
2727

28+
/**
29+
* How a patch source decides which release to load.
30+
*
31+
* - [FOLLOW_STABLE]: ride the newest **stable** (non-pre-release) — auto-updates
32+
* as new stables ship. The default for an untouched source.
33+
* - [FOLLOW_DEV]: ride the newest release **overall**, pre-releases included
34+
* ("bleeding edge"). When a dev is newest you get the dev; when a stable is the
35+
* newest thing out, you get that stable — without losing the dev track.
36+
* - [PINNED]: stay frozen on one exact tag (chosen deliberately), ignoring newer
37+
* releases. The version lives in [SourceVersionPref.pinnedTag].
38+
*/
39+
@Serializable
40+
enum class FollowMode { FOLLOW_STABLE, FOLLOW_DEV, PINNED }
41+
42+
/**
43+
* A source's version preference: which release-tracking [mode], plus the exact
44+
* tag when [mode] is [FollowMode.PINNED] (null otherwise).
45+
*/
46+
@Serializable
47+
data class SourceVersionPref(
48+
val mode: FollowMode,
49+
val pinnedTag: String? = null,
50+
)
51+
2852
@Serializable
2953
data class AppConfig(
3054
val themePreference: String = ThemePreference.SYSTEM.name,
3155
val lastCliVersion: String? = null,
3256
/**
33-
* LEGACY single-source version pin. Kept only for one-version migration into
34-
* [lastPatchesVersionBySource] — read it on first load if the map is empty,
35-
* then phase out. Do not read this directly anywhere new — go through
36-
* [ConfigRepository.getLastPatchesVersionsBySource].
57+
* LEGACY single-source version pin. Kept only so it can be migrated (via
58+
* [lastPatchesVersionBySource]) into [sourceVersionPrefs]. Do not read directly
59+
* anywhere new — go through [ConfigRepository.getSourceVersionPrefs].
3760
*/
3861
val lastPatchesVersion: String? = null,
3962
/**
40-
* Per-source version pin: sourceId → release tag. Absence of a key means
41-
* "no pin — use that source's latest stable". Replaces the legacy single
42-
* [lastPatchesVersion] which silently contaminated other sources whose tag
43-
* names happened to overlap.
63+
* LEGACY per-source version pin: sourceId → release tag. Superseded by
64+
* [sourceVersionPrefs]; kept only so existing configs can migrate (every old
65+
* tag becomes a follow-track based on whether it was a dev tag). Do not read
66+
* directly — go through [ConfigRepository.getSourceVersionPrefs].
4467
*/
4568
val lastPatchesVersionBySource: Map<String, String> = emptyMap(),
69+
/**
70+
* Per-source version preference: sourceId → [SourceVersionPref].
71+
*
72+
* Absence of a key = follow the source's latest stable (the default for a
73+
* brand-new, untouched source). Otherwise the stored [SourceVersionPref]
74+
* decides whether the source rides the latest stable, the latest overall
75+
* (dev/bleeding-edge), or stays frozen on a specific tag. See
76+
* [ConfigRepository.getSourceVersionPrefs] / [setSourceVersionPref].
77+
*/
78+
val sourceVersionPrefs: Map<String, SourceVersionPref> = emptyMap(),
4679
val preferredPatchChannel: String = PatchChannel.STABLE.name,
4780
val defaultOutputDirectory: String? = null,
4881
val autoCleanupTempFiles: Boolean = true, // Default ON

src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ package app.morphe.gui.data.repository
88
import app.morphe.engine.util.PortablePaths
99
import app.morphe.gui.data.model.AppConfig
1010
import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE
11+
import app.morphe.gui.data.model.FollowMode
12+
import app.morphe.gui.data.model.SourceVersionPref
1113
import app.morphe.gui.data.model.PatchChannel
1214
import app.morphe.gui.data.model.PatchSource
1315
import app.morphe.gui.data.model.UpdateChannelPreference
@@ -102,40 +104,48 @@ class ConfigRepository {
102104
}
103105

104106
/**
105-
* LEGACY — kept so single-source callers don't break during the multi-source
106-
* transition. New code should use [setLastPatchesVersionForSource].
107+
* Record a source's version preference (called by PatchesScreen when the user
108+
* picks a release). Per-source, so sources with overlapping tag names don't
109+
* contaminate each other.
107110
*/
108-
@Deprecated("Use setLastPatchesVersionForSource", ReplaceWith("setLastPatchesVersionForSource(sourceId, version)"))
109-
suspend fun setLastPatchesVersion(version: String) {
111+
suspend fun setSourceVersionPref(sourceId: String, pref: SourceVersionPref) {
110112
val current = loadConfig()
111-
saveConfig(current.copy(lastPatchesVersion = version))
113+
saveConfig(current.copy(sourceVersionPrefs = current.sourceVersionPrefs + (sourceId to pref)))
112114
}
113115

114116
/**
115-
* Pin a specific release tag for [sourceId]. Used by PatchesScreen when the
116-
* user picks a version. Per-source = no cross-contamination across sources
117-
* with overlapping tag names.
117+
* Returns the per-source version preferences, with a one-time migration from
118+
* the legacy tag-only fields ([AppConfig.lastPatchesVersionBySource] and the
119+
* even-older single [AppConfig.lastPatchesVersion]).
120+
*
121+
* Migration intent: those legacy tags were auto-saved on every selection, not
122+
* deliberate freezes, so we can't tell a real pin from "just used the latest."
123+
* We therefore convert each old tag to a *follow track* based on whether it was
124+
* a dev tag — `-dev` ⇒ [FollowMode.FOLLOW_DEV], else [FollowMode.FOLLOW_STABLE].
125+
* Net effect: everyone shifts onto the latest of their channel (the fix), at the
126+
* cost of un-freezing anyone who had deliberately pinned an old version (they can
127+
* re-pin). A dev user parked on a stable tag at migration looks like a stable
128+
* user and is migrated as such — an accepted, self-healing one-time loss.
118129
*/
119-
suspend fun setLastPatchesVersionForSource(sourceId: String, version: String) {
130+
suspend fun getSourceVersionPrefs(): Map<String, SourceVersionPref> {
120131
val current = loadConfig()
121-
val updated = current.lastPatchesVersionBySource + (sourceId to version)
122-
saveConfig(current.copy(lastPatchesVersionBySource = updated))
123-
}
132+
if (current.sourceVersionPrefs.isNotEmpty()) return current.sourceVersionPrefs
133+
134+
// Build the legacy tag map (per-source map, or the single legacy field
135+
// mapped onto the default source).
136+
val legacyTags: Map<String, String> = when {
137+
current.lastPatchesVersionBySource.isNotEmpty() -> current.lastPatchesVersionBySource
138+
current.lastPatchesVersion != null -> mapOf(DEFAULT_PATCH_SOURCE.id to current.lastPatchesVersion!!)
139+
else -> emptyMap()
140+
}
141+
if (legacyTags.isEmpty()) return emptyMap()
124142

125-
/**
126-
* Returns the per-source version pin map, with one-time migration from the
127-
* legacy [AppConfig.lastPatchesVersion] field: if the map is empty and the
128-
* legacy field is set, it's mapped to the default source.
129-
*/
130-
suspend fun getLastPatchesVersionsBySource(): Map<String, String> {
131-
val current = loadConfig()
132-
if (current.lastPatchesVersionBySource.isNotEmpty()) {
133-
return current.lastPatchesVersionBySource
143+
val migrated = legacyTags.mapValues { (_, tag) ->
144+
val mode = if (tag.contains("-dev", ignoreCase = true)) FollowMode.FOLLOW_DEV
145+
else FollowMode.FOLLOW_STABLE
146+
SourceVersionPref(mode = mode)
134147
}
135-
val legacy = current.lastPatchesVersion ?: return emptyMap()
136-
// Migrate: write the legacy pin onto the default source, return the new map.
137-
val migrated = mapOf(DEFAULT_PATCH_SOURCE.id to legacy)
138-
saveConfig(current.copy(lastPatchesVersionBySource = migrated))
148+
saveConfig(current.copy(sourceVersionPrefs = migrated))
139149
return migrated
140150
}
141151

src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import app.morphe.engine.UpdateInfo
1313
import app.morphe.engine.model.PatchedAppRecord
1414
import app.morphe.engine.util.SignatureIdentity
1515
import app.morphe.gui.data.model.Patch
16+
import app.morphe.gui.data.model.SourceVersionPref
1617
import app.morphe.gui.data.model.SupportedApp
1718
import app.morphe.gui.data.repository.ConfigRepository
1819
import app.morphe.gui.data.repository.PatchRepository
@@ -347,7 +348,7 @@ class HomeViewModel(
347348
private var lastLoadedVersion: String? = null
348349
// Snapshot of per-source pinned versions used in the last load — drives
349350
// refreshPatchesIfNeeded so we reload when ANY source's pin changes.
350-
private var lastLoadedVersionsBySource: Map<String, String> = emptyMap()
351+
private var lastLoadedVersionsBySource: Map<String, SourceVersionPref> = emptyMap()
351352

352353
/**
353354
* Load patches from all enabled sources via [EnabledSourcesLoader] and build
@@ -372,9 +373,9 @@ class HomeViewModel(
372373
// Per-source pinned versions (with one-time migration from legacy
373374
// single-source field). Each source's resolver looks up its own pin;
374375
// no cross-source contamination.
375-
val preferredVersions = configRepository.getLastPatchesVersionsBySource()
376-
lastLoadedVersionsBySource = preferredVersions
377-
val result = EnabledSourcesLoader.loadAll(enabled, patchService, preferredVersions)
376+
val prefs = configRepository.getSourceVersionPrefs()
377+
lastLoadedVersionsBySource = prefs
378+
val result = EnabledSourcesLoader.loadAll(enabled, patchService, prefs)
378379

379380
if (!result.anyLoaded) {
380381
val firstError = result.resolved.firstNotNullOfOrNull { it.error }
@@ -780,7 +781,7 @@ class HomeViewModel(
780781
*/
781782
fun refreshPatchesIfNeeded() {
782783
screenModelScope.launch {
783-
val saved = configRepository.getLastPatchesVersionsBySource()
784+
val saved = configRepository.getSourceVersionPrefs()
784785
if (saved != lastLoadedVersionsBySource) {
785786
Logger.info("Patches versions changed across sources: $lastLoadedVersionsBySource -> $saved, reloading...")
786787
loadPatchesAndSupportedApps(forceRefresh = true)

src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import app.morphe.patcher.patch.loadPatchesFromJar
1010
import cafe.adriel.voyager.core.model.ScreenModel
1111
import cafe.adriel.voyager.core.model.screenModelScope
1212
import app.morphe.engine.model.Release
13+
import app.morphe.gui.data.model.FollowMode
14+
import app.morphe.gui.data.model.SourceVersionPref
1315
import app.morphe.gui.data.repository.ConfigRepository
1416
import app.morphe.gui.data.repository.PatchRepository
1517
import app.morphe.gui.data.repository.PatchSourceManager
@@ -81,10 +83,16 @@ class PatchesViewModel(
8183
val stableReleases = releases.filter { !it.isDevRelease() }
8284
val devReleases = releases.filter { it.isDevRelease() }
8385

84-
// Check config for previously selected version FOR THIS SOURCE
86+
// Resolve this source's version preference to a concrete tag
87+
// to pre-select: a pin → its tag; follow-stable → newest stable;
88+
// follow-dev → newest overall.
8589
val activeSourceId = patchSourceManager?.getActiveSource()?.id
86-
val savedVersion = activeSourceId?.let {
87-
configRepository.getLastPatchesVersionsBySource()[it]
90+
val pref = activeSourceId?.let { configRepository.getSourceVersionPrefs()[it] }
91+
val savedVersion = when (pref?.mode) {
92+
FollowMode.PINNED -> pref.pinnedTag
93+
FollowMode.FOLLOW_STABLE -> stableReleases.firstOrNull()?.tagName
94+
FollowMode.FOLLOW_DEV -> releases.firstOrNull()?.tagName
95+
null -> null
8896
}
8997

9098
// Find the saved release, or fall back to latest stable
@@ -137,8 +145,12 @@ class PatchesViewModel(
137145
.fold(0L) { acc, part -> acc * 10000 + part }
138146
}
139147
val activeSourceId = patchSourceManager?.getActiveSource()?.id
140-
val savedVersion = activeSourceId?.let {
141-
configRepository.getLastPatchesVersionsBySource()[it]
148+
val pref = activeSourceId?.let { configRepository.getSourceVersionPrefs()[it] }
149+
val savedVersion = when (pref?.mode) {
150+
FollowMode.PINNED -> pref.pinnedTag
151+
FollowMode.FOLLOW_STABLE -> offlineReleases.firstOrNull { !it.isDevRelease() }?.tagName
152+
FollowMode.FOLLOW_DEV -> offlineReleases.firstOrNull()?.tagName
153+
null -> null
142154
}
143155

144156
// Pre-select the saved version, or fall back to the first (most recent)
@@ -306,12 +318,13 @@ class PatchesViewModel(
306318
)
307319
Logger.info("Patches downloaded: ${patchFile.absolutePath}")
308320

309-
// Save the selected version PER SOURCE so HomeScreen can pick it up
310-
// without contaminating other enabled sources.
321+
// Save the version preference PER SOURCE so HomeScreen can pick
322+
// it up without contaminating other enabled sources.
311323
val activeSourceId = patchSourceManager?.getActiveSource()?.id
312324
if (activeSourceId != null) {
313-
configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName)
314-
Logger.info("Saved selected patches version for source '$activeSourceId': ${release.tagName}")
325+
val pref = versionPrefFor(release)
326+
configRepository.setSourceVersionPref(activeSourceId, pref)
327+
Logger.info("Saved version pref for source '$activeSourceId': $pref")
315328
}
316329
},
317330
onFailure = { e ->
@@ -338,12 +351,33 @@ class PatchesViewModel(
338351
screenModelScope.launch {
339352
val activeSourceId = patchSourceManager?.getActiveSource()?.id
340353
if (activeSourceId != null) {
341-
configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName)
342-
Logger.info("Confirmed patches selection for source '$activeSourceId': ${release.tagName}")
354+
val pref = versionPrefFor(release)
355+
configRepository.setSourceVersionPref(activeSourceId, pref)
356+
Logger.info("Confirmed version pref for source '$activeSourceId': $pref")
343357
}
344358
}
345359
}
346360

361+
/**
362+
* Turn a user-selected release into a [SourceVersionPref]:
363+
* - the newest stable → [FollowMode.FOLLOW_STABLE] (ride latest stable)
364+
* - the newest dev → [FollowMode.FOLLOW_DEV] (ride newest overall)
365+
* - anything older → [FollowMode.PINNED] to that exact tag
366+
*
367+
* "Picked the latest of a channel" is read as "stay on that channel's latest,"
368+
* which is what auto-updates the source going forward.
369+
*/
370+
private fun versionPrefFor(release: Release): SourceVersionPref {
371+
val newestStableTag = _uiState.value.stableReleases.firstOrNull()?.tagName
372+
val newestDevTag = _uiState.value.devReleases.firstOrNull()?.tagName
373+
val mode = when {
374+
release.isDevRelease() && release.tagName == newestDevTag -> FollowMode.FOLLOW_DEV
375+
!release.isDevRelease() && release.tagName == newestStableTag -> FollowMode.FOLLOW_STABLE
376+
else -> FollowMode.PINNED
377+
}
378+
return SourceVersionPref(mode = mode, pinnedTag = release.tagName.takeIf { mode == FollowMode.PINNED })
379+
}
380+
347381
/**
348382
* Export patch options from the downloaded .mpp file to a JSON file.
349383
*/

src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
package app.morphe.gui.util
77

88
import app.morphe.engine.MultiSourceLoader
9+
import app.morphe.gui.data.model.FollowMode
910
import app.morphe.gui.data.model.PatchSource
1011
import app.morphe.gui.data.model.PatchSourceType
12+
import app.morphe.gui.data.model.SourceVersionPref
1113
import app.morphe.gui.data.repository.PatchRepository
1214
import kotlinx.coroutines.CancellationException
1315
import kotlinx.coroutines.Dispatchers
@@ -79,7 +81,7 @@ object EnabledSourcesLoader {
7981
suspend fun loadAll(
8082
enabled: List<Pair<PatchSource, PatchRepository?>>,
8183
patchService: PatchService,
82-
preferredVersionsBySource: Map<String, String> = emptyMap(),
84+
prefsBySource: Map<String, SourceVersionPref> = emptyMap(),
8385
): Result = supervisorScope {
8486
// supervisorScope (not coroutineScope) so a single source's failure
8587
// doesn't cancel the other in-flight resolves. Each async catches its
@@ -89,7 +91,7 @@ object EnabledSourcesLoader {
8991
val resolved = enabled.map { (source, repo) ->
9092
async(Dispatchers.IO) {
9193
try {
92-
resolve(source, repo, preferredVersionsBySource[source.id])
94+
resolve(source, repo, prefsBySource[source.id])
9395
} catch (e: CancellationException) {
9496
throw e
9597
} catch (e: Exception) {
@@ -136,7 +138,7 @@ object EnabledSourcesLoader {
136138
private suspend fun resolve(
137139
source: PatchSource,
138140
repo: PatchRepository?,
139-
preferredVersion: String?,
141+
pref: SourceVersionPref?,
140142
): ResolvedSource = withContext(Dispatchers.IO) {
141143
when (source.type) {
142144
PatchSourceType.LOCAL -> resolveLocal(source)
@@ -145,7 +147,7 @@ object EnabledSourcesLoader {
145147
// which API to talk to based on the source's provider type.
146148
PatchSourceType.DEFAULT,
147149
PatchSourceType.GITHUB,
148-
PatchSourceType.GITLAB -> resolveRemote(source, repo, preferredVersion)
150+
PatchSourceType.GITLAB -> resolveRemote(source, repo, pref)
149151
}
150152
}
151153

@@ -169,7 +171,7 @@ object EnabledSourcesLoader {
169171
private suspend fun resolveRemote(
170172
source: PatchSource,
171173
repo: PatchRepository?,
172-
preferredVersion: String?,
174+
pref: SourceVersionPref?,
173175
): ResolvedSource {
174176
if (repo == null) {
175177
return ResolvedSource(source = source, error = "No repository configured for source")
@@ -193,17 +195,23 @@ object EnabledSourcesLoader {
193195
return ResolvedSource(source = source, error = errMsg)
194196
}
195197

196-
// Honor a user-pinned version if it exists in this source's releases.
197-
// Otherwise pick latest stable, falling back to latest dev.
198-
val release = preferredVersion
199-
?.let { pinned -> releases.find { it.tagName == pinned } }
200-
?: releases.firstOrNull { !it.isDevRelease() }
201-
?: releases.firstOrNull()
202-
?: return ResolvedSource(source = source, error = "No releases found")
203-
204-
// Classify against this source's release list so the LED + badge can
205-
// distinguish "latest stable" from "older stable" from "dev".
206-
val latestStableTag = releases.firstOrNull { !it.isDevRelease() }?.tagName
198+
// Resolve which release to load from this source's version preference:
199+
// - PINNED → the exact tag (fall back to latest stable if it's gone)
200+
// - FOLLOW_DEV → newest release overall, pre-releases included
201+
// - FOLLOW_STABLE → newest stable (also the default when there's no pref)
202+
// The release list is newest-first, so firstOrNull() is the newest overall.
203+
val latestStable = releases.firstOrNull { !it.isDevRelease() }
204+
val latestOverall = releases.firstOrNull()
205+
val release = when (pref?.mode) {
206+
FollowMode.PINNED ->
207+
releases.find { it.tagName == pref.pinnedTag } ?: latestStable ?: latestOverall
208+
FollowMode.FOLLOW_DEV -> latestOverall ?: latestStable
209+
FollowMode.FOLLOW_STABLE, null -> latestStable ?: latestOverall
210+
} ?: return ResolvedSource(source = source, error = "No releases found")
211+
212+
// Classify where the resolved release actually sits (for the LED + badge),
213+
// independent of which track it's following.
214+
val latestStableTag = latestStable?.tagName
207215
val latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName
208216
val channel = when {
209217
release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST

0 commit comments

Comments
 (0)