From fb980d12b4cad7f04111dec0c289b75e5d21fe5f Mon Sep 17 00:00:00 2001 From: ARIA Date: Tue, 25 Aug 2026 14:42:39 +0200 Subject: [PATCH] Release version management: single VERSION file as source of truth - VERSION at repo root (0.1.2); bump it to cut a release - App: generated AppVersion.kt (config-cache-safe Gradle task with VERSION as declared input) shown in Settings; sent to the gateway via X-Iris-App-Version header on the SSE open - Gateway: reports its own version in hello.ack server_caps.app_version (read from the repo-root VERSION via the plugin symlink); stores the app's version in the device registry caps (merge, not overwrite, so an old app reconnecting without the header doesn't wipe it) - Settings: app + gateway version rows, mismatch hint, and a best-effort Gitea latest-release check (ReleaseCheck) with an 'update available' hint - Release workflow: reads VERSION from the repo (no manual input), with a guard against an empty file - Docs: frames.schema.json + 04-wire-protocol.md updated for app_version --- .gitea/workflows/release.yml | 14 +-- CI-SETUP.md | 68 +++++++++---- VERSION | 1 + app/androidApp/build.gradle.kts | 13 ++- app/desktopApp/build.gradle.kts | 14 ++- app/shared/build.gradle.kts | 52 ++++++++++ .../commonMain/kotlin/iris/net/HttpGateway.kt | 4 + .../kotlin/iris/net/ReleaseCheck.kt | 96 +++++++++++++++++++ .../kotlin/iris/protocol/Protocol.kt | 2 + .../kotlin/iris/state/IrisController.kt | 15 +++ .../kotlin/iris/ui/screens/SettingsScreen.kt | 64 +++++++++++++ docs/04-wire-protocol.md | 11 ++- docs/protocol/frames.schema.json | 2 +- gateway-plugin/adapter.py | 4 + gateway-plugin/http_server.py | 18 +++- gateway-plugin/version.py | 26 +++++ tests/test_android.py | 55 +++++++++++ 17 files changed, 422 insertions(+), 37 deletions(-) create mode 100644 VERSION create mode 100644 app/shared/src/commonMain/kotlin/iris/net/ReleaseCheck.kt create mode 100644 gateway-plugin/version.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 486c357..62fe7e4 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -3,10 +3,8 @@ name: Release on: workflow_dispatch: inputs: - version: - description: "Release version (e.g. 0.2.0)" - required: true - type: string + # The release version comes from the repo-root VERSION file (the single + # source of truth) — bump it in a commit, then dispatch this workflow. changelog: description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks." required: false @@ -133,7 +131,8 @@ jobs: - name: Build APK + AAB run: | - VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + VERSION=$(cat "$GITHUB_WORKSPACE/VERSION") + [ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; } cd app if [ -n "$ANDROID_KEYSTORE_FILE" ]; then # APK for direct sideloading, AAB for Play Store uploads. @@ -155,7 +154,8 @@ jobs: # for it (jpackage picks the native type: msi on Windows, dmg on macOS). - name: Build desktop app-image + deb run: | - VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + VERSION=$(cat "$GITHUB_WORKSPACE/VERSION") + [ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; } cd app # Self-contained app image (JRE bundled via jlink). ./gradlew :desktopApp:jpackage -PappVersion="$VERSION" @@ -177,7 +177,7 @@ jobs: SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" - VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + VERSION=$(cat "$GITHUB_WORKSPACE/VERSION") # The dispatch input is a single-line field; turn literal \n into real newlines. CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g') TAG="v$VERSION" diff --git a/CI-SETUP.md b/CI-SETUP.md index 0004b41..7994888 100644 --- a/CI-SETUP.md +++ b/CI-SETUP.md @@ -100,11 +100,13 @@ jobs: ## 3. NEW FILE: `.gitea/workflows/release.yml` -Manual trigger: **repo → Actions → Release → Run workflow**, enter a -`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI, -builds a signed Android APK + AAB and the Linux desktop packages (jpackage, -JRE bundled), then creates the Gitea release `v` with all artifacts -as download attachments. +The release version is the repo-root **`VERSION` file** (single source of +truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and +commit). Manual trigger: **repo → Actions → Release → Run workflow**, +optionally with a `changelog`. It runs the same tests as CI, builds a signed +Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled), +then creates the Gitea release `v` with all artifacts as download +attachments. Note: builds + release creation happen in ONE job because Gitea/act_runner does not implement the GitHub artifacts API (`upload-artifact@v4+` fails @@ -116,12 +118,10 @@ name: Release on: workflow_dispatch: inputs: - version: - description: "Release version (e.g. 0.2.0)" - required: true - type: string + # The release version comes from the repo-root VERSION file (the single + # source of truth) — bump it in a commit, then dispatch this workflow. changelog: - description: "Release notes (markdown, shown on the release page)" + description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks." required: false type: string @@ -246,7 +246,8 @@ jobs: - name: Build APK + AAB run: | - VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + VERSION=$(cat "$GITHUB_WORKSPACE/VERSION") + [ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; } cd app if [ -n "$ANDROID_KEYSTORE_FILE" ]; then # APK for direct sideloading, AAB for Play Store uploads. @@ -268,7 +269,8 @@ jobs: # for it (jpackage picks the native type: msi on Windows, dmg on macOS). - name: Build desktop app-image + deb run: | - VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") + VERSION=$(cat "$GITHUB_WORKSPACE/VERSION") + [ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; } cd app # Self-contained app image (JRE bundled via jlink). ./gradlew :desktopApp:jpackage -PappVersion="$VERSION" @@ -290,20 +292,39 @@ jobs: SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" - VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") - CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH") + VERSION=$(cat "$GITHUB_WORKSPACE/VERSION") + # The dispatch input is a single-line field; turn literal \n into real newlines. + CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g') TAG="v$VERSION" API="$SERVER/api/v1/repos/$REPO" AUTH="Authorization: token $TOKEN" - # Re-run safety: drop a previous release (and its tag) for this version. - OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') + # curl wrapper: on HTTP >= 400, print the response body (Gitea's error + # message) before failing — plain `curl -f` hides it (exit 22). + api() { + local code body + body=$(mktemp) + code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; } + if [ "${code:0:1}" != "2" ]; then + echo "API error $code: $(cat "$body")" >&2 + rm -f "$body" + return 1 + fi + cat "$body" + rm -f "$body" + } + + # Re-run safety: drop a previous release AND its tag for this version. + # (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag + # makes the POST below fail with 409.) + OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true if [ -n "$OLD_ID" ]; then - curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null + api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null fi + api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true # Gitea creates the tag at the default branch HEAD automatically. - RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \ "$API/releases" \ -d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \ '{tag_name:$tag, title:$title, body:$body}')" \ @@ -313,15 +334,22 @@ jobs: for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do [ -f "$f" ] || continue echo "Uploading $(basename "$f")" - curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \ - "$API/releases/$RELEASE_ID/attachments" > /dev/null + # Forgejo-style API: release assets live under /assets, not /attachments. + api -X POST -H "$AUTH" -F "attachment=@$f" \ + "$API/releases/$RELEASE_ID/assets" > /dev/null done echo "Done: $SERVER/$REPO/releases/tag/$TAG" +``` --- ## 4. EDITS to existing Gradle files +> **Note (versioning):** the `versionName` / `appVersion` lines shown below +> have since been changed to read the repo-root **`VERSION` file** (single +> source of truth; `-PappVersion` still overrides in CI). See +> `.gitea/workflows/release.yml` and `gateway-plugin/version.py`. + ### 4a. `app/androidApp/build.gradle.kts` **Change 1** — in `defaultConfig`, replace: diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..d917d3e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.2 diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index e00e5e3..8853c0f 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -16,9 +16,16 @@ android { // Play Store requires an incrementing versionCode per upload; CI can // pass -PappVersionCode=. Local builds keep the default. versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1 - // CI passes -PappVersion= (release workflow); local builds - // keep the default. - versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0" + // The repo-root VERSION file is the single source of truth (bump it + // to cut a release); CI can still override with -PappVersion. + versionName = + (project.findProperty("appVersion") as? String) + ?: project + .file("../../VERSION") + .takeIf { it.exists() } + ?.readText() + ?.trim() + ?: "0.1.0" } buildTypes { diff --git a/app/desktopApp/build.gradle.kts b/app/desktopApp/build.gradle.kts index ff18200..8a59363 100644 --- a/app/desktopApp/build.gradle.kts +++ b/app/desktopApp/build.gradle.kts @@ -9,9 +9,17 @@ plugins { val composeVersion = "1.11.1" val os = OperatingSystem.current() val arch = System.getProperty("os.arch") ?: "amd64" -// CI passes -PappVersion= (release workflow); local builds keep the -// default. jpackage requires a plain semver (no leading "v"). -val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0" +// The repo-root VERSION file is the single source of truth (bump it to cut +// a release); CI can still override with -PappVersion. jpackage requires a +// plain semver (no leading "v"). +val appVersion = + (project.findProperty("appVersion") as? String) + ?: project + .file("../../VERSION") + .takeIf { it.exists() } + ?.readText() + ?.trim() + ?: "0.1.0" val desktopTarget = when { os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64" diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index a1e7c6d..c235f33 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -33,6 +33,48 @@ val sqldelightVersion = "2.3.2" val cameraxVersion = "1.5.1" val mlKitVersion = "16.1.1" +// ── App version (generated) ───────────────────────────────────────────── +// The repo-root VERSION file is the single source of truth (bump it to cut +// a release; CI can still override with -PappVersion). Generate AppVersion.kt +// into commonMain so both targets can display it in Settings and send it to +// the gateway (X-Iris-App-Version header). +// +// A real task with the VERSION file as a DECLARED input: on a +// configuration-cache hit the script body does not re-run, so only the task +// (keyed on the file's content) can regenerate AppVersion.kt after a bump. +// The doLast reads everything from the task's own inputs/outputs (which are +// configuration-cache serializable) — it must not reference script-scope +// vals, because a .kts script lambda captures the script object and the +// configuration cache rejects that. +val generatedVersionDir = layout.buildDirectory.dir("generated/app-version") +val generateAppVersion by tasks.registering { + inputs.file(project.file("../../VERSION")) + inputs.property("appVersionOverride", (project.findProperty("appVersion") as? String).orEmpty()) + val outFile = generatedVersionDir.map { it.file("AppVersion.kt") } + outputs.file(outFile) + doLast { + val override = inputs.properties["appVersionOverride"] as? String ?: "" + val versionFile = inputs.files.singleFile + val version = + override.ifBlank { + versionFile.takeIf { it.exists() }?.readText()?.trim() ?: "0.1.0" + } + val f = outFile.get().asFile + f.parentFile?.mkdirs() + f.writeText( + """ + |package iris + | + |/** Generated from the repo-root VERSION file - do not edit. */ + |object AppVersion { + | const val VERSION = "$version" + |} + | + """.trimMargin(), + ) + } +} + kotlin { android { namespace = "iris.shared" @@ -53,6 +95,11 @@ kotlin { } sourceSets { + // AppVersion.kt is generated from the repo-root VERSION file (see + // generateAppVersion above) into commonMain so both targets can read it. + commonMain { + kotlin.srcDir(generatedVersionDir) + } // Both targets are JVM-based (androidTarget + jvm("desktop")), so // shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain. val jvmMain by creating { dependsOn(commonMain.get()) } @@ -138,3 +185,8 @@ sqldelight { } } } + +// Every Kotlin compile depends on the generated AppVersion.kt being current. +tasks.withType().configureEach { + dependsOn(generateAppVersion) +} diff --git a/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt b/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt index 20cee21..33c11f5 100644 --- a/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt +++ b/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt @@ -1,5 +1,6 @@ package iris.net +import iris.AppVersion import iris.media.Sha256 import iris.media.isValidMediaId import iris.protocol.ErrorPayload @@ -135,6 +136,9 @@ class HttpGateway( deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) } fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) } ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) } + // Release version (repo-root VERSION baked in at build time); the + // gateway stores it in the device registry (docs/04 hello.ack note). + b.add("X-Iris-App-Version", AppVersion.VERSION) return b.build() } diff --git a/app/shared/src/commonMain/kotlin/iris/net/ReleaseCheck.kt b/app/shared/src/commonMain/kotlin/iris/net/ReleaseCheck.kt new file mode 100644 index 0000000..2e1593d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/net/ReleaseCheck.kt @@ -0,0 +1,96 @@ +package iris.net + +import iris.protocol.IrisJson +import iris.util.IrisLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +/** + * Latest-release check (docs/04 hello.ack note): the repo is public on Gitea, + * so the app can ask for the newest release tag without any token. Settings + * shows "vX.Y.Z available" when the running build is older. + * + * Best-effort by design: any failure (offline, DNS, rate limit) just yields + * `null` — the check must never surface an error in the UI. + */ +object ReleaseCheck { + const val LATEST_RELEASE_URL = + "https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest" + + /** + * One shared client for the process lifetime: an OkHttpClient owns a + * thread pool and connection pool, so it must not be rebuilt per screen + * open (and never needs explicit shutdown — the pools idle out). Short + * timeouts so a black-holed network can't linger the LaunchedEffect. + */ + private val client: OkHttpClient = + OkHttpClient + .Builder() + .connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + /** + * The latest release version (tag without the leading "v"), or `null` + * when the check could not be performed. + */ + suspend fun latestVersion(): String? = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(LATEST_RELEASE_URL) + .get() + .build() + try { + client.newCall(request).execute().use { response: Response -> + if (!response.isSuccessful) { + IrisLog.d("ReleaseCheck: HTTP ${response.code}") + return@withContext null + } + val body = response.body?.string() ?: return@withContext null + val tag = + IrisJson + .instance + .parseToJsonElement(body) + .jsonObject + .get("tag_name") + ?.jsonPrimitive + ?.content + .orEmpty() + tag.removePrefix("v").ifBlank { null } + } + } catch (e: Exception) { + IrisLog.d("ReleaseCheck: ${e.message}") + null + } + } + + /** + * True when [latest] is a newer release than [running]. Numeric + * component-wise comparison (so "0.1.10" > "0.1.9"); anything that does + * not parse as dotted numbers is treated as "not newer" — the hint is + * best-effort and must never fire for dev builds ahead of the latest + * release. + */ + fun isNewer( + latest: String, + running: String, + ): Boolean { + val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() } + val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() } + if (a.isEmpty() || b.isEmpty()) return false + val n = maxOf(a.size, b.size) + for (i in 0 until n) { + val x = a.getOrElse(i) { 0 } + val y = b.getOrElse(i) { 0 } + if (x != y) return x > y + } + return false + } +} diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index fc9a4b6..7b76050 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -140,6 +140,8 @@ data class ServerCaps( val push: String = "fcm", @SerialName("push_ntfy_server") val pushNtfyServer: String = "", val pickers: Boolean = false, + /** Release version of the gateway plugin (repo-root VERSION file). */ + @SerialName("app_version") val appVersion: String = "", ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 60409fe..77034c0 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -158,6 +158,15 @@ class IrisController( private val _gatewayStatus = MutableStateFlow(null) val gatewayStatus: StateFlow = _gatewayStatus.asStateFlow() + /** + * Release version of the connected gateway (hello.ack `server_caps.app_version`, + * the repo-root VERSION file). Empty when not connected or the gateway is + * old enough not to report it. Settings shows it next to the app version + * and hints when the two differ. + */ + private val _gatewayVersion = MutableStateFlow("") + val gatewayVersion: StateFlow = _gatewayVersion.asStateFlow() + // ── M8: unread indicator ────────────────────────────────────────────── /** True while the current lane's newest content sits at the bottom of the @@ -889,6 +898,11 @@ class IrisController( // just close the in-flight turn's dangling tool cards / // streaming bubble (nothing spins forever). chat.finalizeInterrupted() + // The gateway is gone — clear the advertised version so + // Settings doesn't keep showing it (and the mismatch + // hint) while disconnected (matches the KDoc: empty when + // not connected). + _gatewayVersion.value = "" } } } @@ -927,6 +941,7 @@ class IrisController( * refreshes (skipped on a plain reconnect via historyLoaded). */ private fun onConnectedLane(connected: GatewayClient.State.Connected) { + _gatewayVersion.value = connected.caps.appVersion // Never wipe the directory with an empty list: the long-poll restore // path carries no channels when there was no prior SSE hello (lastAck // null), and the cached directory is still valid then. diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt index 379c571..29f2af1 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import iris.AppVersion +import iris.net.ReleaseCheck import iris.platform.ImageFilePicker import iris.platform.decodeImageBytes import iris.platform.loadScaledImage @@ -79,10 +81,18 @@ fun SettingsScreen( val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() val toolDetail by controller.toolDetail.collectAsState() val fontSizeScale by controller.fontSizeScale.collectAsState() + val gatewayVersion by controller.gatewayVersion.collectAsState() val theme = LocalUserTheme.current var pickerTarget by remember { mutableStateOf(null) } var showForgetConfirm by remember { mutableStateOf(false) } + // Best-effort latest-release check (docs/04): one query per screen open, + // failures stay silent (ReleaseCheck returns null). + var latestVersion by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + latestVersion = ReleaseCheck.latestVersion() + } + Box(modifier = Modifier.fillMaxSize().background(theme.background)) { Column( modifier = @@ -434,6 +444,19 @@ fun SettingsScreen( Text("Forget pairing", fontSize = 12.sp) } } + + Text( + "About", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + SettingsCard { + VersionCard( + appVersion = AppVersion.VERSION, + gatewayVersion = gatewayVersion, + latestVersion = latestVersion, + ) + } } when (pickerTarget) { @@ -534,6 +557,47 @@ private fun BackArrow( } } +/** + * About card (docs/04 hello.ack note): app version (repo-root VERSION baked in + * at build time), the connected gateway's version (hello.ack + * `server_caps.app_version`), a mismatch hint, and the latest Gitea release + * when the running build is older. + */ +@Composable +private fun VersionCard( + appVersion: String, + gatewayVersion: String, + latestVersion: String?, +) { + Text("📦 Version", fontSize = 14.sp) + Text( + "Iris app v$appVersion", + fontSize = 12.sp, + color = IrisColors.textSecondary, + ) + if (gatewayVersion.isNotBlank()) { + Text( + "Gateway v$gatewayVersion", + fontSize = 12.sp, + color = IrisColors.textSecondary, + ) + if (gatewayVersion != appVersion) { + Text( + "App and gateway versions differ — update the other side to match.", + fontSize = 12.sp, + color = IrisColors.statusAmber, + ) + } + } + latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest -> + Text( + "New version v$latest available — see the Gitea releases page.", + fontSize = 12.sp, + color = IrisColors.statusAmber, + ) + } +} + /** Settings row container (panel card). */ @Composable private fun SettingsCard(content: @Composable () -> Unit) { diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index cf99c49..49a0d31 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -40,7 +40,8 @@ Pairing succeeded. ```json {"type":"hello.ack","payload":{ "server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true, - "search":true,"push":"fcm","pickers":true}, + "search":true,"push":"fcm","pickers":true, + "app_version":"0.1.2"}, "sync_cursor":1042, "last_pushed_cursor":1040, "device_token":"9f2c…(64 hex)", @@ -48,6 +49,14 @@ Pairing succeeded. }} ``` +`server_caps.app_version` is the gateway plugin's release version (the +repo-root `VERSION` file, `gateway-plugin/version.py`). The app shows it in +Settings → About (next to its own version) and hints when the app and +gateway versions differ. +The app reports its own version on the SSE open via the +`X-Iris-App-Version` header (stored in the device registry's `caps` JSON, +visible in `~/.hermes/.../devices.db`). + `last_pushed_cursor` is the highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames with `cursor <= last_pushed_cursor` — they already diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 2f8131c..cc52945 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -21,7 +21,7 @@ "hello.ack": { "description": "Pairing succeeded.", "payload": { - "server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } }, + "server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"}, "app_version": {"type":"string","description":"Release version of the gateway plugin (repo-root VERSION file); the app shows it in Settings and hints on app/gateway mismatch."} } }, "sync_cursor": { "type": "integer" }, "last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." }, "device_token": { "type": "string", "description": "Per-device token minted at pairing (docs/09 §9.3). The app stores it and presents it INSTEAD of the shared IRIS_TOKEN from then on; the gateway can revoke it per device. Empty when the gateway didn't issue one (legacy)." }, diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 84f8225..794a2fa 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -135,6 +135,7 @@ from .setup import ( validate_config, ) from .tool_frames import ToolProgressHandlers +from .version import plugin_version logger = logging.getLogger(__name__) @@ -778,6 +779,9 @@ class IrisAdapter( self._push.server_url if isinstance(self._push, NtfyBackend) else "" ), "pickers": True, # picker.choice / picker.select (slash-command menus) + # Release version from the repo-root VERSION file (version.py); + # the app shows it in Settings and hints on app/gateway mismatch. + "app_version": plugin_version(), } diff --git a/gateway-plugin/http_server.py b/gateway-plugin/http_server.py index 351f8dc..1bc95ee 100644 --- a/gateway-plugin/http_server.py +++ b/gateway-plugin/http_server.py @@ -490,11 +490,20 @@ class HttpServer: device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120] fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") or None + # App release version (the repo-root VERSION baked into the build); + # stored in the device registry's caps JSON so `hermes` can see which + # app version each device runs (old-version awareness). A missing + # header (old app build) must not wipe a previously stored version, + # so merge over the existing caps instead of replacing them. + app_version = (handler.headers.get("X-Iris-App-Version") or "").strip()[:40] try: + existing_caps = dict(self._devices.get(device_id) or {}).get("caps") or {} + if app_version: + existing_caps["app_version"] = app_version self._devices.upsert( device_id, device_name or device_id, - None, + existing_caps or None, fcm_token, ntfy_topic, ) @@ -523,7 +532,12 @@ class HttpServer: # lifecycle is the primary "is the device connected?" signal # for debugging flaky links — a gap here is invisible at the # gateway's default log level. - logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor) + logger.info( + "iris: SSE stream opened: %s (cursor=%d, app_version=%s)", + device_id, + cursor, + app_version or "?", + ) # 1. Catch-up from the outbox (id = cursor; the envelope also # carries the cursor for the app's push dedupe). max_cursor = cursor diff --git a/gateway-plugin/version.py b/gateway-plugin/version.py new file mode 100644 index 0000000..9091049 --- /dev/null +++ b/gateway-plugin/version.py @@ -0,0 +1,26 @@ +"""Version discovery -- the repo-root ``VERSION`` file is the single source +of truth for the release version ("everything from here on out is vX.Y.Z" += bump ``VERSION`` and commit). + +The plugin lives at ``/gateway-plugin`` (installed into +``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION`` +file is one directory up. The value is advertised to the app in +``hello.ack`` (``server_caps.app_version``) so the app can show which +gateway version it is talking to. +""" + +from __future__ import annotations + +from pathlib import Path + +_FALLBACK = "unknown" + + +def plugin_version() -> str: + """The release version from ``/VERSION``, or ``"unknown"``.""" + candidate = Path(__file__).resolve().parent.parent / "VERSION" + try: + version = candidate.read_text().strip() + except OSError: + return _FALLBACK + return version or _FALLBACK diff --git a/tests/test_android.py b/tests/test_android.py index f0aaa2c..546c03e 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -431,6 +431,61 @@ async def upload_file( # ── Lifecycle ─────────────────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_hello_ack_advertises_app_version_and_sse_header_is_stored( + adapter, ws_client +): + """hello.ack server_caps carries the gateway's release version, and the + app's X-Iris-App-Version header (sent on the SSE open) is stored in the + device registry's caps JSON (docs/04).""" + client, ack = ws_client + caps = ack["payload"]["server_caps"] + assert caps["app_version"] == sys.modules["iris_plugin_under_test"].version.plugin_version() + + # The test client does not send the header; open a second SSE stream that + # does, and check the device registry picked it up. + from http.client import HTTPConnection + + conn = HTTPConnection("127.0.0.1", adapter._http_server.bound_port, timeout=5) + conn.request( + "GET", + "/v1/events?cursor=0", + headers={ + "Authorization": f"Bearer {TOKEN}", + "X-Iris-Device": DEVICE_ID, + "X-Iris-App-Version": "9.9.9", + }, + ) + resp = conn.getresponse() + assert resp.status == 200 + resp.read(1) # don't hold the stream + conn.close() + + device = adapter._devices.get(DEVICE_ID) + assert device is not None + assert device["caps"].get("app_version") == "9.9.9" + + # A reconnect from an OLD app build (no X-Iris-App-Version header) must + # NOT wipe the previously stored version — the caps merge preserves it. + conn2 = HTTPConnection("127.0.0.1", adapter._http_server.bound_port, timeout=5) + conn2.request( + "GET", + "/v1/events?cursor=0", + headers={ + "Authorization": f"Bearer {TOKEN}", + "X-Iris-Device": DEVICE_ID, + }, + ) + resp2 = conn2.getresponse() + assert resp2.status == 200 + resp2.read(1) + conn2.close() + + device2 = adapter._devices.get(DEVICE_ID) + assert device2 is not None + assert device2["caps"].get("app_version") == "9.9.9" + + @pytest.mark.asyncio async def test_disconnect_broadcasts_status_restarting(adapter): """Teardown broadcasts ``status{state=restarting}`` before closing the