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 {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
|
|
|
|||
|
|
@ -125,6 +125,105 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
private val _modelsError = MutableStateFlow<String?>(null)
|
||||
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)
|
||||
val detail: StateFlow<DetailUi?> = _detail.asStateFlow()
|
||||
|
||||
|
|
@ -2027,6 +2126,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
}
|
||||
|
||||
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_PASSWORD = "local.password"
|
||||
const val KEY_SECRET = "local.secret"
|
||||
|
|
|
|||
|
|
@ -1433,6 +1433,33 @@ fun SettingsScreen(state: DesktopState) {
|
|||
}
|
||||
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) {
|
||||
FolderPickerDialog(
|
||||
startDir = folder ?: File(System.getProperty("user.home")),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue