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:
parent
7ec6229d76
commit
7195aa1ae6
3 changed files with 155 additions and 0 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue