Skip to content

Commit 980ebac

Browse files
authored
feat: UI improvements and fixes (#128)
1 parent cf397f8 commit 980ebac

19 files changed

Lines changed: 1810 additions & 469 deletions

src/main/kotlin/app/morphe/engine/UpdateChecker.kt

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,44 @@ import java.net.URL
55
import java.util.Properties
66
import java.util.logging.Logger
77

8+
/**
9+
* Resolved release channel that [UpdateChecker] should probe. The user-facing
10+
* preference (with `OFF`) lives in the GUI layer; this enum only carries the
11+
* two channels that actually map to a network probe.
12+
*/
13+
enum class ReleaseChannel { STABLE, DEV }
14+
15+
data class UpdateInfo(
16+
val currentVersion: String,
17+
val latestVersion: String,
18+
val crossesDevToStable: Boolean,
19+
val downloadLink: String,
20+
)
21+
822
object UpdateChecker {
9-
fun check(logger: Logger): String? {
23+
/**
24+
* Probe GitHub for the latest version on the given channel. When [channel]
25+
* is null, derives the channel from the running build's version (legacy
26+
* behavior — preserved so the CLI's existing call site keeps working).
27+
* Synchronous — call from a background dispatcher.
28+
*/
29+
fun checkInfo(logger: Logger, channel: ReleaseChannel? = null): UpdateInfo? {
1030
try {
11-
// Current version of this CLI.
1231
val currentVersion = javaClass.getResourceAsStream("/app/morphe/cli/version.properties")
1332
?.use { stream ->
1433
Properties().apply { load(stream) }.getProperty("version")
1534
} ?: return null
1635

17-
// Check if the user is using dev or stable release.
1836
val isDev = currentVersion.contains("dev")
37+
val resolvedChannel = channel ?: if (isDev) ReleaseChannel.DEV else ReleaseChannel.STABLE
1938

20-
val url = if (isDev) {
21-
// If on dev and a new stable release is available, then this
22-
// ref still is correct because after a stable release dev branch is same as main.
23-
"https://raw.githubusercontent.com/MorpheApp/morphe-cli/refs/heads/dev/gradle.properties"
24-
} else {
25-
"https://raw.githubusercontent.com/MorpheApp/morphe-cli/refs/heads/main/gradle.properties"
39+
val url = when (resolvedChannel) {
40+
// dev branch tracks main after every stable release, so probing
41+
// dev also catches new stables for users on dev builds.
42+
ReleaseChannel.DEV ->
43+
"https://raw.githubusercontent.com/MorpheApp/morphe-cli/refs/heads/dev/gradle.properties"
44+
ReleaseChannel.STABLE ->
45+
"https://raw.githubusercontent.com/MorpheApp/morphe-cli/refs/heads/main/gradle.properties"
2646
}
2747

2848
val connection = URL(url).openConnection() as HttpURLConnection
@@ -35,28 +55,50 @@ object UpdateChecker {
3555
load(response.byteInputStream())
3656
}.getProperty("version") ?: return null
3757

38-
if (latestVersion != currentVersion) {
39-
// Warning message for when the user is to about to move from dev -> stable edge case.
40-
val trackChangesMessage = if (isDev && !latestVersion.contains("dev")){
41-
"\nNotice: The latest CLI is a stable release. Updating to that will stop dev " +
42-
"update notifications. To keep receiving dev updates, skip stable update " +
43-
"and wait for the next dev release."
44-
} else ""
45-
46-
val downloadLink = if (isDev) {
47-
"https://github.com/MorpheApp/morphe-cli/releases/"
48-
} else {
49-
"https://github.com/MorpheApp/morphe-cli/releases/latest"
50-
}
51-
52-
return "Update available: v$latestVersion (current: v$currentVersion)" +
53-
"$trackChangesMessage\nDownload from $downloadLink"
58+
if (latestVersion == currentVersion) return null
59+
60+
val downloadLink = when (resolvedChannel) {
61+
ReleaseChannel.DEV -> "https://github.com/MorpheApp/morphe-cli/releases/"
62+
ReleaseChannel.STABLE -> "https://github.com/MorpheApp/morphe-cli/releases/latest"
5463
}
55-
return null
5664

65+
return UpdateInfo(
66+
currentVersion = currentVersion,
67+
latestVersion = latestVersion,
68+
crossesDevToStable = isDev && !latestVersion.contains("dev"),
69+
downloadLink = downloadLink,
70+
)
5771
} catch (ex: Exception) {
5872
logger.fine("Could not check for CLI update: $ex")
5973
return null
6074
}
6175
}
62-
}
76+
77+
/**
78+
* Read the current build's version from the bundled resource. Returns null
79+
* when the resource is missing (development environments without the
80+
* processed properties file). Used by the GUI to decide a smart default
81+
* for the update channel preference.
82+
*/
83+
fun currentVersion(): String? = javaClass.getResourceAsStream("/app/morphe/cli/version.properties")
84+
?.use { stream ->
85+
Properties().apply { load(stream) }.getProperty("version")
86+
}
87+
88+
/**
89+
* Legacy formatter — returns the same multi-line string the CLI prints.
90+
* Kept byte-identical so [app.morphe.cli.command.PatchCommand]'s logger
91+
* output doesn't change.
92+
*/
93+
fun check(logger: Logger): String? {
94+
val info = checkInfo(logger) ?: return null
95+
val trackChangesMessage = if (info.crossesDevToStable) {
96+
"\nNotice: The latest CLI is a stable release. Updating to that will stop dev " +
97+
"update notifications. To keep receiving dev updates, skip stable update " +
98+
"and wait for the next dev release."
99+
} else ""
100+
101+
return "Update available: v${info.latestVersion} (current: v${info.currentVersion})" +
102+
"$trackChangesMessage\nDownload from ${info.downloadLink}"
103+
}
104+
}

src/main/kotlin/app/morphe/gui/App.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,9 @@ private fun AppContent(
162162
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
163163
if (!isLoading) {
164164
val patchService: PatchService = koinInject()
165+
val updateCheckRepository: app.morphe.gui.data.repository.UpdateCheckRepository = koinInject()
165166
val quickViewModel = remember {
166-
QuickPatchViewModel(patchSourceManager, patchService, configRepository)
167+
QuickPatchViewModel(patchSourceManager, patchService, configRepository, updateCheckRepository)
167168
}
168169

169170
Crossfade(targetState = isSimplifiedMode) { simplified ->

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,29 @@ data class AppConfig(
4444
val keepArchitectures: Set<String> = ANDROID_ARCHITECTURES,
4545
// Persisted expand/collapse state for each section in the Settings dialog.
4646
// Keyed by section title (e.g. "STRIP LIBS"). Missing key = section starts collapsed.
47-
val collapsibleSectionStates: Map<String, Boolean> = emptyMap()
47+
val collapsibleSectionStates: Map<String, Boolean> = emptyMap(),
48+
// Latest CLI version the user dismissed the update banner for. The banner stays
49+
// hidden while the available update equals this; reappears when a newer version drops.
50+
val dismissedUpdateVersion: String? = null,
51+
// Which release channel the user wants update checks to follow. Null = not yet set;
52+
// resolved at first read to STABLE/DEV based on the running build's version (so an
53+
// existing dev user upgrading isn't silently flipped to stable).
54+
val updateChannelPreference: String? = null,
55+
// Whether the user explicitly picked the update channel via Settings. When false,
56+
// the channel is re-derived from the running build's version on each read so a
57+
// user who swaps from a stable build to a dev build sees the right default.
58+
// Once they pick one in Settings, this flips to true and we respect their choice.
59+
val userDidChooseUpdateChannel: Boolean = false,
4860
) {
61+
62+
fun getUpdateChannelPreference(): UpdateChannelPreference? {
63+
val raw = updateChannelPreference ?: return null
64+
return try {
65+
UpdateChannelPreference.valueOf(raw)
66+
} catch (e: Exception) {
67+
null
68+
}
69+
}
4970
fun getThemePreference(): ThemePreference {
5071
return try {
5172
ThemePreference.valueOf(themePreference)
@@ -82,3 +103,17 @@ enum class PatchChannel {
82103
STABLE,
83104
DEV
84105
}
106+
107+
/**
108+
* Tracks which CLI release channel the user wants update notifications for.
109+
* No `AUTO` value — the smart default is computed once at first launch based
110+
* on the running build's version, then persisted as a concrete choice.
111+
*/
112+
enum class UpdateChannelPreference {
113+
/** Probe the `main` branch — only stable releases trigger the banner. */
114+
STABLE,
115+
/** Probe the `dev` branch — both newer dev and newer stable releases trigger the banner. */
116+
DEV,
117+
/** No update check, no banner. Re-enable from Settings. */
118+
OFF,
119+
}

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import app.morphe.gui.data.model.AppConfig
99
import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE
1010
import app.morphe.gui.data.model.PatchChannel
1111
import app.morphe.gui.data.model.PatchSource
12+
import app.morphe.gui.data.model.UpdateChannelPreference
1213
import kotlinx.coroutines.Dispatchers
1314
import kotlinx.coroutines.withContext
1415
import kotlinx.serialization.json.Json
@@ -103,6 +104,55 @@ class ConfigRepository {
103104
saveConfig(current.copy(lastPatchesVersion = version))
104105
}
105106

107+
/**
108+
* Mark the given CLI version as dismissed for the update banner. Pass null to
109+
* clear (so the banner reappears for whatever the next-available version is).
110+
*/
111+
suspend fun setDismissedUpdateVersion(version: String?) {
112+
val current = loadConfig()
113+
saveConfig(current.copy(dismissedUpdateVersion = version))
114+
}
115+
116+
/**
117+
* Persist the user's chosen update channel. Marks the choice as explicit
118+
* so subsequent reads respect it even if the running build's channel
119+
* differs. Also clears any prior [AppConfig.dismissedUpdateVersion] —
120+
* that dismissal referred to a specific version on the previous channel.
121+
*/
122+
suspend fun setUpdateChannelPreference(pref: UpdateChannelPreference) {
123+
val current = loadConfig()
124+
saveConfig(
125+
current.copy(
126+
updateChannelPreference = pref.name,
127+
userDidChooseUpdateChannel = true,
128+
dismissedUpdateVersion = null,
129+
)
130+
)
131+
}
132+
133+
/**
134+
* Resolve the update-channel preference. When the user has explicitly
135+
* picked one (via Settings), respect that. Otherwise re-derive from the
136+
* running build's version on every call so swapping between a stable and
137+
* dev build flips the default automatically.
138+
*/
139+
suspend fun getOrInitUpdateChannelPreference(currentVersion: String): UpdateChannelPreference {
140+
val current = loadConfig()
141+
val saved = current.getUpdateChannelPreference()
142+
if (current.userDidChooseUpdateChannel && saved != null) {
143+
return saved
144+
}
145+
val derived = if (currentVersion.contains("dev")) {
146+
UpdateChannelPreference.DEV
147+
} else {
148+
UpdateChannelPreference.STABLE
149+
}
150+
if (saved != derived) {
151+
saveConfig(current.copy(updateChannelPreference = derived.name))
152+
}
153+
return derived
154+
}
155+
106156
/**
107157
* Update default output directory.
108158
*/
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-cli
4+
*/
5+
6+
package app.morphe.gui.data.repository
7+
8+
import app.morphe.cli.command.model.PatchBundle
9+
import app.morphe.cli.command.model.PatchBundleMeta
10+
import app.morphe.cli.command.model.PatchEntry
11+
import app.morphe.gui.util.FileUtils
12+
import app.morphe.gui.util.Logger
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.sync.Mutex
15+
import kotlinx.coroutines.sync.withLock
16+
import kotlinx.coroutines.withContext
17+
import kotlinx.serialization.json.Json
18+
import java.io.File
19+
20+
/**
21+
* Stores per-(source, package) patch selections and option values across sessions.
22+
*
23+
* On disk: <app-data>/patch-preferences.json
24+
* Schema: Map<sourceName, Map<packageName, PatchBundle>>
25+
*
26+
* The on-disk shape reuses [PatchBundle] / [PatchEntry] from the CLI options file
27+
* format, so prefs files are interchangeable with `patch --options-file` input/output.
28+
*/
29+
class PatchPreferencesRepository {
30+
31+
private val json = Json {
32+
prettyPrint = true
33+
ignoreUnknownKeys = true
34+
encodeDefaults = true
35+
}
36+
37+
private val mutex = Mutex()
38+
private var cache: MutableMap<String, MutableMap<String, PatchBundle>>? = null
39+
40+
private fun prefsFile(): File = File(FileUtils.getAppDataDir(), "patch-preferences.json")
41+
42+
private suspend fun load(): MutableMap<String, MutableMap<String, PatchBundle>> {
43+
cache?.let { return it }
44+
val file = prefsFile()
45+
val parsed = try {
46+
if (file.exists()) {
47+
json.decodeFromString<Map<String, Map<String, PatchBundle>>>(file.readText())
48+
.mapValues { (_, byPkg) -> byPkg.toMutableMap() }
49+
.toMutableMap()
50+
} else {
51+
mutableMapOf()
52+
}
53+
} catch (e: Exception) {
54+
Logger.error("Failed to load patch preferences, starting fresh", e)
55+
mutableMapOf()
56+
}
57+
cache = parsed
58+
return parsed
59+
}
60+
61+
/**
62+
* Returns the saved [PatchBundle] for ([sourceName], [packageName]), or null if none.
63+
*/
64+
suspend fun get(sourceName: String, packageName: String): PatchBundle? = withContext(Dispatchers.IO) {
65+
mutex.withLock {
66+
load()[sourceName]?.get(packageName)
67+
}
68+
}
69+
70+
/**
71+
* Returns true if a saved selection exists for ([sourceName], [packageName]).
72+
*/
73+
suspend fun has(sourceName: String, packageName: String): Boolean = withContext(Dispatchers.IO) {
74+
mutex.withLock {
75+
load()[sourceName]?.containsKey(packageName) == true
76+
}
77+
}
78+
79+
/**
80+
* Saves the given enabled-set + options for ([sourceName], [packageName]).
81+
*
82+
* @param enabledPatchNames patches the user has enabled (those not listed are treated as disabled)
83+
* @param disabledPatchNames patches the user has explicitly disabled (must include the patches in this set so we can later distinguish "user opted out" from "patch removed from .mpp")
84+
* @param options optional per-patch option values keyed by patch name
85+
*/
86+
suspend fun save(
87+
sourceName: String,
88+
packageName: String,
89+
enabledPatchNames: Set<String>,
90+
disabledPatchNames: Set<String>,
91+
options: Map<String, Map<String, kotlinx.serialization.json.JsonElement>> = emptyMap(),
92+
) = withContext(Dispatchers.IO) {
93+
mutex.withLock {
94+
val all = load()
95+
val byPkg = all.getOrPut(sourceName) { mutableMapOf() }
96+
val existing = byPkg[packageName]
97+
98+
val patches = mutableMapOf<String, PatchEntry>()
99+
for (name in enabledPatchNames) {
100+
patches[name] = PatchEntry(
101+
enabled = true,
102+
options = options[name] ?: emptyMap(),
103+
)
104+
}
105+
for (name in disabledPatchNames) {
106+
patches[name] = PatchEntry(
107+
enabled = false,
108+
options = options[name] ?: emptyMap(),
109+
)
110+
}
111+
112+
val now = java.time.format.DateTimeFormatter.ISO_INSTANT.format(java.time.Instant.now())
113+
val bundle = PatchBundle(
114+
meta = PatchBundleMeta(
115+
createdAt = existing?.meta?.createdAt ?: now,
116+
updatedAt = if (existing != null) now else null,
117+
source = sourceName,
118+
sha256 = null,
119+
),
120+
patches = patches,
121+
)
122+
byPkg[packageName] = bundle
123+
124+
try {
125+
val file = prefsFile()
126+
file.parentFile?.mkdirs()
127+
file.writeText(json.encodeToString(all as Map<String, Map<String, PatchBundle>>))
128+
Logger.info("Saved patch preferences for $sourceName / $packageName (${patches.size} entries)")
129+
} catch (e: Exception) {
130+
Logger.error("Failed to write patch preferences", e)
131+
}
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)