Compare commits
5
Commits
v0.1.2
...
8657e6afc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8657e6afc6 | ||
|
|
5b78e1566f | ||
|
|
0f5b5a16ab | ||
|
|
ea375fd88c | ||
|
|
b065d1783b |
No files matched your search
@@ -13,3 +13,9 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
- id: check-version-sync
|
||||||
|
name: check gateway-plugin/plugin.yaml version == repo-root VERSION
|
||||||
|
entry: scripts/check_version_sync.sh
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
|
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
|
||||||
|
- A second pre-commit hook (`scripts/check_version_sync.sh`) fails any commit where the `gateway-plugin/plugin.yaml` version ≠ the repo-root `VERSION` file — keep them in sync.
|
||||||
- **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
|
- **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
|
||||||
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
|
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
|
||||||
|
|
||||||
@@ -11,12 +12,16 @@
|
|||||||
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
|
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
|
||||||
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
|
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
|
||||||
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
|
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
|
||||||
|
- `tests/` — committed Python test suite: `test_android.py` (plugin tests; a copy of the hermes-agent mirror described below), `test_android_http.py` (HTTP fallback transport, see `docs/19-http-fallback-transport.md`), `ws_probe.py`, `e2e.py`, `README.md`.
|
||||||
|
- `scripts/` — pre-commit guards (`guard_hermes_agent.sh`, `check_version_sync.sh`) and `make_release_keystore.sh`.
|
||||||
|
- `backdrops/` — backdrop/wallpaper images (Pexels) used by the app theme.
|
||||||
|
- `CI-SETUP.md` — Gitea CI/release setup reference.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
|
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
|
||||||
- Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
|
- Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
|
||||||
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
|
- Android: check the device is connected first (`adb devices` → your device's serial listed as `device`; the serial differs per developer/machine); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
|
||||||
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
|
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
|
||||||
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
|
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
|
||||||
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
|
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
|
||||||
@@ -34,7 +39,7 @@
|
|||||||
|
|
||||||
## Testing quirks
|
## Testing quirks
|
||||||
|
|
||||||
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
|
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); the committed `tests/test_android.py` is a copy of it, and `tests/test_android_http.py` covers the HTTP fallback transport. HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
|
||||||
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
|
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
|
||||||
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
|
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
|
||||||
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
|
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ Iris pairs with your running `hermes gateway` over a private, token-authenticate
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
|
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
|
||||||
- **Absolute Privacy!** — everything stays on your own infrastructure
|
- **Absolute Privacy!** — chat stays on your own infrastructure
|
||||||
(push: ntfy by default; FCM is opt-in and routes push metadata via Google —
|
(push: ntfy by default, **but the default ntfy server is the public
|
||||||
see [Push notifications](#push-notifications))
|
`ntfy.sh`** — self-host ntfy to keep push metadata on your own machine;
|
||||||
|
FCM is opt-in and routes push metadata via Google — see
|
||||||
|
[Push notifications](#push-notifications))
|
||||||
- **100 MB file uploads by default** — configurable on the gateway via
|
- **100 MB file uploads by default** — configurable on the gateway via
|
||||||
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
|
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
|
||||||
on the gateway side (hermes), not in the app
|
on the gateway side (hermes), not in the app
|
||||||
@@ -48,9 +50,11 @@ hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android
|
|||||||
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
||||||
outbox, so nothing is lost.
|
outbox, so nothing is lost.
|
||||||
|
|
||||||
- **ntfy (default)** — push metadata stays on your own infrastructure
|
- **ntfy (default)** — the backend for truly private communication.
|
||||||
(self-hosted ntfy recommended). This is the backend for truly private
|
⚠️ **By default it uses the public `https://ntfy.sh` cloud service** — push
|
||||||
communication.
|
metadata (topic, notification title) passes through ntfy.sh's servers.
|
||||||
|
Set `NTFY_SERVER_URL` to a **self-hosted ntfy** to keep push metadata on
|
||||||
|
your own infrastructure (recommended; public ntfy.sh SSE is also flaky).
|
||||||
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
|
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
|
||||||
metadata (notification title, device token) is routed through **Google's
|
metadata (notification title, device token) is routed through **Google's
|
||||||
servers**. If you want truly private communication, use ntfy instead.
|
servers**. If you want truly private communication, use ntfy instead.
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ import kotlinx.coroutines.launch
|
|||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.TimeMark
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
|
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
|
||||||
@@ -188,13 +190,34 @@ class IrisController(
|
|||||||
_foreground.value = fg
|
_foreground.value = fg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** M8: the last message id whose arrival incremented [lane]'s unread
|
||||||
|
* badge. The same finalized message can be delivered twice (live SSE
|
||||||
|
* plus the sync replay after a push-triggered reconnect) — only the
|
||||||
|
* first delivery may count. Frame-handler coroutine only. */
|
||||||
|
private val countedMessageIds = HashMap<String, String>()
|
||||||
|
|
||||||
|
/** M5/M8: when a high-priority notification banner (cron/approval/clarify)
|
||||||
|
* was last posted per lane. Cron delivery = notification frame + message
|
||||||
|
* frame; the banner already announced the delivery, so the accompanying
|
||||||
|
* message frame must not post a second system notification. Frame-handler
|
||||||
|
* coroutine only. */
|
||||||
|
private val lastHighPriorityBannerAt = HashMap<String, TimeMark>()
|
||||||
|
|
||||||
/** M8: a finalized assistant message arrived in [lane]. Count it as unread
|
/** M8: a finalized assistant message arrived in [lane]. Count it as unread
|
||||||
* unless the user is actively reading that lane right now (it is the
|
* unless the user is actively reading that lane right now (it is the
|
||||||
* current lane, the app is focused, and the newest content is at the
|
* current lane, the app is focused, and the newest content is at the
|
||||||
* bottom of the viewport). */
|
* bottom of the viewport). [messageId] dedupes redeliveries of the same
|
||||||
private fun noteIncomingAssistantMessage(lane: String) {
|
* frame (see [countedMessageIds]). */
|
||||||
|
private fun noteIncomingAssistantMessage(
|
||||||
|
lane: String,
|
||||||
|
messageId: String?,
|
||||||
|
) {
|
||||||
|
if (messageId != null && countedMessageIds[lane] == messageId) return
|
||||||
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
|
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
|
||||||
if (!beingRead) chat.markUnread(lane)
|
if (!beingRead) {
|
||||||
|
if (messageId != null) countedMessageIds[lane] = messageId
|
||||||
|
chat.markUnread(lane)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M8: the user is now viewing the current lane's newest content — clear
|
/** M8: the user is now viewing the current lane's newest content — clear
|
||||||
@@ -332,6 +355,11 @@ class IrisController(
|
|||||||
/** Max simultaneous banners; persistent ones are exempt from the cap. */
|
/** Max simultaneous banners; persistent ones are exempt from the cap. */
|
||||||
private const val MAX_BANNERS = 5
|
private const val MAX_BANNERS = 5
|
||||||
|
|
||||||
|
/** Window in which a high-priority banner suppresses the system
|
||||||
|
* notification for its accompanying message frame (mirrors the
|
||||||
|
* gateway's push-coalesce window, classify._PUSH_COALESCE_S). */
|
||||||
|
private const val BANNER_NOTIFY_SUPPRESS_MS = 5_000L
|
||||||
|
|
||||||
const val FONT_SCALE_MIN = 0.8f
|
const val FONT_SCALE_MIN = 0.8f
|
||||||
const val FONT_SCALE_MAX = 1.5f
|
const val FONT_SCALE_MAX = 1.5f
|
||||||
|
|
||||||
@@ -523,6 +551,13 @@ class IrisController(
|
|||||||
) {
|
) {
|
||||||
if (isAppForeground()) return
|
if (isAppForeground()) return
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
|
// A high-priority banner (cron/approval/clarify) for this lane just
|
||||||
|
// announced this delivery — don't stack a second notification for the
|
||||||
|
// accompanying message frame.
|
||||||
|
chatId?.let { cid ->
|
||||||
|
val mark = lastHighPriorityBannerAt[chat.laneKey(cid, threadId)]
|
||||||
|
if (mark != null && mark.elapsedNow().inWholeMilliseconds < BANNER_NOTIFY_SUPPRESS_MS) return
|
||||||
|
}
|
||||||
val id = chatId ?: "default"
|
val id = chatId ?: "default"
|
||||||
val chatName = channels.byId(id)?.name
|
val chatName = channels.byId(id)?.name
|
||||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||||
@@ -647,7 +682,7 @@ class IrisController(
|
|||||||
// content — count it as unread unless the
|
// content — count it as unread unless the
|
||||||
// user is reading this lane right now.
|
// user is reading this lane right now.
|
||||||
frame.chatId?.let { cid ->
|
frame.chatId?.let { cid ->
|
||||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
|
||||||
}
|
}
|
||||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||||
@@ -660,7 +695,7 @@ class IrisController(
|
|||||||
if (it.role == ROLE_ASSISTANT) {
|
if (it.role == ROLE_ASSISTANT) {
|
||||||
// M8: a finalized (non-streaming) reply.
|
// M8: a finalized (non-streaming) reply.
|
||||||
frame.chatId?.let { cid ->
|
frame.chatId?.let { cid ->
|
||||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
|
||||||
}
|
}
|
||||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||||
@@ -787,6 +822,15 @@ class IrisController(
|
|||||||
// sync replay) must not re-show the banner.
|
// sync replay) must not re-show the banner.
|
||||||
if (!alreadyConsumed) {
|
if (!alreadyConsumed) {
|
||||||
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||||
|
// Cron delivery = notification frame + message frame: remember
|
||||||
|
// that the banner announced this lane so the message frame
|
||||||
|
// doesn't post a second system notification.
|
||||||
|
if (p.kind in HIGH_PRIORITY_NOTIF_KINDS) {
|
||||||
|
p.chatId?.let { cid ->
|
||||||
|
lastHighPriorityBannerAt[chat.laneKey(cid, p.threadId)] =
|
||||||
|
TimeSource.Monotonic.markNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
// M5: the connection is live but the app is backgrounded — the
|
// M5: the connection is live but the app is backgrounded — the
|
||||||
// in-app banner is invisible, so mirror to a system
|
// in-app banner is invisible, so mirror to a system
|
||||||
// notification (the push backend only fires when
|
// notification (the push backend only fires when
|
||||||
|
|||||||
+5
-2
@@ -3,8 +3,11 @@
|
|||||||
The gateway can't reach a sleeping phone directly. Push goes through a cloud
|
The gateway can't reach a sleeping phone directly. Push goes through a cloud
|
||||||
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
|
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
|
||||||
Privacy: FCM push metadata (notification title, device token) is routed
|
Privacy: FCM push metadata (notification title, device token) is routed
|
||||||
through Google's servers — for truly private communication use ntfy
|
through Google's servers. ntfy is the private option — **but note the default
|
||||||
(self-hosted), which keeps everything on your own infrastructure.
|
`NTFY_SERVER_URL` is the public `https://ntfy.sh` cloud service**, so push
|
||||||
|
metadata passes through ntfy.sh's servers unless you self-host ntfy (set
|
||||||
|
`NTFY_SERVER_URL`); only a self-hosted ntfy keeps everything on your own
|
||||||
|
infrastructure.
|
||||||
|
|
||||||
## 8.1 When push fires
|
## 8.1 When push fires
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -201,9 +201,12 @@ app is closed. Nothing is lost either way — on reconnect the app syncs its
|
|||||||
outbox.
|
outbox.
|
||||||
|
|
||||||
- **ntfy (default)** — the phone generates its own topic automatically; the
|
- **ntfy (default)** — the phone generates its own topic automatically; the
|
||||||
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy**
|
gateway publishes to it. ⚠️ **The default server is the public
|
||||||
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata
|
`https://ntfy.sh` cloud service** — push metadata (topic, notification
|
||||||
stays on your own infrastructure — this is the private option.
|
title) passes through ntfy.sh's servers. Set `NTFY_SERVER_URL` to a
|
||||||
|
**self-hosted ntfy** to keep push metadata on your own infrastructure —
|
||||||
|
that is the private option (and also more reliable: the public `ntfy.sh`
|
||||||
|
SSE endpoint is flaky).
|
||||||
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
|
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
|
||||||
metadata (notification title, device token) is routed through **Google's
|
metadata (notification title, device token) is routed through **Google's
|
||||||
servers**. Needs a Firebase project + `google-services.json` in the app
|
servers**. Needs a Firebase project + `google-services.json` in the app
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ threads, media, search — Telegram-quality, on your own infrastructure.
|
|||||||
**Absolute Privacy!** — everything stays on your own infrastructure:
|
**Absolute Privacy!** — everything stays on your own infrastructure:
|
||||||
|
|
||||||
- Your gateway, your machine, your data. No cloud middleman for chat.
|
- Your gateway, your machine, your data. No cloud middleman for chat.
|
||||||
- **Push notifications:** ntfy by default — push metadata stays on your own
|
- **Push notifications:** ntfy by default. Note: out of the box it uses the
|
||||||
(self-hosted) ntfy server.
|
public ntfy.sh service; self-host ntfy (one env var) to keep push metadata
|
||||||
|
on your own server.
|
||||||
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
|
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
|
||||||
push metadata (notification title, device token) is routed through
|
push metadata (notification title, device token) is routed through
|
||||||
**Google's servers**. If you want truly private communication, use ntfy
|
**Google's servers**. If you want truly private communication, use ntfy
|
||||||
@@ -32,10 +33,12 @@ Runs on Android and desktop (Linux, macOS, Windows) from one shared codebase.
|
|||||||
## Privacy note (for the "Data safety" section / FAQ)
|
## Privacy note (for the "Data safety" section / FAQ)
|
||||||
|
|
||||||
Iris talks directly to your own hermes gateway over a private, token-authenticated
|
Iris talks directly to your own hermes gateway over a private, token-authenticated
|
||||||
connection. By default, push notifications use ntfy, which you can self-host so
|
connection. By default, push notifications use ntfy — out of the box via the
|
||||||
that push metadata never leaves your infrastructure. If you explicitly enable
|
public ntfy.sh service (push metadata such as the topic and notification title
|
||||||
FCM, push metadata (notification title, device token) is sent via Google's FCM
|
passes through ntfy.sh's servers); self-host ntfy (one env var) so that push
|
||||||
servers; chat content itself is not sent to Google — FCM only carries a short
|
metadata never leaves your infrastructure. If you explicitly enable FCM, push
|
||||||
preview, and full content is fetched from your gateway over the authenticated
|
metadata (notification title, device token) is sent via Google's FCM servers;
|
||||||
|
chat content itself is not sent to Google — FCM only carries a short preview,
|
||||||
|
and full content is fetched from your gateway over the authenticated
|
||||||
connection. For truly private communication, use the default ntfy backend
|
connection. For truly private communication, use the default ntfy backend
|
||||||
(self-hosted).
|
with a self-hosted ntfy server.
|
||||||
@@ -199,7 +199,11 @@ class HttpServer:
|
|||||||
from gateway.status import acquire_scoped_lock
|
from gateway.status import acquire_scoped_lock
|
||||||
|
|
||||||
lock_key = f"http:{host}:{port}"
|
lock_key = f"http:{host}:{port}"
|
||||||
if not acquire_scoped_lock("iris", lock_key):
|
# acquire_scoped_lock returns (acquired, existing_record); the
|
||||||
|
# tuple is always truthy, so test the first element (matching
|
||||||
|
# gateway/platforms/base.py's canonical usage).
|
||||||
|
acquired, _ = acquire_scoped_lock("iris", lock_key)
|
||||||
|
if not acquired:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"iris: HTTP port %s:%s in use by another profile; server disabled",
|
"iris: HTTP port %s:%s in use by another profile; server disabled",
|
||||||
host,
|
host,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
name: iris-platform
|
name: iris-platform
|
||||||
label: Iris
|
label: Iris
|
||||||
kind: platform
|
kind: platform
|
||||||
version: 0.1.0
|
# MUST match the repo-root VERSION file (checked by
|
||||||
|
# scripts/check_version_sync.sh on commit). This field is the version the
|
||||||
|
# gateway advertises in production installs, where only this plugin dir is
|
||||||
|
# shipped (see version.py).
|
||||||
|
version: 0.1.3
|
||||||
description: >
|
description: >
|
||||||
Native Android / Desktop client gateway adapter for Hermes Agent.
|
Native Android / Desktop client gateway adapter for Hermes Agent.
|
||||||
Runs an HTTP server (optional TLS) inside the gateway; the app connects
|
Runs an HTTP server (optional TLS) inside the gateway; the app connects
|
||||||
|
|||||||
+54
-13
@@ -1,26 +1,67 @@
|
|||||||
"""Version discovery -- the repo-root ``VERSION`` file is the single source
|
"""Version discovery.
|
||||||
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
|
The repo-root ``VERSION`` file is the single source of truth for the release
|
||||||
``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION``
|
version ("everything from here on out is vX.Y.Z" = bump ``VERSION`` and
|
||||||
file is one directory up. The value is advertised to the app in
|
commit). It is advertised to the app in ``hello.ack``
|
||||||
``hello.ack`` (``server_caps.app_version``) so the app can show which
|
(``server_caps.app_version``) so the app can show which gateway version it is
|
||||||
gateway version it is talking to.
|
talking to.
|
||||||
|
|
||||||
|
Resolution order (first hit wins):
|
||||||
|
|
||||||
|
1. ``<repo>/VERSION`` — dev checkout / symlink install: the plugin lives at
|
||||||
|
``<repo>/gateway-plugin``, so the ``VERSION`` file is one directory up.
|
||||||
|
2. ``version:`` in the plugin's own ``plugin.yaml`` — production install:
|
||||||
|
``hermes plugins install <repo>#gateway-plugin`` moves ONLY the
|
||||||
|
``gateway-plugin/`` subdirectory into ``~/.hermes/plugins/iris``, so the
|
||||||
|
repo-root ``VERSION`` is not present there. ``plugin.yaml`` ships with the
|
||||||
|
plugin dir; a pre-commit hook (``scripts/check_version_sync.sh``) keeps its
|
||||||
|
``version:`` field in sync with the repo-root ``VERSION``.
|
||||||
|
3. ``"unknown"``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_FALLBACK = "unknown"
|
_FALLBACK = "unknown"
|
||||||
|
|
||||||
|
_VERSION_RE = re.compile(r"^version:\s*[\"']?([^\"'\s]+)")
|
||||||
|
|
||||||
def plugin_version() -> str:
|
|
||||||
"""The release version from ``<repo>/VERSION``, or ``"unknown"``."""
|
def _read_version_file(path: Path) -> str:
|
||||||
candidate = Path(__file__).resolve().parent.parent / "VERSION"
|
|
||||||
try:
|
try:
|
||||||
version = candidate.read_text().strip()
|
return path.read_text().strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_yaml_version(plugin_dir: Path) -> str:
|
||||||
|
"""The top-level ``version:`` field of ``plugin.yaml`` (stdlib-only parse)."""
|
||||||
|
try:
|
||||||
|
text = (plugin_dir / "plugin.yaml").read_text()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = _VERSION_RE.match(line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_version(base: Path | None = None) -> str:
|
||||||
|
"""The release version, or ``"unknown"`` if it cannot be found.
|
||||||
|
|
||||||
|
``base`` overrides the plugin directory (tests); by default it is the
|
||||||
|
directory containing this file.
|
||||||
|
"""
|
||||||
|
plugin_dir = base if base is not None else Path(__file__).resolve().parent
|
||||||
|
# 1. Repo-root VERSION (dev checkout / symlink install).
|
||||||
|
version = _read_version_file(plugin_dir.parent / "VERSION")
|
||||||
|
if version:
|
||||||
|
return version
|
||||||
|
# 2. plugin.yaml (production install ships only the plugin dir).
|
||||||
|
version = _plugin_yaml_version(plugin_dir)
|
||||||
|
if version:
|
||||||
|
return version
|
||||||
return _FALLBACK
|
return _FALLBACK
|
||||||
return version or _FALLBACK
|
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fail the commit if gateway-plugin/plugin.yaml's `version:` field drifts
|
||||||
|
# from the repo-root VERSION file (the single source of truth).
|
||||||
|
#
|
||||||
|
# Why: `hermes plugins install <repo>#gateway-plugin` ships ONLY the
|
||||||
|
# gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so in production
|
||||||
|
# the gateway advertises the version from plugin.yaml (see
|
||||||
|
# gateway-plugin/version.py). If the two drift, the app shows a false
|
||||||
|
# "versions differ" warning.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(git rev-parse --show-toplevel)"
|
||||||
|
version_file="$repo_root/VERSION"
|
||||||
|
plugin_yaml="$repo_root/gateway-plugin/plugin.yaml"
|
||||||
|
|
||||||
|
[ -f "$version_file" ] || {
|
||||||
|
echo "check_version_sync: missing $version_file" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[ -f "$plugin_yaml" ] || {
|
||||||
|
echo "check_version_sync: missing $plugin_yaml" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
root_version="$(tr -d '[:space:]' <"$version_file")"
|
||||||
|
# Mirrors gateway-plugin/version.py's _VERSION_RE: optional single OR double
|
||||||
|
# quote, at least one captured character.
|
||||||
|
yaml_version="$(sed -n "s/^version:[[:space:]]*[\"']\{0,1\}\([^\"'[:space:]]\{1,\}\).*/\1/p" "$plugin_yaml" | head -n1)"
|
||||||
|
|
||||||
|
if [ -z "$yaml_version" ]; then
|
||||||
|
echo "check_version_sync: no top-level 'version:' field in gateway-plugin/plugin.yaml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$root_version" != "$yaml_version" ]; then
|
||||||
|
echo "check_version_sync: version drift" >&2
|
||||||
|
echo " VERSION (repo root) = $root_version" >&2
|
||||||
|
echo " gateway-plugin/plugin.yaml = $yaml_version" >&2
|
||||||
|
echo "Bump both to the same value (VERSION is the source of truth)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -486,6 +486,46 @@ async def test_hello_ack_advertises_app_version_and_sse_header_is_stored(
|
|||||||
assert device2["caps"].get("app_version") == "9.9.9"
|
assert device2["caps"].get("app_version") == "9.9.9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_version_falls_back_to_plugin_yaml_in_production_layout(plugin, tmp_path):
|
||||||
|
"""Production install (``hermes plugins install <repo>#gateway-plugin``)
|
||||||
|
ships ONLY the ``gateway-plugin/`` subdirectory into
|
||||||
|
``~/.hermes/plugins/iris`` — the repo-root ``VERSION`` file is not
|
||||||
|
present there. The advertised version must then come from
|
||||||
|
``plugin.yaml``, not "unknown" (regression: the app showed
|
||||||
|
'Gateway vunknown' against a production gateway)."""
|
||||||
|
v = plugin.version
|
||||||
|
|
||||||
|
# Dev checkout / symlink install: repo-root VERSION is the source of truth.
|
||||||
|
root_version = Path(v.__file__).resolve().parent.parent / "VERSION"
|
||||||
|
root_value = root_version.read_text().strip() if root_version.is_file() else ""
|
||||||
|
if root_value:
|
||||||
|
assert v.plugin_version() == root_value
|
||||||
|
|
||||||
|
# Simulate the production layout: a plugin dir with a plugin.yaml but no
|
||||||
|
# VERSION file one level up.
|
||||||
|
prod_dir = tmp_path / "plugins" / "iris"
|
||||||
|
prod_dir.mkdir(parents=True)
|
||||||
|
(prod_dir / "plugin.yaml").write_text(
|
||||||
|
(Path(v.__file__).parent / "plugin.yaml").read_text()
|
||||||
|
)
|
||||||
|
advertised = v.plugin_version(base=prod_dir)
|
||||||
|
assert advertised != "unknown"
|
||||||
|
# The pre-commit hook (scripts/check_version_sync.sh) keeps plugin.yaml in
|
||||||
|
# sync with the repo-root VERSION, so the production fallback must
|
||||||
|
# advertise the same real value.
|
||||||
|
if root_value:
|
||||||
|
assert advertised == root_value
|
||||||
|
|
||||||
|
# A VERSION file one level up still wins when present.
|
||||||
|
(tmp_path / "plugins" / "VERSION").write_text("9.9.9\n")
|
||||||
|
assert v.plugin_version(base=prod_dir) == "9.9.9"
|
||||||
|
|
||||||
|
# Nothing at all -> "unknown".
|
||||||
|
empty = tmp_path / "empty"
|
||||||
|
empty.mkdir()
|
||||||
|
assert v.plugin_version(base=empty) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
@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