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
This commit is contained in:
1 parent
f3f1b37221
commit
fb980d12b4
17 files changed
+423
-38
No files matched your search
@@ -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"
|
||||
|
||||
+49
-21
@@ -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<version>` 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<VERSION>` 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')
|
||||
if [ -n "$OLD_ID" ]; then
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||
# 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
|
||||
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:
|
||||
|
||||
@@ -16,9 +16,16 @@ android {
|
||||
// Play Store requires an incrementing versionCode per upload; CI can
|
||||
// pass -PappVersionCode=<n>. Local builds keep the default.
|
||||
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
|
||||
// CI passes -PappVersion=<version> (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 {
|
||||
|
||||
@@ -9,9 +9,17 @@ plugins {
|
||||
val composeVersion = "1.11.1"
|
||||
val os = OperatingSystem.current()
|
||||
val arch = System.getProperty("os.arch") ?: "amd64"
|
||||
// CI passes -PappVersion=<version> (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"
|
||||
|
||||
@@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
dependsOn(generateAppVersion)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@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
|
||||
|
||||
@@ -158,6 +158,15 @@ class IrisController(
|
||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -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<PickerTarget?>(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<String?>(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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)." },
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user