Release version management: single VERSION file as source of truth
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m0s

- 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
This commit is contained in:
ARIA committed 2026-08-25 14:42:39 +02:00
1 parent f3f1b37221
commit fb980d12b4
17 files changed
+423 -38

No files matched your search

+7 -7
View File
@@ -3,10 +3,8 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: # The release version comes from the repo-root VERSION file (the single
description: "Release version (e.g. 0.2.0)" # source of truth) — bump it in a commit, then dispatch this workflow.
required: true
type: string
changelog: changelog:
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks." description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false required: false
@@ -133,7 +131,8 @@ jobs:
- name: Build APK + AAB - name: Build APK + AAB
run: | 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 cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads. # 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). # for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb - name: Build desktop app-image + deb
run: | 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 cd app
# Self-contained app image (JRE bundled via jlink). # Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION" ./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -177,7 +177,7 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" 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. # 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') CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION" TAG="v$VERSION"
+49 -21
View File
@@ -100,11 +100,13 @@ jobs:
## 3. NEW FILE: `.gitea/workflows/release.yml` ## 3. NEW FILE: `.gitea/workflows/release.yml`
Manual trigger: **repo → Actions → Release → Run workflow**, enter a The release version is the repo-root **`VERSION` file** (single source of
`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI, truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
builds a signed Android APK + AAB and the Linux desktop packages (jpackage, commit). Manual trigger: **repo → Actions → Release → Run workflow**,
JRE bundled), then creates the Gitea release `v<version>` with all artifacts optionally with a `changelog`. It runs the same tests as CI, builds a signed
as download attachments. Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled),
then creates the Gitea release `v<VERSION>` with all artifacts as download
attachments.
Note: builds + release creation happen in ONE job because Gitea/act_runner Note: builds + release creation happen in ONE job because Gitea/act_runner
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
@@ -116,12 +118,10 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: # The release version comes from the repo-root VERSION file (the single
description: "Release version (e.g. 0.2.0)" # source of truth) — bump it in a commit, then dispatch this workflow.
required: true
type: string
changelog: 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 required: false
type: string type: string
@@ -246,7 +246,8 @@ jobs:
- name: Build APK + AAB - name: Build APK + AAB
run: | 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 cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads. # 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). # for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb - name: Build desktop app-image + deb
run: | 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 cd app
# Self-contained app image (JRE bundled via jlink). # Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION" ./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -290,20 +292,39 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH") # 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" TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO" API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN" AUTH="Authorization: token $TOKEN"
# Re-run safety: drop a previous release (and its tag) for this version. # curl wrapper: on HTTP >= 400, print the response body (Gitea's error
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') # message) before failing — plain `curl -f` hides it (exit 22).
if [ -n "$OLD_ID" ]; then api() {
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null 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 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
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. # 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" \ "$API/releases" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \ -d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{tag_name:$tag, title:$title, body:$body}')" \ '{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 for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
echo "Uploading $(basename "$f")" echo "Uploading $(basename "$f")"
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \ # Forgejo-style API: release assets live under /assets, not /attachments.
"$API/releases/$RELEASE_ID/attachments" > /dev/null api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done done
echo "Done: $SERVER/$REPO/releases/tag/$TAG" echo "Done: $SERVER/$REPO/releases/tag/$TAG"
```
--- ---
## 4. EDITS to existing Gradle files ## 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` ### 4a. `app/androidApp/build.gradle.kts`
**Change 1** — in `defaultConfig`, replace: **Change 1** — in `defaultConfig`, replace:
+1
View File
@@ -0,0 +1 @@
0.1.2
+10 -3
View File
@@ -16,9 +16,16 @@ android {
// Play Store requires an incrementing versionCode per upload; CI can // Play Store requires an incrementing versionCode per upload; CI can
// pass -PappVersionCode=<n>. Local builds keep the default. // pass -PappVersionCode=<n>. Local builds keep the default.
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1 versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
// CI passes -PappVersion=<version> (release workflow); local builds // The repo-root VERSION file is the single source of truth (bump it
// keep the default. // to cut a release); CI can still override with -PappVersion.
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0" versionName =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
} }
buildTypes { buildTypes {
+11 -3
View File
@@ -9,9 +9,17 @@ plugins {
val composeVersion = "1.11.1" val composeVersion = "1.11.1"
val os = OperatingSystem.current() val os = OperatingSystem.current()
val arch = System.getProperty("os.arch") ?: "amd64" val arch = System.getProperty("os.arch") ?: "amd64"
// CI passes -PappVersion=<version> (release workflow); local builds keep the // The repo-root VERSION file is the single source of truth (bump it to cut
// default. jpackage requires a plain semver (no leading "v"). // a release); CI can still override with -PappVersion. jpackage requires a
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0" // 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 = val desktopTarget =
when { when {
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64" os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
+52
View File
@@ -33,6 +33,48 @@ val sqldelightVersion = "2.3.2"
val cameraxVersion = "1.5.1" val cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.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 { kotlin {
android { android {
namespace = "iris.shared" namespace = "iris.shared"
@@ -53,6 +95,11 @@ kotlin {
} }
sourceSets { 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 // Both targets are JVM-based (androidTarget + jvm("desktop")), so
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain. // shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
val jvmMain by creating { dependsOn(commonMain.get()) } 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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(generateAppVersion)
}
@@ -1,5 +1,6 @@
package iris.net package iris.net
import iris.AppVersion
import iris.media.Sha256 import iris.media.Sha256
import iris.media.isValidMediaId import iris.media.isValidMediaId
import iris.protocol.ErrorPayload import iris.protocol.ErrorPayload
@@ -135,6 +136,9 @@ class HttpGateway(
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) } deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", 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) } 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() return b.build()
} }
@@ -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
}
}
@@ -140,6 +140,8 @@ data class ServerCaps(
val push: String = "fcm", val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "", @SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false, val pickers: Boolean = false,
/** Release version of the gateway plugin (repo-root VERSION file). */
@SerialName("app_version") val appVersion: String = "",
) )
@Serializable @Serializable
@@ -158,6 +158,15 @@ class IrisController(
private val _gatewayStatus = MutableStateFlow<String?>(null) private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow() val gatewayStatus: StateFlow<String?> = _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<String> = _gatewayVersion.asStateFlow()
// ── M8: unread indicator ────────────────────────────────────────────── // ── M8: unread indicator ──────────────────────────────────────────────
/** True while the current lane's newest content sits at the bottom of the /** 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 / // just close the in-flight turn's dangling tool cards /
// streaming bubble (nothing spins forever). // streaming bubble (nothing spins forever).
chat.finalizeInterrupted() 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). * refreshes (skipped on a plain reconnect via historyLoaded).
*/ */
private fun onConnectedLane(connected: GatewayClient.State.Connected) { private fun onConnectedLane(connected: GatewayClient.State.Connected) {
_gatewayVersion.value = connected.caps.appVersion
// Never wipe the directory with an empty list: the long-poll restore // Never wipe the directory with an empty list: the long-poll restore
// path carries no channels when there was no prior SSE hello (lastAck // path carries no channels when there was no prior SSE hello (lastAck
// null), and the cached directory is still valid then. // null), and the cached directory is still valid then.
@@ -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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import iris.AppVersion
import iris.net.ReleaseCheck
import iris.platform.ImageFilePicker import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage import iris.platform.loadScaledImage
@@ -79,10 +81,18 @@ fun SettingsScreen(
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState() val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState() val fontSizeScale by controller.fontSizeScale.collectAsState()
val gatewayVersion by controller.gatewayVersion.collectAsState()
val theme = LocalUserTheme.current val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) } 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<String?>(null) }
LaunchedEffect(Unit) {
latestVersion = ReleaseCheck.latestVersion()
}
Box(modifier = Modifier.fillMaxSize().background(theme.background)) { Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column( Column(
modifier = modifier =
@@ -434,6 +444,19 @@ fun SettingsScreen(
Text("Forget pairing", fontSize = 12.sp) 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) { 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). */ /** Settings row container (panel card). */
@Composable @Composable
private fun SettingsCard(content: @Composable () -> Unit) { private fun SettingsCard(content: @Composable () -> Unit) {
+10 -1
View File
@@ -40,7 +40,8 @@ Pairing succeeded.
```json ```json
{"type":"hello.ack","payload":{ {"type":"hello.ack","payload":{
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true, "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, "sync_cursor":1042,
"last_pushed_cursor":1040, "last_pushed_cursor":1040,
"device_token":"9f2c…(64 hex)", "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 `last_pushed_cursor` is the highest outbox cursor already delivered to THIS
device via the push backend (0 = never). The app skips system notifications device via the push backend (0 = never). The app skips system notifications
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
+1 -1
View File
@@ -21,7 +21,7 @@
"hello.ack": { "hello.ack": {
"description": "Pairing succeeded.", "description": "Pairing succeeded.",
"payload": { "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" }, "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)." }, "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)." }, "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)." },
+4
View File
@@ -135,6 +135,7 @@ from .setup import (
validate_config, validate_config,
) )
from .tool_frames import ToolProgressHandlers from .tool_frames import ToolProgressHandlers
from .version import plugin_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -778,6 +779,9 @@ class IrisAdapter(
self._push.server_url if isinstance(self._push, NtfyBackend) else "" self._push.server_url if isinstance(self._push, NtfyBackend) else ""
), ),
"pickers": True, # picker.choice / picker.select (slash-command menus) "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(),
} }
+16 -2
View File
@@ -490,11 +490,20 @@ class HttpServer:
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120] device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") 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: 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( self._devices.upsert(
device_id, device_id,
device_name or device_id, device_name or device_id,
None, existing_caps or None,
fcm_token, fcm_token,
ntfy_topic, ntfy_topic,
) )
@@ -523,7 +532,12 @@ class HttpServer:
# lifecycle is the primary "is the device connected?" signal # lifecycle is the primary "is the device connected?" signal
# for debugging flaky links — a gap here is invisible at the # for debugging flaky links — a gap here is invisible at the
# gateway's default log level. # 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 # 1. Catch-up from the outbox (id = cursor; the envelope also
# carries the cursor for the app's push dedupe). # carries the cursor for the app's push dedupe).
max_cursor = cursor max_cursor = cursor
+26
View File
@@ -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 ``<repo>/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 ``<repo>/VERSION``, or ``"unknown"``."""
candidate = Path(__file__).resolve().parent.parent / "VERSION"
try:
version = candidate.read_text().strip()
except OSError:
return _FALLBACK
return version or _FALLBACK
+55
View File
@@ -431,6 +431,61 @@ async def upload_file(
# ── Lifecycle ─────────────────────────────────────────────────────────────── # ── 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 @pytest.mark.asyncio
async def test_disconnect_broadcasts_status_restarting(adapter): async def test_disconnect_broadcasts_status_restarting(adapter):
"""Teardown broadcasts ``status{state=restarting}`` before closing the """Teardown broadcasts ``status{state=restarting}`` before closing the