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
+422
-37
No files matched your search
@@ -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"
|
||||||
|
|||||||
+48
-20
@@ -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).
|
||||||
|
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
|
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
|
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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)." },
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
# ── 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
|
||||||
|
|||||||
Reference in new issue
Block a user