diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7e257c..82d742c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index afd8b5b..5123d4d 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -125,6 +125,105 @@ class DesktopState(private val appDir: File = defaultAppDir()) { private val _modelsError = MutableStateFlow(null) val modelsError: StateFlow = _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.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(null) val detail: StateFlow = _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" diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index e0c5c4b..9c05dcb 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -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")),