Settings: 'About & updates' — check-for-updates against the forgejo repo

Bakes the source commit + build timestamp into the jar
(generateBuildInfo task -> shonar-build.properties on the classpath).
The Settings screen gains a 'Check for updates' button that queries the
anonymous Forgejo commits API for the latest master commit and compares
it to this build: up to date / update available (with commit shas and an
'Open repo' link via xdg-open) / 'newer than forgejo — local work not
yet pushed' for dev builds ahead of the remote. Network failures render
as a message with a repo link, never a crash.

Gate: :app:compileKotlin + :app:test green (9 suites); jar property
verified by unzip.
This commit is contained in:
avi 2026-09-22 09:12:09 -05:00
commit 7195aa1ae6
3 changed files with 155 additions and 0 deletions

View file

@ -33,6 +33,32 @@ sourceSets {
} }
} }
// Bake the source commit + build time into the jar so the in-app
// check-for-updates can tell "this build is current" from "the repo has
// newer work". Lives on outputs so a new commit invalidates it.
val generateBuildInfo = tasks.register("generateBuildInfo") {
val outDir = layout.buildDirectory.dir("generated/shonar/resources")
outputs.dir(outDir)
val commit = providers.exec {
commandLine("git", "-C", rootProject.projectDir.absolutePath,
"rev-parse", "--short", "HEAD")
}.standardOutput.asText.map { it.trim() }
doLast {
val dir = outDir.get().asFile
dir.mkdirs()
File(dir, "shonar-build.properties").writeText(
"commit=${commit.get().ifBlank { "unknown" }}\n" +
"builtAt=${System.currentTimeMillis()}\n",
)
}
}
sourceSets {
main {
resources.srcDir(generateBuildInfo)
}
}
dependencies { dependencies {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(compose.material3) implementation(compose.material3)

View file

@ -125,6 +125,105 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
private val _modelsError = MutableStateFlow<String?>(null) private val _modelsError = MutableStateFlow<String?>(null)
val modelsError: StateFlow<String?> = _modelsError.asStateFlow() val modelsError: StateFlow<String?> = _modelsError.asStateFlow()
// ---- update check (forgejo) ---------------------------------------------
/** Result of the Settings → "Check for updates" button. The app's own
* commit is baked into the jar at build time (shonar-build.properties,
* see app/build.gradle.kts); the repo's latest master commit comes
* from the anonymous Forgejo API. A sha mismatch with a build NEWER
* than the remote commit is reported as "local build" rather than
* "out of date" — dev builds legitimately lead the pushed history. */
data class UpdateStatus(
val phase: String, // "idle" | "checking" | "result"
val message: String = "",
val url: String? = null, // repo page when there is something to see
val stale: Boolean = false, // render as a warning when true
)
private val _updateStatus = MutableStateFlow(UpdateStatus("idle"))
val updateStatus: StateFlow<UpdateStatus> = _updateStatus.asStateFlow()
/** This build's short commit ("unknown" when built outside git). */
val buildCommit: String by lazy {
runCatching {
val p = java.util.Properties()
DesktopState::class.java.classLoader
.getResourceAsStream("shonar-build.properties")
?.use { p.load(it) }
p.getProperty("commit") ?: "unknown"
}.getOrDefault("unknown")
}
private val buildTimeMs: Long by lazy {
runCatching {
val p = java.util.Properties()
DesktopState::class.java.classLoader
.getResourceAsStream("shonar-build.properties")
?.use { p.load(it) }
p.getProperty("builtAt")?.toLongOrNull() ?: 0L
}.getOrDefault(0L)
}
fun checkForUpdates() {
scope.launch {
_updateStatus.value = UpdateStatus("checking", "Checking forgejo…")
val result = runCatching {
val req = okhttp3.Request.Builder().url(
"$REPO_API/commits?limit=1&sha=master").build()
http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful)
error("forgejo HTTP ${resp.code}")
val body = resp.body?.string() ?: error("empty response")
val first = org.json.JSONArray(body).getJSONObject(0)
UpdateInfo(
sha = first.getString("sha"),
webUrl = first.optString("html_url")
.ifBlank { "$REPO_WEB/commits/branch/master" },
createdAtMs = runCatching {
java.time.OffsetDateTime.parse(
first.getString("created")).toInstant().toEpochMilli()
}.getOrDefault(0L),
)
}
}
_updateStatus.value = result.fold(
onSuccess = { remote ->
val latest = remote.sha.take(7)
val mine = buildCommit
when {
latest == mine || remote.sha.startsWith(mine) ->
UpdateStatus("result",
"Up to date — build $mine is the latest on forgejo.")
buildTimeMs > remote.createdAtMs ->
UpdateStatus("result",
"This build ($mine) is newer than forgejo's latest " +
"($latest) — local work not yet pushed.")
else ->
UpdateStatus("result",
"Update available: forgejo has $latest, " +
"this build is $mine.",
url = remote.webUrl, stale = true)
}
},
onFailure = {
UpdateStatus("result",
"Update check failed: ${it.message ?: "network error"}",
url = REPO_WEB, stale = true)
},
)
}
}
private data class UpdateInfo(
val sha: String, val webUrl: String, val createdAtMs: Long)
/** Open the repo page in the user's browser (best-effort). */
fun openRepoPage(url: String = REPO_WEB) {
scope.launch {
runCatching { ProcessBuilder("xdg-open", url).start() }
}
}
private val _detail = MutableStateFlow<DetailUi?>(null) private val _detail = MutableStateFlow<DetailUi?>(null)
val detail: StateFlow<DetailUi?> = _detail.asStateFlow() val detail: StateFlow<DetailUi?> = _detail.asStateFlow()
@ -2027,6 +2126,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} }
const val KEY_URL = "server.url" const val KEY_URL = "server.url"
/** Forgejo home of this app; used by the in-app update check. */
const val REPO_WEB = "https://git.atitlan.io/avi/S.H.O.N.A.R._Desktop_Companion"
const val REPO_API = "https://git.atitlan.io/api/v1/repos/avi/S.H.O.N.A.R._Desktop_Companion"
const val KEY_FOLDER = "library.folder" const val KEY_FOLDER = "library.folder"
const val KEY_PASSWORD = "local.password" const val KEY_PASSWORD = "local.password"
const val KEY_SECRET = "local.secret" const val KEY_SECRET = "local.secret"

View file

@ -1433,6 +1433,33 @@ fun SettingsScreen(state: DesktopState) {
} }
modelsError?.let { Text(it, color = MaterialTheme.colorScheme.error) } modelsError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
SectionHeader("About & updates")
HelperText("Build ${state.buildCommit} — checks this build against the " +
"latest commit pushed to the forgejo repo.")
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2),
verticalAlignment = Alignment.CenterVertically) {
val us = state.updateStatus.collectAsState().value
ShonarOutlinedButton(
onClick = { state.checkForUpdates() },
enabled = us.phase != "checking",
) {
Text(if (us.phase == "checking") "Checking…"
else "Check for updates")
}
us.url?.let {
ShonarGhostButton({ state.openRepoPage(it) }) {
Text("Open repo")
}
}
}
val us = state.updateStatus.collectAsState().value
if (us.message.isNotEmpty()) {
Text(us.message,
style = MaterialTheme.typography.bodySmall,
color = if (us.stale) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant)
}
if (showPicker) { if (showPicker) {
FolderPickerDialog( FolderPickerDialog(
startDir = folder ?: File(System.getProperty("user.home")), startDir = folder ?: File(System.getProperty("user.home")),