Compare commits
43
Commits
7309158e12
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7a16d51e3 | ||
|
|
b1c9bac7d8 | ||
|
|
a61b47a947 | ||
|
|
597a28050f | ||
|
|
f90e40a3fc | ||
|
|
330e63e941 | ||
|
|
b8e756c3dd | ||
|
|
7faaf2aa1c | ||
|
|
746d809d48 | ||
|
|
70282dfb65 | ||
|
|
44e8c7322e | ||
|
|
29d0c1a73f | ||
|
|
d801a18db5 | ||
|
|
560d9c19b3 | ||
|
|
32db7fc4e8 | ||
|
|
863ab34915 | ||
|
|
a4e4a4ea63 | ||
|
|
ca622d3a39 | ||
|
|
abbed438ec | ||
|
|
6458c3183c | ||
|
|
9c50f2dbc1 | ||
|
|
487fd1c83c | ||
|
|
4e9f1d028a | ||
|
|
742916903b | ||
|
|
1ff2ef380c | ||
|
|
82c5a20848 | ||
|
|
34a64d6e53 | ||
|
|
7a6d922d12 | ||
|
|
27dc7917f2 | ||
|
|
4866f14231 | ||
|
|
8fba00ba7f | ||
|
|
7f0bdcbbc1 | ||
|
|
e6015033b6 | ||
|
|
2349a95dd4 | ||
|
|
7f936fa596 | ||
|
|
e5c7d690b8 | ||
|
|
524ed8ce53 | ||
|
|
dd43033888 | ||
|
|
6591d7cec0 | ||
|
|
3a33f6be15 | ||
|
|
c71636ad55 | ||
|
|
5a69e3927b | ||
|
|
1f182a7e7e |
No files matched your search
+65
-59
@@ -1,73 +1,79 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# The gateway tests run inside the hermes-agent test harness, which is
|
||||
# git-ignored in this repo (read-only research reference). CI clones the
|
||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
# The gateway tests run inside the hermes-agent test harness, which is
|
||||
# git-ignored in this repo (read-only research reference). CI clones the
|
||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
uv sync
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
||||
# strip it so CI uses the JDK installed by setup-java.
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
||||
# strip it so CI uses the JDK installed by setup-java.
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
||||
# missing SDK platforms (licenses accepted above).
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
||||
# missing SDK platforms (licenses accepted above).
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
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
|
||||
|
||||
@@ -32,13 +32,15 @@ jobs:
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
uv sync
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
@@ -63,7 +65,11 @@ jobs:
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
@@ -71,8 +77,11 @@ jobs:
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
|
||||
android:
|
||||
name: Build Android APK
|
||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
||||
release:
|
||||
name: Build + create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
@@ -94,10 +103,19 @@ jobs:
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
||||
# does not ship.
|
||||
- name: Install fakeroot (for jpackage --type deb)
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
||||
|
||||
- name: Restore release keystore (from Gitea secrets)
|
||||
env:
|
||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
@@ -113,45 +131,29 @@ jobs:
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
||||
fi
|
||||
|
||||
- name: Build APK
|
||||
- name: Build APK + AAB
|
||||
run: |
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
cd app
|
||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
./gradlew :androidApp:assembleRelease -PappVersion="$VERSION"
|
||||
# APK for direct sideloading, AAB for Play Store uploads.
|
||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
||||
else
|
||||
./gradlew :androidApp:assembleDebug -PappVersion="$VERSION"
|
||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android
|
||||
path: iris-android-*.apk
|
||||
|
||||
desktop:
|
||||
name: Build desktop (Linux, jpackage)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
# jpackage cannot cross-compile: this job only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, copy this job, change
|
||||
# runs-on, and drop the -x64-linux suffix (jpackage picks the native
|
||||
# type: msi on Windows, dmg on macOS).
|
||||
- name: Build app-image + deb
|
||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, add a second build job
|
||||
# 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")
|
||||
cd app
|
||||
@@ -159,27 +161,11 @@ jobs:
|
||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||
(cd desktopApp/build/jpackage && zip -qr \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
||||
# .deb package (dpkg-deb ships with Ubuntu).
|
||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
||||
cp desktopApp/build/jpackage/*.deb \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop
|
||||
path: |
|
||||
iris-desktop-linux-x64-*.zip
|
||||
iris-desktop-linux-x64-*.deb
|
||||
|
||||
release:
|
||||
name: Create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [android, desktop]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create release + upload artifacts
|
||||
env:
|
||||
# Optional: create a personal access token (scope: Releases: write)
|
||||
@@ -192,29 +178,49 @@ jobs:
|
||||
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")
|
||||
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
||||
TAG="v$VERSION"
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
# Re-run safety: drop a previous release (and its tag) for this version.
|
||||
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
|
||||
# message) before failing — plain `curl -f` hides it (exit 22).
|
||||
api() {
|
||||
local code body
|
||||
body=$(mktemp)
|
||||
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
||||
if [ "${code:0:1}" != "2" ]; then
|
||||
echo "API error $code: $(cat "$body")" >&2
|
||||
rm -f "$body"
|
||||
return 1
|
||||
fi
|
||||
cat "$body"
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
# Re-run safety: drop a previous release AND its tag for this version.
|
||||
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
||||
# makes the POST below fail with 409.)
|
||||
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
||||
if [ -n "$OLD_ID" ]; then
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||
fi
|
||||
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
|
||||
|
||||
# Gitea creates the tag at the default branch HEAD automatically.
|
||||
RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"$API/releases" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
||||
| jq -r .id)
|
||||
echo "Created release $TAG (id $RELEASE_ID)"
|
||||
|
||||
for f in artifacts/*/*; do
|
||||
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"
|
||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
@@ -3,7 +3,8 @@
|
||||
## 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.
|
||||
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine).
|
||||
- **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).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -14,26 +15,26 @@
|
||||
## Commands
|
||||
|
||||
- `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 `ANDROID_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).
|
||||
- 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).
|
||||
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
|
||||
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
|
||||
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
|
||||
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
|
||||
|
||||
## Environment / pairing quirks
|
||||
|
||||
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
|
||||
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
||||
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
|
||||
- HTTP default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
||||
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
||||
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
||||
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
|
||||
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
|
||||
- **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
|
||||
- Desktop jpackage on Linux/JDK 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
|
||||
|
||||
## 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 `ANDROID_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`); 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.
|
||||
- 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.
|
||||
|
||||
+46
-57
@@ -58,7 +58,7 @@ jobs:
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
@@ -102,9 +102,13 @@ jobs:
|
||||
|
||||
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 + the Linux desktop packages (jpackage, JRE
|
||||
bundled), then creates the Gitea release `v<version>` with all artifacts as
|
||||
download attachments.
|
||||
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
|
||||
with `GHESNotSupportedError`).
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
@@ -141,13 +145,15 @@ jobs:
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
uv sync
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
@@ -172,7 +178,11 @@ jobs:
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
@@ -180,8 +190,11 @@ jobs:
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
|
||||
android:
|
||||
name: Build Android APK
|
||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
||||
release:
|
||||
name: Build + create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
@@ -203,10 +216,19 @@ jobs:
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
||||
# does not ship.
|
||||
- name: Install fakeroot (for jpackage --type deb)
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
||||
|
||||
- name: Restore release keystore (from Gitea secrets)
|
||||
env:
|
||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
@@ -222,45 +244,29 @@ jobs:
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
||||
fi
|
||||
|
||||
- name: Build APK
|
||||
- name: Build APK + AAB
|
||||
run: |
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
cd app
|
||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
./gradlew :androidApp:assembleRelease -PappVersion="$VERSION"
|
||||
# APK for direct sideloading, AAB for Play Store uploads.
|
||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
||||
else
|
||||
./gradlew :androidApp:assembleDebug -PappVersion="$VERSION"
|
||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android
|
||||
path: iris-android-*.apk
|
||||
|
||||
desktop:
|
||||
name: Build desktop (Linux, jpackage)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
# jpackage cannot cross-compile: this job only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, copy this job, change
|
||||
# runs-on, and drop the -x64-linux suffix (jpackage picks the native
|
||||
# type: msi on Windows, dmg on macOS).
|
||||
- name: Build app-image + deb
|
||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, add a second build job
|
||||
# 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")
|
||||
cd app
|
||||
@@ -268,27 +274,11 @@ jobs:
|
||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||
(cd desktopApp/build/jpackage && zip -qr \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
||||
# .deb package (dpkg-deb ships with Ubuntu).
|
||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
||||
cp desktopApp/build/jpackage/*.deb \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop
|
||||
path: |
|
||||
iris-desktop-linux-x64-*.zip
|
||||
iris-desktop-linux-x64-*.deb
|
||||
|
||||
release:
|
||||
name: Create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [android, desktop]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create release + upload artifacts
|
||||
env:
|
||||
# Optional: create a personal access token (scope: Releases: write)
|
||||
@@ -320,14 +310,13 @@ jobs:
|
||||
| jq -r .id)
|
||||
echo "Created release $TAG (id $RELEASE_ID)"
|
||||
|
||||
for f in artifacts/*/*; do
|
||||
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
|
||||
done
|
||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
|
||||
|
||||
Iris pairs with your running `hermes gateway` over a private WebSocket and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
|
||||
Iris pairs with your running `hermes gateway` over a private, token-authenticated connection and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
|
||||
|
||||
## Features
|
||||
|
||||
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
|
||||
- **Absolute Privacy!** — everything stays on your own infrastructure
|
||||
- **No file limit**
|
||||
- **No character limit**
|
||||
(push: ntfy by default; 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
|
||||
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
|
||||
on the gateway side (hermes), not in the app
|
||||
- **No 4,096-character message limit like Telegram** — messages travel over
|
||||
your own gateway (hard frame-body cap: 1 MiB)
|
||||
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
|
||||
- **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView
|
||||
- **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly.
|
||||
@@ -24,11 +29,11 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
|
||||
## How it works
|
||||
|
||||
```
|
||||
hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (Android / Desktop)
|
||||
hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android / Desktop)
|
||||
```
|
||||
|
||||
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
|
||||
`hermes gateway` process and opens a WebSocket server the apps connect to.
|
||||
`hermes gateway` process and opens an HTTP server the apps connect to.
|
||||
Zero new Python dependencies, zero hermes-core changes.
|
||||
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
|
||||
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
|
||||
@@ -36,14 +41,51 @@ hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (And
|
||||
- The app is a first-class hermes *messaging platform*, so everything the gateway
|
||||
already does just works: slash commands, cron delivery, `send_message` routing,
|
||||
coexistence with Telegram/Discord/etc.
|
||||
- Push notifications: FCM (primary) or ntfy (fallback).
|
||||
- Push notifications: ntfy (default) or FCM (opt-in).
|
||||
|
||||
## Push notifications
|
||||
|
||||
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
||||
outbox, so nothing is lost.
|
||||
|
||||
- **ntfy (default)** — push metadata stays on your own infrastructure
|
||||
(self-hosted ntfy recommended). This is the backend for truly private
|
||||
communication.
|
||||
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
|
||||
metadata (notification title, device token) is routed through **Google's
|
||||
servers**. If you want truly private communication, use ntfy instead.
|
||||
|
||||
Setup: [`docs/install.md`](docs/install.md); details:
|
||||
[`docs/08-push.md`](docs/08-push.md).
|
||||
|
||||
## Install the gateway
|
||||
|
||||
Three commands on the machine where hermes runs:
|
||||
|
||||
```bash
|
||||
cd hermes-agent && uv sync # 1. hermes with its venv (separate project, not this repo)
|
||||
|
||||
# 2. install the Iris plugin — the #gateway-plugin suffix points the
|
||||
# installer at the plugin subfolder of this monorepo
|
||||
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
|
||||
|
||||
# 3. generate the pairing token + server URL, then run the gateway
|
||||
hermes gateway setup
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
`hermes gateway setup` prints the **server URL** and **pairing token / QR**
|
||||
the app needs on its Connect screen.
|
||||
|
||||
All options (LAN binding, TLS, push, device allowlist) and the full app
|
||||
pairing walkthrough: [`docs/install.md`](docs/install.md).
|
||||
|
||||
## Build from source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Where | You need |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) |
|
||||
| Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device |
|
||||
| Desktop build machine | JDK 17 only |
|
||||
@@ -52,19 +94,21 @@ No system Gradle needed — both apps use the project wrapper (`./gradlew`).
|
||||
|
||||
### 1. Gateway (on the gateway host)
|
||||
|
||||
If you're developing from a checkout, skip `hermes plugins install` and
|
||||
symlink the plugin so it always tracks your working tree:
|
||||
|
||||
```bash
|
||||
# hermes-agent is a separate project (not part of this repo)
|
||||
cd hermes-agent && uv sync
|
||||
|
||||
# install the Iris plugin into the live hermes home
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
|
||||
hermes gateway status # should list "android"
|
||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||
hermes gateway status # should list the Iris platform
|
||||
|
||||
hermes gateway setup # generates ANDROID_TOKEN, prints the server URL
|
||||
hermes gateway setup # generates IRIS_TOKEN, prints server URL + pairing QR
|
||||
hermes gateway # run the gateway
|
||||
```
|
||||
|
||||
(Otherwise see [Install the gateway](#install-the-gateway) above.)
|
||||
|
||||
### 2. Android app
|
||||
|
||||
```bash
|
||||
@@ -86,21 +130,22 @@ cd app
|
||||
|
||||
On the app's **Connect** screen:
|
||||
|
||||
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`).
|
||||
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env`
|
||||
1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
|
||||
2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
|
||||
on the gateway host.
|
||||
3. **Test & Connect.**
|
||||
|
||||
Notes:
|
||||
|
||||
- The app has **no QR scanner** — pairing is manual URL + token entry.
|
||||
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone
|
||||
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
||||
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
|
||||
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
|
||||
- **Android** has a **Scan QR** button that reads the QR printed by
|
||||
`hermes gateway setup` and pre-fills URL + token; desktop uses manual entry.
|
||||
- The default bind is `127.0.0.1` (desktop on the same machine only). For a
|
||||
phone on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
||||
- Remote access: Tailscale/WireGuard, or a reverse proxy/tunnel with TLS
|
||||
(`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`).
|
||||
|
||||
Full walkthrough, push setup (FCM/ntfy), and troubleshooting:
|
||||
[`docs/setup.md`](docs/setup.md).
|
||||
Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
|
||||
[`docs/install.md`](docs/install.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -112,7 +157,7 @@ Contributions are welcome! Before you start:
|
||||
2. **Know the layout.**
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth |
|
||||
| `app/shared` | KMP module with most of the client code (shared by Android + Desktop) |
|
||||
| `app/androidApp` | Thin Android shell (package `dev.iris.app`) |
|
||||
@@ -135,4 +180,4 @@ Open an issue first for anything big, then send a pull request.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 — see [LICENSE](LICENSE).
|
||||
Apache License 2.0 — see [LICENSE](LICENSE).
|
||||
@@ -13,7 +13,9 @@ android {
|
||||
applicationId = "dev.iris.app"
|
||||
minSdk = 29
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
// 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"
|
||||
@@ -80,4 +82,4 @@ dependencies {
|
||||
// inert and the ntfy listener is the push path.
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,16 @@
|
||||
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- M-3: cleartext (http://) is required because the gateway is a LAN host
|
||||
addressed by IP (e.g. 192.168.x.x), not a domain. Android's
|
||||
network_security_config can only scope cleartext to domain names, not
|
||||
IP ranges, so a per-host allowlist isn't possible for this use case.
|
||||
The token is still required for auth; traffic is only ever sent to the
|
||||
user-configured gateway on the local network. -->
|
||||
<application
|
||||
android:label="Iris"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -38,8 +47,22 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="iris" android:host="chat" />
|
||||
</intent-filter>
|
||||
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="iris" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- QR pairing (docs/20): full-screen scanner launched from the
|
||||
Connect screen. Not exported; started only by our own app. -->
|
||||
<activity
|
||||
android:name="iris.platform.QrScanActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -16,11 +16,16 @@ import iris.platform.AndroidEnv
|
||||
import iris.platform.AndroidSecureStore
|
||||
import iris.platform.AppBridge
|
||||
import iris.platform.syncNtfyListener
|
||||
import iris.util.PairLink
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val deepLinkChatId = mutableStateOf<String?>(null)
|
||||
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
||||
|
||||
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
|
||||
// screen with.
|
||||
private val pendingPair = mutableStateOf<PairLink?>(null)
|
||||
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
||||
|
||||
@@ -37,7 +42,13 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
val chatId by deepLinkChatId
|
||||
val threadId by deepLinkThreadId
|
||||
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
|
||||
val pair by pendingPair
|
||||
IrisApp(
|
||||
store = store,
|
||||
deepLinkChatId = chatId,
|
||||
deepLinkThreadId = threadId,
|
||||
deepLinkPair = pair,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +60,10 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
AppBridge.foreground = true
|
||||
// Wake the connect loop's backoff: after a background stint the
|
||||
// network is usually back, so re-probe immediately instead of making
|
||||
// the user wait out the (up to 30 s) backoff with a Connecting banner.
|
||||
AppBridge.controller?.client?.poke()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -60,6 +75,14 @@ class MainActivity : ComponentActivity() {
|
||||
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
||||
private fun handleDeepLink(intent: Intent?) {
|
||||
val data = intent?.data
|
||||
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
|
||||
// the whole payload is in the query string; parse it with the shared
|
||||
// PairLink parser.
|
||||
if (data?.scheme == "iris" && data.host == "pair") {
|
||||
val link = PairLink.parse(data.toString())
|
||||
if (link != null) pendingPair.value = link
|
||||
return
|
||||
}
|
||||
val chatId =
|
||||
intent?.getStringExtra("chat_id")
|
||||
?: data?.pathSegments?.firstOrNull()
|
||||
|
||||
@@ -12,11 +12,12 @@ 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"
|
||||
val desktopTarget = when {
|
||||
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
|
||||
os.isWindows -> "windows-x64"
|
||||
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
|
||||
}
|
||||
val desktopTarget =
|
||||
when {
|
||||
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
|
||||
os.isWindows -> "windows-x64"
|
||||
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
@@ -48,19 +49,21 @@ afterEvaluate {
|
||||
|
||||
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
|
||||
|
||||
val fatJar = tasks.register<Jar>("fatJar") {
|
||||
archiveClassifier.set("all")
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
|
||||
from(sourceSets.main.get().output)
|
||||
from({
|
||||
configurations.runtimeClasspath.get()
|
||||
.filter { it.name.endsWith(".jar") }
|
||||
.map { zipTree(it) }
|
||||
}) {
|
||||
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
|
||||
val fatJar =
|
||||
tasks.register<Jar>("fatJar") {
|
||||
archiveClassifier.set("all")
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
|
||||
from(sourceSets.main.get().output)
|
||||
from({
|
||||
configurations.runtimeClasspath
|
||||
.get()
|
||||
.filter { it.name.endsWith(".jar") }
|
||||
.map { zipTree(it) }
|
||||
}) {
|
||||
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage the fat jar into the jpackage input dir. A dedicated Sync task keeps
|
||||
// the Exec task free of task references (configuration-cache compatible) and
|
||||
@@ -76,23 +79,39 @@ tasks.register<Exec>("jpackage") {
|
||||
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
|
||||
val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
|
||||
val inputDir = jpackageInputDir.get().asFile
|
||||
val destDir = layout.buildDirectory.dir("jpackage").get().asFile
|
||||
val destDir =
|
||||
layout.buildDirectory
|
||||
.dir("jpackage")
|
||||
.get()
|
||||
.asFile
|
||||
inputs.dir(inputDir)
|
||||
outputs.dir(destDir)
|
||||
commandLine(
|
||||
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
|
||||
"--name", "iris",
|
||||
"--app-version", appVersion,
|
||||
"--vendor", "Iris",
|
||||
"--type", type,
|
||||
"--input", inputDir.absolutePath,
|
||||
"--main-jar", "desktopApp-all.jar",
|
||||
"--main-class", "iris.desktop.MainKt",
|
||||
"--icon", file("src/main/resources/icon.png").absolutePath,
|
||||
"--dest", destDir.absolutePath,
|
||||
"--java-options", "-Xmx1g",
|
||||
"--name",
|
||||
"iris",
|
||||
"--app-version",
|
||||
appVersion,
|
||||
"--vendor",
|
||||
"Iris",
|
||||
"--type",
|
||||
type,
|
||||
"--input",
|
||||
inputDir.absolutePath,
|
||||
"--main-jar",
|
||||
"desktopApp-all.jar",
|
||||
"--main-class",
|
||||
"iris.desktop.MainKt",
|
||||
"--icon",
|
||||
file("src/main/resources/icon.png").absolutePath,
|
||||
"--dest",
|
||||
destDir.absolutePath,
|
||||
"--java-options",
|
||||
"-Xmx1g",
|
||||
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
|
||||
"--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
|
||||
"--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
|
||||
"--java-options",
|
||||
"--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
|
||||
"--java-options",
|
||||
"--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,12 @@ import iris.IrisApp
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.DesktopBridge
|
||||
import iris.platform.DesktopSecureStore
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics2D
|
||||
import javax.imageio.ImageIO
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.SystemTray
|
||||
import java.awt.event.WindowEvent
|
||||
@@ -29,10 +32,7 @@ import java.awt.event.WindowFocusListener
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
/**
|
||||
* M6: desktop shell (docs/11 §11.2/§11.3).
|
||||
@@ -48,6 +48,7 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso
|
||||
|
||||
// Window/taskbar icon (src/main/resources/icon.png).
|
||||
private class IrisDesktop
|
||||
|
||||
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
||||
|
||||
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
||||
@@ -87,15 +88,37 @@ fun main() {
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
val state = DesktopBridge.controller?.client?.state?.value
|
||||
val (color, tooltip) = when (state) {
|
||||
null,
|
||||
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline"
|
||||
is GatewayClient.State.Connecting,
|
||||
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…"
|
||||
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected"
|
||||
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed"
|
||||
}
|
||||
val state =
|
||||
DesktopBridge.controller
|
||||
?.client
|
||||
?.state
|
||||
?.value
|
||||
val (color, tooltip) =
|
||||
when (state) {
|
||||
null,
|
||||
is GatewayClient.State.Disconnected,
|
||||
-> {
|
||||
TRAY_OFFLINE to "Iris — offline"
|
||||
}
|
||||
|
||||
is GatewayClient.State.Connecting,
|
||||
is GatewayClient.State.Reconnecting,
|
||||
-> {
|
||||
TRAY_CONNECTING to "Iris — connecting…"
|
||||
}
|
||||
|
||||
is GatewayClient.State.Connected -> {
|
||||
TRAY_CONNECTED to "Iris — connected"
|
||||
}
|
||||
|
||||
is GatewayClient.State.AuthFailed -> {
|
||||
TRAY_AUTH_FAILED to "Iris — auth failed"
|
||||
}
|
||||
|
||||
is GatewayClient.State.TlsConfirmRequired -> {
|
||||
TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation"
|
||||
}
|
||||
}
|
||||
trayColor = color
|
||||
trayTooltip = tooltip
|
||||
}
|
||||
@@ -126,15 +149,17 @@ fun main() {
|
||||
state = loadWindowState(),
|
||||
) {
|
||||
composeWindow = window
|
||||
window.addWindowFocusListener(object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = true
|
||||
}
|
||||
window.addWindowFocusListener(
|
||||
object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = true
|
||||
}
|
||||
|
||||
override fun windowLostFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = false
|
||||
}
|
||||
})
|
||||
override fun windowLostFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = false
|
||||
}
|
||||
},
|
||||
)
|
||||
IrisApp(store)
|
||||
}
|
||||
}
|
||||
@@ -187,4 +212,4 @@ private fun saveWindowState(window: ComposeWindow?) {
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
# Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is
|
||||
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
|
||||
# unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget).
|
||||
# Point this at your local JDK 21 if the path differs.
|
||||
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
||||
#
|
||||
# The JDK-21 home is machine-specific, so it is NOT hardcoded here (a
|
||||
# committed path would break every other checkout). Set it per machine via
|
||||
# one of:
|
||||
# - ~/.gradle/gradle.properties -> org.gradle.java.home=/path/to/jdk21
|
||||
# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ val kcefVersion = "2025.03.23"
|
||||
val markdownVersion = "0.44.0"
|
||||
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
|
||||
val sqldelightVersion = "2.3.2"
|
||||
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
|
||||
val cameraxVersion = "1.5.1"
|
||||
val mlKitVersion = "16.1.1"
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
@@ -111,6 +114,13 @@ kotlin {
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
// SAF picker (rememberLauncherForActivityResult).
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
|
||||
// uses the camera2 CameraX backend and ML Kit's barcode model.
|
||||
implementation("androidx.camera:camera-core:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-view:$cameraxVersion")
|
||||
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
|
||||
// M5: FCM push (inert without a Firebase project / google-services.json;
|
||||
// the ntfy listener is the fallback). The google-services plugin is
|
||||
// applied conditionally in the app module.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package iris.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -15,7 +16,9 @@ actual fun setActiveController(controller: Any?) {
|
||||
actual fun syncNtfyListener(backend: String) {
|
||||
val context = AndroidEnv.context
|
||||
val intent = Intent(context, NtfyListenerService::class.java)
|
||||
val store = AndroidSecureStore(context)
|
||||
// L-18: reuse one AndroidSecureStore instead of rebuilding it (and
|
||||
// re-running EncryptedSharedPreferences.create + migration) on every call.
|
||||
val store = ntfyStore(context)
|
||||
if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} else {
|
||||
@@ -25,6 +28,18 @@ actual fun syncNtfyListener(backend: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private val ntfyStoreLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cachedNtfyStore: AndroidSecureStore? = null
|
||||
|
||||
private fun ntfyStore(context: Context): AndroidSecureStore {
|
||||
cachedNtfyStore?.let { return it }
|
||||
return synchronized(ntfyStoreLock) {
|
||||
cachedNtfyStore ?: AndroidSecureStore(context).also { cachedNtfyStore = it }
|
||||
}
|
||||
}
|
||||
|
||||
actual fun postSystemNotification(
|
||||
chatId: String?,
|
||||
chatName: String?,
|
||||
@@ -33,7 +48,7 @@ actual fun postSystemNotification(
|
||||
threadId: String?,
|
||||
) {
|
||||
val context = AndroidEnv.context
|
||||
val id = chatId ?: "android:default"
|
||||
val id = chatId ?: "default"
|
||||
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -76,6 +76,14 @@ class AndroidSecureStore(
|
||||
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
|
||||
|
||||
override var deviceToken: String
|
||||
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
|
||||
|
||||
override var pinnedCertFingerprint: String
|
||||
get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply()
|
||||
|
||||
override val deviceId: String
|
||||
get() {
|
||||
var id = prefs.getString(KEY_DEVICE_ID, null)
|
||||
@@ -176,13 +184,28 @@ class AndroidSecureStore(
|
||||
) {
|
||||
serverUrl = url
|
||||
this.token = token
|
||||
// A (re-)pair may target a different gateway: the old per-device
|
||||
// token is dead there. The next hello.ack re-mints/returns it.
|
||||
deviceToken = ""
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
// M-10: clearing pairing must also wipe the device identity + push
|
||||
// state, otherwise a re-pair to a different gateway would keep the old
|
||||
// deviceId/syncCursor/ntfyTopic and the server would treat the new
|
||||
// pairing as the same device.
|
||||
prefs
|
||||
.edit()
|
||||
.remove(KEY_URL)
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_DEVICE_TOKEN)
|
||||
.remove(KEY_DEVICE_ID)
|
||||
.remove(KEY_SYNC_CURSOR)
|
||||
.remove(KEY_FCM_TOKEN)
|
||||
.remove(KEY_NTFY_TOPIC)
|
||||
.remove(KEY_NTFY_SERVER)
|
||||
.remove(KEY_PUSH_BACKEND)
|
||||
.remove(KEY_PINNED_CERT)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -191,12 +214,14 @@ class AndroidSecureStore(
|
||||
const val SECURE_PREFS_NAME = "iris_secure"
|
||||
const val KEY_URL = "server_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_DEVICE_TOKEN = "device_token"
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_SYNC_CURSOR = "sync_cursor"
|
||||
const val KEY_FCM_TOKEN = "fcm_token"
|
||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||
const val KEY_PUSH_BACKEND = "push_backend"
|
||||
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
|
||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
||||
|
||||
@@ -6,6 +6,7 @@ import iris.db.IrisDatabase
|
||||
|
||||
actual fun appDataDir(): String = AndroidEnv.context.filesDir.absolutePath
|
||||
|
||||
// The driver creates the schema in the open-helper callback (v1: no
|
||||
// migrations yet; add .sqm files + `Schema.migrate` in onUpgrade later).
|
||||
// The schema-aware driver creates the schema on first run and applies
|
||||
// pending .sqm migrations on existing DBs (e.g. v1 -> v2: the `tool`
|
||||
// table), stamping PRAGMA user_version along the way.
|
||||
actual fun createCacheDriver(): SqlDriver = AndroidSqliteDriver(IrisDatabase.Schema, AndroidEnv.context, "iris_cache.db")
|
||||
@@ -22,7 +22,10 @@ import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
||||
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
|
||||
|
||||
@Composable
|
||||
actual fun PlatformWebView(html: String, modifier: Modifier) {
|
||||
actual fun PlatformWebView(
|
||||
html: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
|
||||
state.webSettings.androidWebSettings.domStorageEnabled = true
|
||||
WebView(state, modifier = modifier)
|
||||
@@ -33,16 +36,18 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
|
||||
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
val currentOnBack = rememberUpdatedState(onBack)
|
||||
DisposableEffect(dispatcher) {
|
||||
if (dispatcher != null) {
|
||||
val callback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
currentOnBack.value()
|
||||
}
|
||||
val callback =
|
||||
dispatcher?.let { d ->
|
||||
val c =
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
currentOnBack.value()
|
||||
}
|
||||
}
|
||||
d.addCallback(c)
|
||||
c
|
||||
}
|
||||
dispatcher.addCallback(callback)
|
||||
onDispose { callback.remove() }
|
||||
}
|
||||
onDispose { }
|
||||
onDispose { callback?.remove() }
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,14 @@ object AppBridge {
|
||||
@Volatile
|
||||
var controller: IrisController? = null
|
||||
|
||||
/** True while the launcher activity is resumed (set by MainActivity). */
|
||||
/** True while the launcher activity is resumed (set by MainActivity).
|
||||
* A change is forwarded to the controller (M8: unread clear on focus). */
|
||||
@Volatile
|
||||
var foreground: Boolean = false
|
||||
}
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
controller?.setForeground(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,32 +11,41 @@ import iris.net.GatewayClient
|
||||
*
|
||||
* - [onNewToken]: persist the rotated token and push it to the server via
|
||||
* `fcm.register` (so the next push targets the current token).
|
||||
* - [onMessageReceived]: the data payload drives a silent sync. When the app
|
||||
* is foregrounded the WS path already delivered the frame (in-app banner),
|
||||
* so we only post a system notification when backgrounded.
|
||||
* - [onMessageReceived]: posts a system notification from the data payload.
|
||||
* When the app is foregrounded the SSE path already delivered the frame
|
||||
* (in-app banner), so we only post a notification when backgrounded.
|
||||
*
|
||||
* Inert without a Firebase project (no google-services.json): the service is
|
||||
* declared in the manifest but never receives messages, and the app falls
|
||||
* back to the ntfy listener.
|
||||
*/
|
||||
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
val store = AndroidSecureStore(applicationContext)
|
||||
store.fcmToken = token
|
||||
// Push the rotation to the server if we're connected.
|
||||
// Push the rotation to the server if we're connected (the ntfy topic
|
||||
// rides along so a wiped registry recovers both push tokens).
|
||||
AppBridge.controller?.client?.sendFrame(
|
||||
iris.protocol.fcmRegisterFrame(fcmToken = token),
|
||||
iris.protocol.fcmRegisterFrame(
|
||||
fcmToken = token,
|
||||
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
// Foreground + live WS: the in-app banner already showed this.
|
||||
// Foreground + live SSE: the in-app banner already showed this.
|
||||
if (AppBridge.foreground) return
|
||||
// Live WS: the frame arrives over the socket and the controller
|
||||
// Live SSE: the frame arrives over the stream and the controller
|
||||
// mirrors it to a system notification itself — posting here would
|
||||
// duplicate it (docs/08 §8.7).
|
||||
if (AppBridge.controller?.client?.state?.value is GatewayClient.State.Connected) return
|
||||
if (AppBridge.controller
|
||||
?.client
|
||||
?.state
|
||||
?.value is GatewayClient.State.Connected
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Backgrounded/killed: FCM already displayed the `notification`
|
||||
// payload on our behalf (the data payload only carries sync
|
||||
// metadata). Posting again would show a second notification with a
|
||||
@@ -44,7 +53,7 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||
// exception: the app must display them itself.
|
||||
if (message.notification != null) return
|
||||
val data = message.data
|
||||
val chatId = data["chat_id"] ?: "android:default"
|
||||
val chatId = data["chat_id"] ?: "default"
|
||||
val threadId = data["thread_id"]
|
||||
val title = data["title"] ?: "Iris"
|
||||
val body = data["body"] ?: data["title"].orEmpty()
|
||||
@@ -55,4 +64,4 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
IrisNotifications.post(applicationContext, chatId, null, title, body, threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
@@ -21,15 +20,22 @@ object IrisNotifications {
|
||||
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
|
||||
private const val NOTIF_ID_BASE = 1_000_000
|
||||
|
||||
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
// L-31: notification channel ids are capped at 64 chars (Android limit) and
|
||||
// are user-visible, so a long server-provided chatId must be truncated.
|
||||
private fun channelIdFor(chatId: String): String = (CHANNEL_PREFIX + chatId).take(64)
|
||||
|
||||
fun ensureChannel(
|
||||
context: Context,
|
||||
chatId: String,
|
||||
chatName: String? = null,
|
||||
) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val id = CHANNEL_PREFIX + chatId
|
||||
val id = channelIdFor(chatId)
|
||||
val name = chatName ?: chatId
|
||||
if (nm.getNotificationChannel(id) == null) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
.apply { description = "Iris messages for $name" }
|
||||
.apply { description = "Iris messages for $name" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -43,23 +49,28 @@ object IrisNotifications {
|
||||
threadId: String?,
|
||||
) {
|
||||
ensureChannel(context, chatId, chatName)
|
||||
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
|
||||
val intent = Intent(ACTION_OPEN_CHAT).apply {
|
||||
setPackage(context.packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra("chat_id", chatId)
|
||||
if (threadId != null) putExtra("thread_id", threadId)
|
||||
}
|
||||
val pi = PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
// L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
|
||||
// to the same notification id and clobber each other.
|
||||
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff)
|
||||
val intent =
|
||||
Intent(ACTION_OPEN_CHAT).apply {
|
||||
setPackage(context.packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra("chat_id", chatId)
|
||||
if (threadId != null) putExtra("thread_id", threadId)
|
||||
}
|
||||
val pi =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(
|
||||
id,
|
||||
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
|
||||
NotificationCompat
|
||||
.Builder(context, channelIdFor(chatId))
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
@@ -69,4 +80,4 @@ object IrisNotifications {
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,13 @@ import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import iris.protocol.IrisJson
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -28,18 +30,31 @@ import java.util.concurrent.TimeUnit
|
||||
*
|
||||
* A foreground service that subscribes to this device's ntfy topic and posts
|
||||
* a system notification for each push. The structured payload rides in the
|
||||
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
|
||||
* is the short preview. When the app is foregrounded the WS path already
|
||||
* delivered the frame, so the service skips posting to avoid a duplicate.
|
||||
* `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
|
||||
* body is the short preview. When the app is foregrounded the SSE path
|
||||
* already delivered the frame, so the service skips posting to avoid a
|
||||
* duplicate.
|
||||
*
|
||||
* The stream is reconnected with capped exponential backoff when it drops
|
||||
* (EOF, network error, or a non-2xx response) — `START_STICKY` alone only
|
||||
* restarts the service after process death, not after a failed read.
|
||||
*/
|
||||
class NtfyListenerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var streamJob: Job? = null
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
|
||||
.build()
|
||||
private val client =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
// ntfy sends keep-alive comments every ~10 s; a 60 s read timeout
|
||||
// detects a half-open connection instead of hanging forever.
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
startForeground(NOTIF_ID, foregroundNotification())
|
||||
streamJob?.cancel()
|
||||
streamJob = scope.launch { stream() }
|
||||
@@ -60,47 +75,79 @@ class NtfyListenerService : Service() {
|
||||
if (topic.isBlank()) return
|
||||
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
|
||||
val url = "$server/$topic"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return
|
||||
val body = resp.body ?: return
|
||||
val source = body.source()
|
||||
var data: String? = null
|
||||
var title: String? = null
|
||||
var msgBody: String? = null
|
||||
while (!source.exhausted()) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
when {
|
||||
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
|
||||
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
|
||||
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
|
||||
line.isEmpty() -> {
|
||||
// Event boundary: process the accumulated message.
|
||||
data?.let { handleData(it, title, msgBody) }
|
||||
data = null
|
||||
title = null
|
||||
msgBody = null
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
var backoff = 1_000L
|
||||
while (true) {
|
||||
try {
|
||||
client.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
IrisLog.w("ntfy stream HTTP ${resp.code}")
|
||||
} else {
|
||||
val body = resp.body ?: return
|
||||
readEvents(body.source())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.w("ntfy stream dropped: ${e.message}")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Stream dropped; the service is START_STICKY so the system
|
||||
// restarts it. If it keeps failing, the WS path still works.
|
||||
// Stream ended (EOF, error, or non-2xx): back off and reconnect.
|
||||
delay(backoff)
|
||||
backoff = (backoff * 2).coerceAtMost(30_000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
|
||||
val data = try {
|
||||
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
/**
|
||||
* Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF;
|
||||
* do NOT use `source.exhausted()` here — it reads until EOF and would
|
||||
* block forever on a live stream.
|
||||
*/
|
||||
private fun readEvents(source: okio.BufferedSource) {
|
||||
var data: String? = null
|
||||
var title: String? = null
|
||||
var msgBody: String? = null
|
||||
while (true) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
when {
|
||||
line.startsWith("X-Data:") -> {
|
||||
data = line.removePrefix("X-Data:").trim()
|
||||
}
|
||||
|
||||
line.startsWith("X-Title:") -> {
|
||||
title = line.removePrefix("X-Title:").trim()
|
||||
}
|
||||
|
||||
line.startsWith("data:") -> {
|
||||
msgBody = line.removePrefix("data:").trim()
|
||||
}
|
||||
|
||||
line.isEmpty() -> {
|
||||
// Event boundary: process the accumulated message.
|
||||
data?.let { handleData(it, title, msgBody) }
|
||||
data = null
|
||||
title = null
|
||||
msgBody = null
|
||||
}
|
||||
}
|
||||
}
|
||||
val chatId = data?.str("chat_id") ?: "android:default"
|
||||
}
|
||||
|
||||
private fun handleData(
|
||||
dataJson: String,
|
||||
title: String?,
|
||||
msgBody: String?,
|
||||
) {
|
||||
val data =
|
||||
try {
|
||||
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val chatId = data?.str("chat_id") ?: "default"
|
||||
val threadId = data?.str("thread_id")
|
||||
// The short preview rides in the SSE `data:` field; fall back to the
|
||||
// X-Title, then a generic label.
|
||||
@@ -126,11 +173,12 @@ class NtfyListenerService : Service() {
|
||||
LISTENER_CHANNEL,
|
||||
"Iris push listener",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
|
||||
return NotificationCompat
|
||||
.Builder(context, LISTENER_CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Iris")
|
||||
.setContentText("Listening for messages")
|
||||
@@ -146,5 +194,4 @@ class NtfyListenerService : Service() {
|
||||
}
|
||||
|
||||
/** Read a string field from a JSON object (null when absent / not a string). */
|
||||
private fun JsonObject?.str(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.content
|
||||
private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
|
||||
@@ -0,0 +1,49 @@
|
||||
package iris.platform
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
|
||||
* scanned text (or null on cancel) to [onResult].
|
||||
*/
|
||||
@Composable
|
||||
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val text =
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onResult(text)
|
||||
}
|
||||
Button(onClick = {
|
||||
val activity = context.resolveActivity()
|
||||
if (activity != null) {
|
||||
launcher.launch(Intent(activity, QrScanActivity::class.java))
|
||||
}
|
||||
}) {
|
||||
Text("Scan QR")
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
|
||||
private fun Context.resolveActivity(): Activity? =
|
||||
when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.resolveActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package iris.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
|
||||
/**
|
||||
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
|
||||
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
|
||||
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
|
||||
*
|
||||
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
|
||||
* stopped in [onDestroy].
|
||||
*/
|
||||
class QrScanActivity : ComponentActivity() {
|
||||
companion object {
|
||||
/** Intent extra carrying the scanned QR text. */
|
||||
const val EXTRA_QR = "qr"
|
||||
}
|
||||
|
||||
private lateinit var previewView: PreviewView
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var settled = false
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) startCamera() else finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
previewView =
|
||||
PreviewView(this).apply {
|
||||
layoutParams =
|
||||
ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
setContentView(previewView)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (!settled) setResult(Activity.RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCamera() {
|
||||
val future = ProcessCameraProvider.getInstance(this)
|
||||
future.addListener(
|
||||
{
|
||||
val provider = future.get()
|
||||
cameraProvider = provider
|
||||
val preview =
|
||||
Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val analyzer =
|
||||
ImageAnalysis
|
||||
.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
try {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
this,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analyzer,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// No usable camera (e.g. headless emulator): bail out.
|
||||
finish()
|
||||
return@addListener
|
||||
}
|
||||
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
|
||||
analyzeImage(proxy)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(this),
|
||||
)
|
||||
}
|
||||
|
||||
private fun analyzeImage(proxy: ImageProxy) {
|
||||
val mediaImage = proxy.image
|
||||
if (mediaImage == null) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
|
||||
BarcodeScanning
|
||||
.getClient()
|
||||
.process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
|
||||
if (text != null) finishWithResult(text)
|
||||
}.addOnCompleteListener { proxy.close() }
|
||||
}
|
||||
|
||||
private fun finishWithResult(text: String) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cameraProvider?.unbindAll()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -26,19 +27,21 @@ import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.IrisTheme
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.ui.theme.rememberBackgroundImage
|
||||
import iris.util.PairLink
|
||||
|
||||
/**
|
||||
* Root composable shared by the Android and Desktop shells.
|
||||
*
|
||||
* M1: routes between the Connect screen (unpaired / auth failed) and the
|
||||
* Chat screen (paired). Later milestones add the channel list, search,
|
||||
* settings, and media (docs/10-android-app.md).
|
||||
* Routes between the Connect screen (unpaired / auth failed) and the main
|
||||
* app (paired), which hosts the channel list, chat, search, settings, and
|
||||
* media (docs/10-android-app.md).
|
||||
*/
|
||||
@Composable
|
||||
fun IrisApp(
|
||||
store: SecureStore,
|
||||
deepLinkChatId: String? = null,
|
||||
deepLinkThreadId: String? = null,
|
||||
deepLinkPair: PairLink? = null,
|
||||
) {
|
||||
val controller = remember(store) { IrisController(store) }
|
||||
DisposableEffect(controller) {
|
||||
@@ -66,10 +69,11 @@ fun IrisApp(
|
||||
// untouched, so the UI keeps its proportions at any size.
|
||||
CompositionLocalProvider(
|
||||
LocalUserTheme provides theme,
|
||||
LocalDensity provides Density(
|
||||
density = baseDensity.density,
|
||||
fontScale = baseDensity.fontScale * fontScale,
|
||||
),
|
||||
LocalDensity provides
|
||||
Density(
|
||||
density = baseDensity.density,
|
||||
fontScale = baseDensity.fontScale * fontScale,
|
||||
),
|
||||
) {
|
||||
// The color goes through Surface's `color` parameter: a
|
||||
// Modifier.background on the Surface would be painted *under* the
|
||||
@@ -95,20 +99,49 @@ fun IrisApp(
|
||||
)
|
||||
}
|
||||
val s = state
|
||||
// QR pairing (docs/20): an iris://pair deep link prefills
|
||||
// the Connect screen, taking priority over stored creds.
|
||||
val pairUrl = deepLinkPair?.url ?: store.serverUrl
|
||||
val pairToken = deepLinkPair?.token ?: store.token
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
GatewayClient.State.Disconnected -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
|
||||
}
|
||||
}
|
||||
|
||||
is GatewayClient.State.AuthFailed -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = pairUrl,
|
||||
prefillToken = pairToken,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TLS: the gateway's certificate is untrusted and not
|
||||
// the pinned one (docs/09 §9.4) — ask the user to
|
||||
// confirm its fingerprint (SSH host-key style).
|
||||
is GatewayClient.State.TlsConfirmRequired -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = pairUrl,
|
||||
prefillToken = pairToken,
|
||||
initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.",
|
||||
tlsFingerprint = s.fingerprint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// status bubble + connection banner show the link state
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
else -> {
|
||||
ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
// M9: full-screen HTML artifact preview (opened from an
|
||||
// artifact card in the chat); covers everything while open.
|
||||
@@ -117,4 +150,4 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,54 @@ class ChannelStore {
|
||||
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
|
||||
}
|
||||
|
||||
// ── Optimistic local updates (M-7) ────────────────────────────────────
|
||||
//
|
||||
// The gateway only broadcasts channel.created/renamed/deleted — there are
|
||||
// no favorite/icon/automation/default events. So a toggle sent by THIS
|
||||
// device would not update its own UI until a full channel.list re-fetch.
|
||||
// These apply the change locally (optimistically); a later channel.list /
|
||||
// hello.ack re-seed reconciles any divergence (e.g. a server rejection).
|
||||
|
||||
/** Toggle the cosmetic favorite flag locally. */
|
||||
fun setFavorite(
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
) = update(chatId) { it.copy(favorite = on) }
|
||||
|
||||
/** Toggle the automation flag locally. */
|
||||
fun setAutomation(
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
) = update(chatId) { it.copy(automation = on) }
|
||||
|
||||
/** Set the icon (base64) and/or avatar color locally; null clears a field. */
|
||||
fun setIcon(
|
||||
chatId: String,
|
||||
icon: String?,
|
||||
color: String?,
|
||||
) = update(chatId) { it.copy(icon = icon, color = color) }
|
||||
|
||||
/** Make [chatId] the default channel locally (clearing the previous one). */
|
||||
fun setDefault(chatId: String) {
|
||||
_channels.value =
|
||||
sorted(
|
||||
_channels.value.map {
|
||||
when {
|
||||
it.chatId == chatId -> it.copy(isDefault = true)
|
||||
it.isDefault -> it.copy(isDefault = false)
|
||||
else -> it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun update(
|
||||
chatId: String,
|
||||
transform: (ChannelInfo) -> ChannelInfo,
|
||||
) {
|
||||
_channels.value = sorted(_channels.value.map { if (it.chatId == chatId) transform(it) else it })
|
||||
}
|
||||
|
||||
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
||||
list.sortedWith(
|
||||
compareByDescending<ChannelInfo> { it.isDefault }
|
||||
|
||||
@@ -16,9 +16,9 @@ import iris.protocol.IrisJson
|
||||
* the controller) so every reconciled frame survives a process death.
|
||||
* - [metaGet] / [metaPut] hold small UI state (last-viewed lane).
|
||||
*
|
||||
* Only [MessageItem]s are persisted — tool cards, live streaming state and
|
||||
* [MessageItem]s and [ToolItem]s are persisted; live streaming state and
|
||||
* local system notices are ephemeral. Rows are JSON payloads keyed by
|
||||
* (lane, id), so the schema does not drift with [MessageItem] fields.
|
||||
* (lane, id), so the schema does not drift with the model fields.
|
||||
*
|
||||
* All access is synchronized: the debounced save collectors run on the
|
||||
* controller scope while [dispose] may flush from the UI thread.
|
||||
@@ -32,27 +32,76 @@ class ChatDb(
|
||||
|
||||
// ── messages ──────────────────────────────────────────────────────────
|
||||
|
||||
/** All persisted lanes (lane key -> messages ordered by ts). */
|
||||
fun loadLanes(): Map<String, List<MessageItem>> =
|
||||
/** All persisted lanes (lane key -> items: messages ordered by ts with
|
||||
* tool cards interleaved at their anchored position). */
|
||||
fun loadLanes(): Map<String, List<ChatItem>> =
|
||||
synchronized(lock) {
|
||||
val lanes = linkedMapOf<String, MutableList<MessageItem>>()
|
||||
val messages = linkedMapOf<String, MutableList<ChatItem>>()
|
||||
for (row in db.cacheQueries.allMessages().executeAsList()) {
|
||||
val item = decodeMessage(row.payload) ?: continue
|
||||
lanes.getOrPut(row.lane) { mutableListOf() }.add(item)
|
||||
messages.getOrPut(row.lane) { mutableListOf() }.add(item)
|
||||
}
|
||||
val tools = linkedMapOf<String, MutableList<ToolItem>>()
|
||||
for (row in db.cacheQueries.allTools().executeAsList()) {
|
||||
val item = decodeTool(row.payload) ?: continue
|
||||
tools.getOrPut(row.lane) { mutableListOf() }.add(item)
|
||||
}
|
||||
(messages.keys + tools.keys).distinct().associateWith { lane ->
|
||||
val items: MutableList<ChatItem> = messages[lane].orEmpty().toMutableList()
|
||||
// Insert each tool card after its anchor message (the message
|
||||
// it followed live). Cards sharing an anchor keep their seq
|
||||
// order; a card whose anchor is gone (deleted message) falls
|
||||
// to the end of the lane. The lastPos cache assumes anchors
|
||||
// are monotonically non-decreasing per lane (true for the
|
||||
// onToolStart anchor rule: last non-streaming message) — a
|
||||
// tool anchored to an EARLIER message processed after a
|
||||
// later-anchored one would be misplaced.
|
||||
val lastPos = mutableMapOf<String, Int>()
|
||||
for (tool in tools[lane].orEmpty()) {
|
||||
val anchor = tool.anchorId
|
||||
val pos =
|
||||
if (anchor == null) {
|
||||
-1
|
||||
} else {
|
||||
lastPos.getOrPut(anchor) { items.indexOfFirst { it.id == anchor } }
|
||||
}
|
||||
if (pos >= 0) {
|
||||
items.add(pos + 1, tool)
|
||||
if (anchor != null) lastPos[anchor] = pos + 1
|
||||
} else {
|
||||
items.add(tool)
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
lanes.mapValues { it.value.toList() }
|
||||
}
|
||||
|
||||
/** Replace the whole message cache with [lanes] (atomic snapshot). Tool
|
||||
* cards and local system notices are skipped (ephemeral). */
|
||||
/** Replace the whole message + tool cache with [lanes] (atomic snapshot).
|
||||
* Local system notices are skipped (ephemeral). */
|
||||
fun saveLanes(lanes: Map<String, List<ChatItem>>) {
|
||||
synchronized(lock) {
|
||||
db.transaction {
|
||||
db.cacheQueries.clearMessages()
|
||||
db.cacheQueries.clearTools()
|
||||
for ((lane, items) in lanes) {
|
||||
var toolSeq = 0
|
||||
for (item in items) {
|
||||
if (item is MessageItem && !item.isSystem) {
|
||||
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
|
||||
when (item) {
|
||||
is MessageItem -> {
|
||||
if (!item.isSystem) {
|
||||
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
|
||||
}
|
||||
}
|
||||
|
||||
is ToolItem -> {
|
||||
db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item))
|
||||
}
|
||||
|
||||
// Picker cards ride in the message table (JSON
|
||||
// payload; decodeMessage picks the type back out).
|
||||
is PickerItem -> {
|
||||
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,15 +158,29 @@ class ChatDb(
|
||||
synchronized(lock) {
|
||||
db.transaction {
|
||||
db.cacheQueries.clearMessages()
|
||||
db.cacheQueries.clearTools()
|
||||
db.cacheQueries.clearChannels()
|
||||
db.cacheQueries.clearMeta()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeMessage(payload: String): MessageItem? =
|
||||
private fun decodeMessage(payload: String): ChatItem? =
|
||||
try {
|
||||
json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
|
||||
} catch (_: Exception) {
|
||||
// Not a message payload — a picker card (MessageItem requires
|
||||
// "role", PickerItem requires "title": the two never cross-decode).
|
||||
try {
|
||||
json.decodeFromString<PickerItem>(payload)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeTool(payload: String): ToolItem? =
|
||||
try {
|
||||
json.decodeFromString<ToolItem>(payload).sanitizeForRestore()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -132,4 +195,9 @@ class ChatDb(
|
||||
pending = false,
|
||||
status = if (status == MsgStatus.Pending) MsgStatus.Failed else status,
|
||||
)
|
||||
|
||||
/** A restored tool card is never mid-flight: an open card (the process
|
||||
* died before tool.end) is closed as interrupted, mirroring
|
||||
* [ChatStore.finalizeInterrupted]. */
|
||||
private fun ToolItem.sanitizeForRestore(): ToolItem = if (done) this else copy(done = true, ok = false)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import iris.protocol.MessagePayload
|
||||
import iris.protocol.MessageStartPayload
|
||||
import iris.protocol.MessageStopPayload
|
||||
import iris.protocol.MessageUpdatePayload
|
||||
import iris.protocol.PickerChoice
|
||||
import iris.protocol.PickerChoicePayload
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.ROLE_USER
|
||||
import iris.protocol.RuntimeMeta
|
||||
@@ -17,9 +19,13 @@ import iris.protocol.TYPE_MESSAGE
|
||||
import iris.protocol.TYPE_MESSAGE_START
|
||||
import iris.protocol.TYPE_MESSAGE_STOP
|
||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||
import iris.protocol.TYPE_PICKER_CHOICE
|
||||
import iris.protocol.TYPE_TODO_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_END
|
||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.TodoItem
|
||||
import iris.protocol.TodoUpdatePayload
|
||||
import iris.protocol.ToolEndPayload
|
||||
import iris.protocol.ToolProgressPayload
|
||||
import iris.protocol.ToolStartPayload
|
||||
@@ -90,18 +96,42 @@ data class MediaItem(
|
||||
val localPath: String? = null,
|
||||
)
|
||||
|
||||
/** A structured tool-activity card (spinner until [done]). */
|
||||
/** A structured tool-activity card (spinner until [done]).
|
||||
* [anchorId] is the id of the message this card follows in the lane (the
|
||||
* last non-streaming message when the tool started) — persisted with the
|
||||
* card so a restart restores it in its correct position (user message →
|
||||
* tool card → answer) instead of dropping it or appending it at the end.
|
||||
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
|
||||
@Serializable
|
||||
data class ToolItem(
|
||||
override val id: String,
|
||||
val index: Int,
|
||||
val name: String,
|
||||
val preview: String? = null,
|
||||
val args: JsonElement? = null,
|
||||
/** Cosmetic per-tool glyph from the gateway (hermes get_tool_emoji);
|
||||
* null for unknown tools / older frames — the UI falls back to 🔧. */
|
||||
val emoji: String? = null,
|
||||
val note: String? = null,
|
||||
val done: Boolean = false,
|
||||
val ok: Boolean = true,
|
||||
val duration: Double? = null,
|
||||
val outputPreview: String? = null,
|
||||
val anchorId: String? = null,
|
||||
) : ChatItem
|
||||
|
||||
/** An interactive choice picker card (one tap → one value), sent by
|
||||
* finite-choice slash commands (/reasoning, /fast, …) via `picker.choice`.
|
||||
* [selected] is the value the user tapped (null = still pending); the
|
||||
* server's reply arrives as a normal message afterwards.
|
||||
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
|
||||
@Serializable
|
||||
data class PickerItem(
|
||||
override val id: String, // picker_id (unique; the picker.select key)
|
||||
val title: String,
|
||||
val choices: List<PickerChoice> = emptyList(),
|
||||
val ts: Long = 0,
|
||||
val selected: String? = null,
|
||||
) : ChatItem
|
||||
|
||||
class ChatStore {
|
||||
@@ -113,8 +143,25 @@ class ChatStore {
|
||||
private val _currentLane = MutableStateFlow(DEFAULT_LANE)
|
||||
val currentLane: StateFlow<String> = _currentLane.asStateFlow()
|
||||
|
||||
private var localSeq = 0
|
||||
private var toolSeq = 0
|
||||
/** The agent's live todo list per lane (todo.update; last-write-wins).
|
||||
* Ephemeral: not persisted — the gateway re-sends a snapshot when the
|
||||
* app reconnects, and the next `todo` tool call refreshes it. */
|
||||
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
|
||||
val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow()
|
||||
|
||||
/** Unread message count per lane (M8: unread indicator). Ephemeral
|
||||
* (in-memory): a process death resets it, and the `sync` delta re-counts
|
||||
* genuinely new messages on reconnect. A lane absent from the map has
|
||||
* no unread messages. */
|
||||
private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
|
||||
|
||||
/** Single lock for the lane/todo/unread maps: they are mutated from the
|
||||
* UI thread (addPending via send) and the frame-collector thread
|
||||
* (Dispatchers.Default). A non-atomic read-modify-write loses a frame
|
||||
* that lands between the read and the write (e.g. a streaming delta
|
||||
* dropped while the user sends). */
|
||||
private val lock = Any()
|
||||
|
||||
/** When false, `message.start`/`message.update` frames are ignored and each
|
||||
* reply materializes as a single final message on `message.stop`
|
||||
@@ -123,15 +170,16 @@ class ChatStore {
|
||||
var streamingEnabled: Boolean = true
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_LANE = "android:default"
|
||||
const val DEFAULT_LANE = "default"
|
||||
|
||||
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
}
|
||||
|
||||
// ── Lane helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
|
||||
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
|
||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
|
||||
* thread lane can never collide with a chat id (chat ids are direct,
|
||||
* e.g. `chan_1`, and never contain `:`). */
|
||||
fun laneKey(
|
||||
chatId: String,
|
||||
threadId: String?,
|
||||
@@ -156,9 +204,26 @@ class ChatStore {
|
||||
lane: String,
|
||||
transform: (List<ChatItem>) -> List<ChatItem>,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
map[lane] = transform(map[lane].orEmpty())
|
||||
_lanes.value = map
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply [transform] to every lane, writing back only when something
|
||||
* changed. Callers must hold [lock]. */
|
||||
private fun mapLanes(transform: (List<ChatItem>) -> List<ChatItem>) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
map[lane] = transform(map[lane].orEmpty())
|
||||
_lanes.value = map
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = transform(list)
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
// ── Optimistic send ───────────────────────────────────────────────────
|
||||
@@ -169,8 +234,11 @@ class ChatStore {
|
||||
lane: String,
|
||||
media: List<MediaItem> = emptyList(),
|
||||
): String {
|
||||
localSeq++
|
||||
val id = "local_$localSeq"
|
||||
// Process-unique id: the in-memory seq resets on every ChatStore
|
||||
// creation, and failed sends are persisted — a restart would
|
||||
// otherwise re-mint local_1 and collide with the restored bubble
|
||||
// (duplicate list key).
|
||||
val id = randomId("local")
|
||||
updateLane(lane) {
|
||||
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
|
||||
}
|
||||
@@ -184,8 +252,10 @@ class ChatStore {
|
||||
lane: String,
|
||||
text: String,
|
||||
) {
|
||||
localSeq++
|
||||
val id = "sys_$localSeq"
|
||||
// randomId (not a process-local seq): system messages are persisted,
|
||||
// and a seq that resets on restart would re-mint sys_0 and collide
|
||||
// with the restored one (upsert overwrite).
|
||||
val id = randomId("sys")
|
||||
updateLane(lane) {
|
||||
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
|
||||
}
|
||||
@@ -204,8 +274,10 @@ class ChatStore {
|
||||
TYPE_TOOL_START -> onToolStart(lane, frame)
|
||||
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
|
||||
TYPE_TOOL_END -> onToolEnd(lane, frame)
|
||||
TYPE_TODO_UPDATE -> onTodoUpdate(lane, frame)
|
||||
TYPE_COMMENTARY -> onCommentary(lane, frame)
|
||||
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
|
||||
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -240,10 +312,14 @@ class ChatStore {
|
||||
)
|
||||
list.toMutableList().also { it[byId] = updated }
|
||||
} else if (p.role == ROLE_USER) {
|
||||
// Replace the matching optimistic pending bubble (server echo).
|
||||
// Replace the matching optimistic bubble (server echo). Also
|
||||
// matches a FAILED bubble: the send may have arrived after
|
||||
// its POST response was lost in a network drop — the echo is
|
||||
// the proof of delivery, so reconcile instead of duplicating.
|
||||
val pendingIdx =
|
||||
list.indexOfLast {
|
||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
||||
(it.pending || it.status == MsgStatus.Failed)
|
||||
}
|
||||
if (pendingIdx >= 0) {
|
||||
list.toMutableList().also {
|
||||
@@ -302,15 +378,18 @@ class ChatStore {
|
||||
val (chatId, threadId) = parseLane(lane)
|
||||
if (threadId == null) return
|
||||
val flatLane = chatId
|
||||
val map = _lanes.value.toMutableMap()
|
||||
val flatList = map[flatLane].orEmpty()
|
||||
val idx =
|
||||
flatList.indexOfLast {
|
||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||
}
|
||||
if (idx < 0) return
|
||||
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
||||
_lanes.value = map
|
||||
synchronized(lock) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
val flatList = map[flatLane].orEmpty()
|
||||
val idx =
|
||||
flatList.indexOfLast {
|
||||
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
||||
(it.pending || it.status == MsgStatus.Failed)
|
||||
}
|
||||
if (idx < 0) return@synchronized
|
||||
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
||||
_lanes.value = map
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge server media refs into existing items, keeping local paths. */
|
||||
@@ -418,9 +497,17 @@ class ChatStore {
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<ToolStartPayload>() ?: return
|
||||
toolSeq++
|
||||
val id = "tool_$toolSeq"
|
||||
// Process-unique id: the in-memory seq resets on every ChatStore
|
||||
// creation, and cards are persisted — a restart would otherwise
|
||||
// re-mint tool_1 and collide with the restored card (duplicate list
|
||||
// key, upsert overwrite). onToolProgress/onToolEnd match by index,
|
||||
// so non-sequential ids are safe.
|
||||
val id = randomId("tool")
|
||||
updateLane(lane) { list ->
|
||||
// Anchor the card to the message it follows: the last non-streaming
|
||||
// message (a live streaming bubble is the answer that arrives AFTER
|
||||
// the tool, so it is skipped). Restored with the card on restart.
|
||||
val anchorId = list.lastOrNull { it is MessageItem && !it.streaming }?.id
|
||||
list +
|
||||
ToolItem(
|
||||
id = id,
|
||||
@@ -428,6 +515,8 @@ class ChatStore {
|
||||
name = p.name,
|
||||
preview = p.preview,
|
||||
args = p.args,
|
||||
emoji = p.emoji,
|
||||
anchorId = anchorId,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -470,6 +559,20 @@ class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
// ── todo.update (the agent's live todo list, last-write-wins) ─────────
|
||||
|
||||
private fun onTodoUpdate(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<TodoUpdatePayload>() ?: return
|
||||
synchronized(lock) {
|
||||
val map = _todos.value.toMutableMap()
|
||||
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
|
||||
_todos.value = map
|
||||
}
|
||||
}
|
||||
|
||||
// ── commentary (dimmed interim beat) ──────────────────────────────────
|
||||
|
||||
private fun onCommentary(
|
||||
@@ -477,13 +580,12 @@ class ChatStore {
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<CommentaryPayload>() ?: return
|
||||
// Gateway lifecycle notices (restart / shutdown / online) are rendered
|
||||
// as a centered system notice by the controller, on the down/up state
|
||||
// transition. The server also emits the "restarting" notice as a
|
||||
// commentary frame (and the sync catch-up can replay it *after* the
|
||||
// local "online" notice), so drop it here: the app is the source of
|
||||
// truth for these notices, which keeps the order (restarting → online)
|
||||
// and prevents duplicates.
|
||||
// Gateway lifecycle notices (restart / shutdown / online) are the
|
||||
// controller's business: it renders the "restarting" / "online" pair
|
||||
// as centered system notices on the down/up state transitions (gated
|
||||
// on the gateway's status{restarting} frame). The server also emits
|
||||
// these as commentary frames (and the sync catch-up can replay them
|
||||
// out of order), so drop them here to prevent duplicates.
|
||||
if (isGatewayLifecycleNotice(p.text)) return
|
||||
updateLane(lane) { list ->
|
||||
if (list.any { it.id == p.messageId }) {
|
||||
@@ -549,10 +651,8 @@ class ChatStore {
|
||||
mediaId: String,
|
||||
localPath: String,
|
||||
) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated =
|
||||
synchronized(lock) {
|
||||
mapLanes { list ->
|
||||
list.map { item ->
|
||||
if (item is MessageItem) {
|
||||
item.copy(
|
||||
@@ -565,20 +665,84 @@ class ChatStore {
|
||||
item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
// ── picker.choice (interactive slash-command menu) ──────────────────────
|
||||
|
||||
/**
|
||||
* Append a choice-picker card to [lane]. Idempotent by picker id: the
|
||||
* frame is outboxed, so a sync replay of an already-rendered picker is a
|
||||
* no-op (a locally resolved card is never re-opened by a replay).
|
||||
*/
|
||||
private fun onPickerChoice(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<PickerChoicePayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
if (list.any { it.id == p.pickerId }) {
|
||||
list
|
||||
} else {
|
||||
list +
|
||||
PickerItem(
|
||||
id = p.pickerId,
|
||||
title = p.title,
|
||||
choices = p.choices,
|
||||
ts = nowMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the picker [pickerId] as answered with [value] (all lanes; the
|
||||
* picker id is unique). Optimistic: the server's reply message follows
|
||||
* as a normal message; an expired picker (gateway restart) simply never
|
||||
* replies. */
|
||||
fun resolvePicker(
|
||||
pickerId: String,
|
||||
value: String,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
mapLanes { list ->
|
||||
list.map { item ->
|
||||
if (item is PickerItem && item.id == pickerId && item.selected == null) {
|
||||
item.copy(selected = value)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** M8: a new message arrived in [lane] that the user hasn't seen —
|
||||
* increment its unread count. */
|
||||
fun markUnread(lane: String) {
|
||||
synchronized(lock) {
|
||||
val map = _unread.value.toMutableMap()
|
||||
map[lane] = (map[lane] ?: 0) + 1
|
||||
_unread.value = map
|
||||
}
|
||||
}
|
||||
|
||||
/** M8: the user is now viewing [lane]'s newest content — clear its unread
|
||||
* count. Idempotent (a lane with no unread is a no-op). */
|
||||
fun markLaneRead(lane: String) {
|
||||
synchronized(lock) {
|
||||
val map = _unread.value.toMutableMap()
|
||||
if (map.remove(lane) != null) _unread.value = map
|
||||
}
|
||||
}
|
||||
|
||||
/** M8: unread count for a single lane (0 when none). */
|
||||
fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0
|
||||
|
||||
/** M5: mark the user message [messageId] as read (read.receipt). */
|
||||
fun markRead(messageId: String) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated =
|
||||
synchronized(lock) {
|
||||
mapLanes { list ->
|
||||
list.map { item ->
|
||||
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||
item.status != MsgStatus.Read
|
||||
@@ -588,12 +752,27 @@ class ChatStore {
|
||||
item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/** M7: mark a single user message as failed (the send never reached the
|
||||
* gateway — network drop, or the gateway rejected it); tap the bubble
|
||||
* to retry. */
|
||||
fun failMessage(messageId: String) {
|
||||
synchronized(lock) {
|
||||
mapLanes { list ->
|
||||
list.map { item ->
|
||||
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||
item.status != MsgStatus.Failed
|
||||
) {
|
||||
item.copy(pending = false, status = MsgStatus.Failed)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,16 +783,9 @@ class ChatStore {
|
||||
*/
|
||||
fun removeMessages(messageIds: Set<String>) {
|
||||
if (messageIds.isEmpty()) return
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated = list.filterNot { it.id in messageIds }
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
synchronized(lock) {
|
||||
mapLanes { list -> list.filterNot { it.id in messageIds } }
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -624,30 +796,23 @@ class ChatStore {
|
||||
* message.stop) will never arrive to close them.
|
||||
*/
|
||||
fun finalizeInterrupted() {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated =
|
||||
synchronized(lock) {
|
||||
mapLanes { list ->
|
||||
list.map { item ->
|
||||
when (item) {
|
||||
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
|
||||
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
|
||||
is PickerItem -> item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/** M7: mark all pending user messages as failed (gateway error frame). */
|
||||
fun failPending() {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((lane, list) in map) {
|
||||
val updated =
|
||||
synchronized(lock) {
|
||||
mapLanes { list ->
|
||||
list.map { item ->
|
||||
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
|
||||
item.copy(pending = false, status = MsgStatus.Failed)
|
||||
@@ -655,12 +820,8 @@ class ChatStore {
|
||||
item
|
||||
}
|
||||
}
|
||||
if (updated != list) {
|
||||
map[lane] = updated
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) _lanes.value = map
|
||||
}
|
||||
|
||||
/** M7: re-arm a failed user message for a retry send. */
|
||||
@@ -683,9 +844,11 @@ class ChatStore {
|
||||
* Load a history page into [lane] (oldest → newest). The history is the
|
||||
* authoritative full list of final messages for the lane; it replaces the
|
||||
* lane's final messages and preserves non-final items (tool cards, live
|
||||
* streaming bubbles, commentary) that are not part of the history. Used to
|
||||
* restore the view on first open of a chat / after a process death, where
|
||||
* the in-memory store is empty and the `sync` delta does not cover older
|
||||
* streaming bubbles, commentary) that are not part of the history. Tool
|
||||
* cards sort at their anchor message's position, so a history refresh
|
||||
* keeps them between the user message and the answer. Used to restore the
|
||||
* view on first open of a chat / after a process death, where the
|
||||
* in-memory store is empty and the `sync` delta does not cover older
|
||||
* messages.
|
||||
*/
|
||||
fun loadHistory(
|
||||
@@ -694,12 +857,49 @@ class ChatStore {
|
||||
) {
|
||||
updateLane(lane) { list ->
|
||||
val historyIds = messages.map { it.id }.toSet()
|
||||
// A local FAILED bubble whose text+media matches a history user
|
||||
// message was actually delivered (the POST response was lost in
|
||||
// the network drop) — the history copy is authoritative, so drop
|
||||
// the local duplicate instead of showing the message twice.
|
||||
val historyUser = messages.filter { it.role == ROLE_USER }
|
||||
val preserved =
|
||||
list.filter { item ->
|
||||
item !is MessageItem || item.id !in historyIds
|
||||
if (item !is MessageItem) return@filter true
|
||||
if (item.id in historyIds) return@filter false
|
||||
if (item.role == ROLE_USER && item.status == MsgStatus.Failed &&
|
||||
historyUser.any {
|
||||
it.text == item.text &&
|
||||
it.media.map { m -> m.mediaId } == item.media.map { m -> m.mediaId }
|
||||
}
|
||||
) {
|
||||
return@filter false
|
||||
}
|
||||
true
|
||||
}
|
||||
// ts of every item in the current lane: ts-less items (commentary,
|
||||
// tool cards) inherit the ts of the item before them, so a tool
|
||||
// card anchored to a commentary still sorts at the right place.
|
||||
val tsOf = mutableMapOf<String, Long>()
|
||||
var lastTs = 0L
|
||||
for (item in list) {
|
||||
val t = (item as? MessageItem)?.ts?.takeIf { it > 0 } ?: lastTs
|
||||
if (t > 0) lastTs = t
|
||||
tsOf[item.id] = t
|
||||
}
|
||||
// History is authoritative for the ts of its messages.
|
||||
for (m in messages) {
|
||||
if (m.ts > 0) tsOf[m.id] = m.ts
|
||||
}
|
||||
(messages + preserved).sortedBy { item ->
|
||||
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||
when (item) {
|
||||
is MessageItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||
|
||||
// A resolved ts of 0 means the anchor itself is ts-less
|
||||
// (lane start) — sort with it (end) instead of to the top.
|
||||
is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||
|
||||
is PickerItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -711,12 +911,29 @@ class ChatStore {
|
||||
* delta and the `history` refresh reconcile the cache on connect.
|
||||
* No-op when [lanes] is empty (first launch).
|
||||
*/
|
||||
fun loadFromCache(lanes: Map<String, List<MessageItem>>) {
|
||||
fun loadFromCache(lanes: Map<String, List<ChatItem>>) {
|
||||
if (lanes.isEmpty()) return
|
||||
_lanes.value = lanes
|
||||
synchronized(lock) {
|
||||
// The cache is ordered by (ts, id); pending/failed sends are
|
||||
// persisted with ts = 0, so the DB returns them FIRST — but in
|
||||
// memory addPending appends them to the END of the lane. Move the
|
||||
// ts=0 message bubbles to the end (preserving their relative
|
||||
// order); everything else keeps its stored order, so a restored
|
||||
// lane looks like the live one until loadHistory re-sorts it
|
||||
// after a connect.
|
||||
_lanes.value =
|
||||
lanes.mapValues { (_, items) ->
|
||||
val (zeroTs, rest) = items.partition { (it as? MessageItem)?.ts == 0L }
|
||||
rest + zeroTs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_lanes.value = emptyMap()
|
||||
synchronized(lock) {
|
||||
_lanes.value = emptyMap()
|
||||
_unread.value = emptyMap()
|
||||
_todos.value = emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,26 @@ package iris.data
|
||||
|
||||
/**
|
||||
* Pairing settings storage. The token is a secret: platform actuals keep it
|
||||
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
|
||||
* SharedPreferences for M1 dev, file on desktop).
|
||||
* in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
|
||||
* encrypted file on desktop).
|
||||
*/
|
||||
interface SecureStore {
|
||||
/** ws(s)://host:port/ws */
|
||||
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
||||
var serverUrl: String
|
||||
|
||||
/** ANDROID_TOKEN presented in the hello frame. */
|
||||
/** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */
|
||||
var token: String
|
||||
|
||||
/** Per-device token minted at pairing (hello.ack ``device_token``,
|
||||
* docs/09 §9.3). Presented INSTEAD of [token] when non-empty; the
|
||||
* gateway can revoke it per device. Empty until the first hello.ack. */
|
||||
var deviceToken: String
|
||||
|
||||
/** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS
|
||||
* certificate, confirmed by the user on first pair (docs/09 §9.4).
|
||||
* Empty = nothing pinned (CA-signed certs need no pin). */
|
||||
var pinnedCertFingerprint: String
|
||||
|
||||
/** Stable app-generated device id (persisted). */
|
||||
val deviceId: String
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package iris.media
|
||||
|
||||
/** A readable local file (expect/actual; JVM impl in jvmMain). */
|
||||
expect class FileSource(path: String) : AutoCloseable {
|
||||
/** Total size in bytes. */
|
||||
fun size(): Long
|
||||
|
||||
/** Read up to [buf.size] bytes into [buf]; returns bytes read or -1 at EOF. */
|
||||
fun read(buf: ByteArray): Int
|
||||
}
|
||||
@@ -6,32 +6,43 @@ import iris.protocol.KIND_IMAGE
|
||||
import iris.protocol.KIND_VIDEO
|
||||
|
||||
/** Map a MIME type to a media kind (docs/07 §7.1). */
|
||||
fun kindFromMime(mime: String): String = when {
|
||||
mime.startsWith("image/") -> KIND_IMAGE
|
||||
mime.startsWith("video/") -> KIND_VIDEO
|
||||
mime.startsWith("audio/") -> KIND_AUDIO
|
||||
else -> KIND_DOCUMENT
|
||||
}
|
||||
fun kindFromMime(mime: String): String =
|
||||
when {
|
||||
mime.startsWith("image/") -> KIND_IMAGE
|
||||
mime.startsWith("video/") -> KIND_VIDEO
|
||||
mime.startsWith("audio/") -> KIND_AUDIO
|
||||
else -> KIND_DOCUMENT
|
||||
}
|
||||
|
||||
/** Best-effort file extension for a MIME type (cache file naming). */
|
||||
fun extForMime(mime: String): String = when {
|
||||
mime == "image/jpeg" -> ".jpg"
|
||||
mime == "image/png" -> ".png"
|
||||
mime == "image/webp" -> ".webp"
|
||||
mime == "image/gif" -> ".gif"
|
||||
mime == "image/heic" -> ".heic"
|
||||
mime == "image/heif" -> ".heif"
|
||||
mime == "video/mp4" -> ".mp4"
|
||||
mime == "video/webm" -> ".webm"
|
||||
mime == "video/quicktime" -> ".mov"
|
||||
mime == "audio/mpeg" -> ".mp3"
|
||||
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
|
||||
mime == "audio/ogg" -> ".ogg"
|
||||
mime == "audio/wav" -> ".wav"
|
||||
mime == "audio/flac" -> ".flac"
|
||||
mime == "audio/aac" -> ".aac"
|
||||
mime == "application/pdf" -> ".pdf"
|
||||
mime == "application/zip" -> ".zip"
|
||||
mime == "text/plain" -> ".txt"
|
||||
else -> ".bin"
|
||||
}
|
||||
fun extForMime(mime: String): String =
|
||||
when {
|
||||
mime == "image/jpeg" -> ".jpg"
|
||||
mime == "image/png" -> ".png"
|
||||
mime == "image/webp" -> ".webp"
|
||||
mime == "image/gif" -> ".gif"
|
||||
mime == "image/heic" -> ".heic"
|
||||
mime == "image/heif" -> ".heif"
|
||||
mime == "video/mp4" -> ".mp4"
|
||||
mime == "video/webm" -> ".webm"
|
||||
mime == "video/quicktime" -> ".mov"
|
||||
mime == "audio/mpeg" -> ".mp3"
|
||||
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
|
||||
mime == "audio/ogg" -> ".ogg"
|
||||
mime == "audio/wav" -> ".wav"
|
||||
mime == "audio/flac" -> ".flac"
|
||||
mime == "audio/aac" -> ".aac"
|
||||
mime == "application/pdf" -> ".pdf"
|
||||
mime == "application/zip" -> ".zip"
|
||||
mime == "text/plain" -> ".txt"
|
||||
else -> ".bin"
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a server-provided media id before it is used in a file path or a
|
||||
* `GET /v1/media/{id}` URL (M-2 / S-1). The id comes from the gateway's
|
||||
* `media.offer` frame; a value like `../../x` would write outside the media
|
||||
* directory and break the pull URL. Only a conservative token charset is
|
||||
* accepted.
|
||||
*/
|
||||
fun isValidMediaId(id: String): Boolean = id.length in 1..128 && id.all { it.isLetterOrDigit() || it == '_' || it == '-' }
|
||||
@@ -1,33 +1,19 @@
|
||||
package iris.net
|
||||
|
||||
import iris.data.SecureStore
|
||||
import iris.media.FileSource
|
||||
import iris.media.Sha256
|
||||
import iris.protocol.ChannelInfo
|
||||
import iris.protocol.ErrorPayload
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.HelloAckPayload
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.MediaPullEndPayload
|
||||
import iris.protocol.MediaUploadAckPayload
|
||||
import iris.protocol.ServerCaps
|
||||
import iris.protocol.TYPE_ERROR
|
||||
import iris.protocol.TYPE_HELLO_ACK
|
||||
import iris.protocol.TYPE_MEDIA_PULL_END
|
||||
import iris.protocol.TYPE_MEDIA_UPLOAD_ACK
|
||||
import iris.protocol.TYPE_PONG
|
||||
import iris.protocol.helloFrame
|
||||
import iris.protocol.mediaPullFrame
|
||||
import iris.protocol.mediaUploadEndFrame
|
||||
import iris.protocol.mediaUploadStartFrame
|
||||
import iris.protocol.messageSendFrame
|
||||
import iris.protocol.pingFrame
|
||||
import iris.protocol.syncFrame
|
||||
import iris.util.IrisLog
|
||||
import iris.util.nowMillis
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -40,28 +26,23 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
|
||||
* HTTP client for the hermes iris gateway (docs/19).
|
||||
*
|
||||
* - connect + hello (real auth leg), hello.ack
|
||||
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
||||
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
||||
*
|
||||
* - connect: health probe + SSE hello (the HTTP hello.ack)
|
||||
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
|
||||
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
|
||||
* - events: server frames (minus hello.ack) on [events]
|
||||
* - request/response correlation by id (M2+ consumers)
|
||||
* - events: server frames on [events]
|
||||
* - correlation: the POST body carries the synchronous reply (e.g. read
|
||||
* receipt, errors); everything else arrives on the event stream, and the
|
||||
* app reconciles by frame id (docs/19 §19.7)
|
||||
*/
|
||||
class GatewayClient(
|
||||
private val scope: CoroutineScope,
|
||||
@@ -86,6 +67,13 @@ class GatewayClient(
|
||||
data class AuthFailed(
|
||||
val message: String,
|
||||
) : State
|
||||
|
||||
/** The gateway's TLS certificate is untrusted and doesn't match the
|
||||
* pinned fingerprint (docs/09 §9.4). Terminal: the connect loop
|
||||
* stops and the UI asks the user to confirm the fingerprint. */
|
||||
data class TlsConfirmRequired(
|
||||
val fingerprint: String,
|
||||
) : State
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||
@@ -98,48 +86,79 @@ class GatewayClient(
|
||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
||||
|
||||
// TLS: the pinning trust manager wraps the platform default (CA-signed
|
||||
// certs behave as before); a self-signed gateway cert is accepted only
|
||||
// after the user confirms its fingerprint (docs/09 §9.4). The pin is
|
||||
// read live from the store so a fresh confirm takes effect without
|
||||
// rebuilding the client.
|
||||
private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint }
|
||||
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
|
||||
.build()
|
||||
|
||||
private var connectJob: Job? = null
|
||||
private var socket: WebSocket? = null
|
||||
private var nextRequestId = 1
|
||||
|
||||
// Incremented from the SSE callback thread (onHttpHello) and the UI
|
||||
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
|
||||
// request/response correlation breaks.
|
||||
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
|
||||
|
||||
// Written by poke() (UI thread) and the connect loop (Default).
|
||||
@Volatile
|
||||
private var attempt = 0
|
||||
|
||||
// Set by poke() (app returned to the foreground): the connect loop's
|
||||
// backoff waits in 500 ms slices and re-probes immediately when set.
|
||||
@Volatile
|
||||
private var wakeRequested = false
|
||||
|
||||
// True once a connection has been established this session; reset by
|
||||
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
|
||||
// drop) so the UI can show the right status without a blocking screen.
|
||||
private var hasConnected = false
|
||||
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
|
||||
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
||||
|
||||
// M4: binary frames (media upload chunks / pull stream) have no per-frame
|
||||
// id, so at most one binary session is active per socket. The gateway
|
||||
// allows one upload per connection; pull is request/response.
|
||||
private sealed interface BinarySession {
|
||||
data class Pulling(
|
||||
val requestId: Int,
|
||||
val chunks: Channel<ByteArray>,
|
||||
val end: CompletableDeferred<Frame>,
|
||||
) : BinarySession
|
||||
}
|
||||
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
|
||||
// the resume cursor (SSE id / outbox high-water mark), updated from the
|
||||
// SSE/poll callback threads.
|
||||
private var http: HttpGateway? = null
|
||||
|
||||
private var binarySession: BinarySession? = null
|
||||
@Volatile
|
||||
private var httpCursor: Long = 0
|
||||
private var sseFailures = 0
|
||||
private var usingLongPoll = false
|
||||
|
||||
// Last hello.ack payload — used to restore State.Connected after a
|
||||
// reconnect state race in the connect loop. Written by the SSE callback
|
||||
// thread, read by the long-poll loop.
|
||||
@Volatile
|
||||
private var lastAck: HelloAckPayload? = null
|
||||
|
||||
// Epoch ms of the last frame delivered by the receive stream (SSE or
|
||||
// long-poll). The watchdog uses this to detect a STALE stream: a live
|
||||
// connection (heartbeats / poll answers keep it open, so the read timeout
|
||||
// never fires) that has stopped delivering frames — the state a gateway
|
||||
// restart can leave the app in, where sent messages sit at "sending…"
|
||||
// because their echo is never delivered (issue #6). Set when the receive
|
||||
// loop (re)starts so the first window isn't treated as stale.
|
||||
@Volatile
|
||||
private var lastFrameMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Fired promptly (on the WS thread) the moment `hello.ack` is received —
|
||||
* on every (re)connect. Used for time-critical work that must not wait for
|
||||
* the state collector, which can be starved for seconds during app startup
|
||||
* (Dispatchers.Default) and would push a history request past a flaky
|
||||
* network's window. Set before [start].
|
||||
* Fired promptly the moment the SSE hello (hello.ack) is received — on
|
||||
* every (re)connect. Used for time-critical work that must not wait for
|
||||
* the state collector, which can be starved for seconds during app
|
||||
* startup (Dispatchers.Default) and would push a history request past a
|
||||
* flaky network's window. Set before [start].
|
||||
*/
|
||||
var onHelloAck: ((State.Connected) -> Unit)? = null
|
||||
|
||||
// M4: only one pull may be in flight at a time (binarySession is a single
|
||||
// slot). Serialize concurrent offers so their byte streams don't interleave.
|
||||
// Only one pull may be in flight at a time. Serialize concurrent offers so
|
||||
// their byte streams don't interleave.
|
||||
private val pullMutex = Mutex()
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
@@ -152,13 +171,34 @@ class GatewayClient(
|
||||
connectJob = scope.launch { connectLoop() }
|
||||
}
|
||||
|
||||
/** Stop the connect loop and close the socket. */
|
||||
/**
|
||||
* Stop the connect loop and reset the HTTP leg. Without the reset, a
|
||||
* re-pair to a *different* gateway would keep using the cached
|
||||
* [HttpGateway] (old URL, old token, old resume cursor) until the process
|
||||
* is killed.
|
||||
*/
|
||||
fun stop() {
|
||||
connectJob?.cancel()
|
||||
connectJob = null
|
||||
socket?.close(1000, "client shutdown")
|
||||
socket = null
|
||||
_state.value = State.Disconnected
|
||||
http?.close()
|
||||
http = null
|
||||
httpCursor = 0
|
||||
sseFailures = 0
|
||||
usingLongPoll = false
|
||||
lastAck = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when the app returns to the foreground: if the connect loop is
|
||||
* between attempts (backing off after failed health probes — up to 30 s),
|
||||
* wake it so it re-probes immediately instead of making the user wait out
|
||||
* the backoff with a "Connecting…" banner. No-op while connected.
|
||||
*/
|
||||
fun poke() {
|
||||
if (_state.value is State.Connected) return
|
||||
attempt = 0
|
||||
wakeRequested = true
|
||||
}
|
||||
|
||||
/** Re-pair: stop, then start fresh (used after saving new settings). */
|
||||
@@ -170,223 +210,294 @@ class GatewayClient(
|
||||
private suspend fun connectLoop() {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val url = store.serverUrl.trim()
|
||||
val token = store.token
|
||||
// Per-device token when the gateway minted one (docs/09 §9.3),
|
||||
// else the shared IRIS_TOKEN (bootstrap).
|
||||
val token = store.deviceToken.ifBlank { store.token }
|
||||
if (url.isBlank() || token.isBlank()) {
|
||||
_state.value = State.Disconnected
|
||||
return
|
||||
}
|
||||
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
||||
val dial = dial(url, token)
|
||||
when (val result = dial.result) {
|
||||
is DialResult.AuthFailed -> {
|
||||
_state.value = State.AuthFailed(result.message)
|
||||
dial.socket.close(1000, "auth failed")
|
||||
return
|
||||
}
|
||||
|
||||
DialResult.Connected -> {
|
||||
hasConnected = true
|
||||
attempt = 0
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
dial.closed.await()
|
||||
if (!currentCoroutineContext().isActive) return
|
||||
// socket dropped -> loop again (Reconnecting)
|
||||
}
|
||||
|
||||
is DialResult.Failed -> {
|
||||
attempt++
|
||||
delay(backoffMs(attempt))
|
||||
val gw = httpGateway() ?: continue
|
||||
// Health probe: if the gateway is alive, open the SSE receive loop
|
||||
// (which delivers the hello.ack). Otherwise back off and retry.
|
||||
val healthOk =
|
||||
try {
|
||||
gw.health()
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
false
|
||||
}
|
||||
if (!healthOk) {
|
||||
attempt++
|
||||
backoffOrWake(backoffMs(attempt))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dial (one connect + hello) ────────────────────────────────────────
|
||||
|
||||
private sealed interface DialResult {
|
||||
data object Connected : DialResult
|
||||
|
||||
data class AuthFailed(
|
||||
val message: String,
|
||||
) : DialResult
|
||||
|
||||
data class Failed(
|
||||
val message: String,
|
||||
) : DialResult
|
||||
}
|
||||
|
||||
private data class Dial(
|
||||
val result: DialResult,
|
||||
val socket: WebSocket,
|
||||
val closed: CompletableDeferred<Unit>,
|
||||
)
|
||||
|
||||
private suspend fun dial(
|
||||
url: String,
|
||||
token: String,
|
||||
): Dial {
|
||||
val closed = CompletableDeferred<Unit>()
|
||||
val helloAck = CompletableDeferred<HelloAckPayload>()
|
||||
val authError = CompletableDeferred<String>()
|
||||
val fail = CompletableDeferred<String>()
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
val ws =
|
||||
client.newWebSocket(
|
||||
request,
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
IrisLog.d("ws open (${response.code})")
|
||||
webSocket.send(
|
||||
helloFrame(
|
||||
token = token,
|
||||
deviceId = store.deviceId,
|
||||
deviceName = store.deviceName,
|
||||
fcmToken = store.fcmToken.ifBlank { null },
|
||||
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||
).toWire(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
val frame =
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(Frame.serializer(), text)
|
||||
} catch (e: Exception) {
|
||||
// A dropped frame is silent data loss — log it (the
|
||||
// first bytes hint at which frame it was).
|
||||
IrisLog.e("frame decode failed (${text.length}B): $e :: ${text.take(120)}")
|
||||
return
|
||||
}
|
||||
when (frame.type) {
|
||||
TYPE_HELLO_ACK -> {
|
||||
val ack = frame.payloadAs<HelloAckPayload>()
|
||||
if (ack != null) helloAck.complete(ack)
|
||||
}
|
||||
|
||||
TYPE_ERROR -> {
|
||||
val err = frame.payloadAs<ErrorPayload>()
|
||||
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed")
|
||||
// M7: post-connect error frames are app events, not
|
||||
// auth failures — let the controller react.
|
||||
_events.tryEmit(frame)
|
||||
}
|
||||
|
||||
TYPE_PONG -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
else -> {
|
||||
_events.tryEmit(frame)
|
||||
frame.id?.let { id ->
|
||||
pending[id]?.complete(frame)
|
||||
// M4: terminal frame of a pull stream — close
|
||||
// the chunk channel so the pull loop exits.
|
||||
if (frame.type == TYPE_MEDIA_PULL_END) {
|
||||
(binarySession as? BinarySession.Pulling)?.let {
|
||||
it.chunks.close()
|
||||
binarySession = null
|
||||
}
|
||||
}
|
||||
}
|
||||
attempt = 0
|
||||
hasConnected = true
|
||||
sseFailures = 0
|
||||
usingLongPoll = false
|
||||
httpCursor = store.syncCursor
|
||||
lastFrameMs = nowMs()
|
||||
// Provisional Connected state (previous caps/channels) until the
|
||||
// SSE hello arrives with the real ones.
|
||||
val prev = _state.value
|
||||
_state.value =
|
||||
State.Connected(
|
||||
caps = (prev as? State.Connected)?.caps ?: ServerCaps(),
|
||||
channels = (prev as? State.Connected)?.channels ?: emptyList(),
|
||||
lastPushedCursor = (prev as? State.Connected)?.lastPushedCursor ?: 0,
|
||||
)
|
||||
// Run the HTTP receive loop (SSE/long-poll) until cancelled.
|
||||
coroutineScope {
|
||||
val receiveJob = launch { httpReceiveLoop(gw) }
|
||||
// Dead-stream watchdog: the SSE read timeout (45 s) is the
|
||||
// only in-stream dead-connection detector; probe /v1/health
|
||||
// in parallel so a dropped network flips the state to
|
||||
// Reconnecting within ~20 s (2 failed probes) instead of 45,
|
||||
// and a stuck stream can't keep a stale "Connected".
|
||||
var probeFailures = 0
|
||||
while (receiveJob.isActive) {
|
||||
delay(10_000)
|
||||
val ok =
|
||||
try {
|
||||
gw.health()
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) {
|
||||
receiveJob.cancel()
|
||||
break
|
||||
}
|
||||
false
|
||||
}
|
||||
probeFailures = if (ok) 0 else probeFailures + 1
|
||||
if (probeFailures >= 2) {
|
||||
receiveJob.cancel()
|
||||
break
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
bytes: ByteString,
|
||||
) {
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
// M4: binary frames belong to the active pull stream
|
||||
// (uploads are outbound; stray inbound chunks are dropped).
|
||||
(binarySession as? BinarySession.Pulling)
|
||||
?.chunks
|
||||
?.trySend(bytes.toByteArray())
|
||||
// Stale-stream watchdog: the gateway is up (health ok) but
|
||||
// the receive stream has delivered no frames for a while —
|
||||
// a live-but-dead connection the read timeout can't see.
|
||||
// Force a fresh (re)connect so parked frames (e.g. our own
|
||||
// echo) are re-delivered from the outbox.
|
||||
if (lastFrameMs > 0L && nowMs() - lastFrameMs > STALE_STREAM_TIMEOUT_MS) {
|
||||
IrisLog.w("receive stream stale (no frames for ${STALE_STREAM_TIMEOUT_MS}ms); forcing reconnect")
|
||||
receiveJob.cancel()
|
||||
break
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
IrisLog.w("ws closed code=$code reason=\"$reason\"")
|
||||
closed.complete(Unit)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
IrisLog.e("ws failure: ${t.javaClass.simpleName}: ${t.message} (http=${response?.code})")
|
||||
fail.complete(t.message ?: "connection failed")
|
||||
closed.complete(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
socket = ws
|
||||
|
||||
val winner = CompletableDeferred<DialResult>()
|
||||
helloAck.invokeOnCompletion { e ->
|
||||
if (e == null) {
|
||||
val ack = helloAck.getCompleted()
|
||||
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||
_state.value = connected
|
||||
// M5: reconnect catch-up — replay frames parked while offline.
|
||||
val local = store.syncCursor
|
||||
if (local < ack.syncCursor) {
|
||||
val id = nextRequestId++
|
||||
ws.send(syncFrame(id, local).toWire())
|
||||
}
|
||||
// Prompt fast path (before the possibly-starved state collector).
|
||||
onHelloAck?.invoke(connected)
|
||||
winner.complete(DialResult.Connected)
|
||||
receiveJob.join()
|
||||
}
|
||||
// Terminal failures: don't redial with the same bad token / the
|
||||
// same untrusted certificate (the UI asks the user to act).
|
||||
if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP receive leg ──────────────────────────────────────────────────
|
||||
|
||||
/** Lazily build the HTTP client from the stored URL. Synchronized: two
|
||||
* threads racing the check-then-create would leak a gateway (and its
|
||||
* OkHttp clients). */
|
||||
private fun httpGateway(): HttpGateway? =
|
||||
synchronized(this) {
|
||||
val url = store.serverUrl.trim()
|
||||
val token = store.deviceToken.ifBlank { store.token }
|
||||
if (url.isBlank() || token.isBlank()) return@synchronized null
|
||||
http
|
||||
?: HttpGateway(
|
||||
client,
|
||||
HttpGateway.deriveHttpUrl(url),
|
||||
// Live provider: a device token minted by the next
|
||||
// hello.ack is picked up without rebuilding the client.
|
||||
token = { store.deviceToken.ifBlank { store.token } },
|
||||
store.deviceId,
|
||||
deviceName = store.deviceName,
|
||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
||||
).also { http = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP receive loop: SSE by default; after two consecutive SSE open
|
||||
* failures (buffering proxy) it switches to long-poll until the next full
|
||||
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
|
||||
*/
|
||||
private suspend fun httpReceiveLoop(gw: HttpGateway) {
|
||||
var backoff = 1_000L
|
||||
while (currentCoroutineContext().isActive) {
|
||||
if (usingLongPoll) {
|
||||
try {
|
||||
val res = gw.poll(httpCursor)
|
||||
res.frames.forEach { emitHttpFrame(it) }
|
||||
if (res.cursor > httpCursor) httpCursor = res.cursor
|
||||
// The poll answered: the link is back (long-poll has no
|
||||
// hello — restore the Connected state from the last one).
|
||||
restoreConnected()
|
||||
} catch (e: HttpGateway.HttpAuthException) {
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
markStreamLost()
|
||||
IrisLog.w("http poll failed: ${e.message}")
|
||||
delay(backoff)
|
||||
backoff = minOf(backoff * 2, 15_000)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
gw.events(
|
||||
cursor = httpCursor,
|
||||
onHello = { onHttpHello(it) },
|
||||
onFrame = { emitHttpFrame(it) },
|
||||
onCursor = { if (it > httpCursor) httpCursor = it },
|
||||
// A keep-alive comment proves the stream is alive —
|
||||
// count it toward liveness so an idle-but-healthy
|
||||
// stream isn't force-reconnected by the stale
|
||||
// watchdog every 3 minutes.
|
||||
onKeepAlive = { lastFrameMs = nowMs() },
|
||||
)
|
||||
// Clean EOF: back off briefly so a server that keeps
|
||||
// closing cleanly can't tight-loop the reconnect.
|
||||
backoff = 1_000L
|
||||
delay(backoff)
|
||||
} catch (e: HttpGateway.HttpAuthException) {
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
sseFailures++
|
||||
markStreamLost()
|
||||
if (sseFailures >= 2) {
|
||||
// SSE seems blocked: switch to long-poll.
|
||||
usingLongPoll = true
|
||||
continue
|
||||
}
|
||||
IrisLog.w("sse read failed: ${e.message}")
|
||||
delay(backoff)
|
||||
backoff = minOf(backoff * 2, 15_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
authError.invokeOnCompletion { e ->
|
||||
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
|
||||
}
|
||||
|
||||
/** The SSE `event: hello` (the HTTP hello.ack). */
|
||||
private fun onHttpHello(ack: HelloAckPayload) {
|
||||
lastAck = ack
|
||||
// Per-device token (docs/09 §9.3): minted at pairing, stable across
|
||||
// (re)connects. Store it — from the next request on the app presents
|
||||
// it instead of the shared IRIS_TOKEN, so the gateway can revoke
|
||||
// THIS device without touching the others.
|
||||
if (ack.deviceToken.isNotBlank() && ack.deviceToken != store.deviceToken) {
|
||||
store.deviceToken = ack.deviceToken
|
||||
}
|
||||
fail.invokeOnCompletion { e ->
|
||||
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
|
||||
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||
_state.value = connected
|
||||
// M5: reconnect catch-up — replay frames parked while offline.
|
||||
val local = store.syncCursor
|
||||
if (local < ack.syncCursor) {
|
||||
val id = nextId()
|
||||
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
|
||||
}
|
||||
val result =
|
||||
withTimeoutOrNull(15_000) { winner.await() }
|
||||
?: DialResult.Failed("timeout waiting for hello.ack")
|
||||
return Dial(result, ws, closed)
|
||||
// Prompt fast path (before the possibly-starved state collector).
|
||||
onHelloAck?.invoke(connected)
|
||||
}
|
||||
|
||||
/** The receive stream just died: don't keep claiming "Connected" while
|
||||
* between attempts (a stale green dot through a Wi-Fi drop). */
|
||||
private fun markStreamLost() {
|
||||
if (_state.value is State.Connected) _state.value = State.Reconnecting
|
||||
}
|
||||
|
||||
/** The receive stream is open again (long-poll answered): the link is
|
||||
* back. Long-poll has no hello, so restore the Connected state from the
|
||||
* last hello.ack (the SSE path gets a fresh one). Fire [onHelloAck] too:
|
||||
* the long-poll path has no `event: hello`, so without this the app's
|
||||
* "on connected" side effects (resend queued offline sends, load the
|
||||
* active lane's history) never run when a gateway restart lands while
|
||||
* the app is on the long-poll fallback — queued messages would sit at
|
||||
* "sending…" forever until an app restart (issue #6). */
|
||||
private fun restoreConnected() {
|
||||
if (_state.value !is State.Reconnecting) return
|
||||
val connected =
|
||||
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
|
||||
?: State.Connected(ServerCaps(), emptyList())
|
||||
_state.value = connected
|
||||
onHelloAck?.invoke(connected)
|
||||
}
|
||||
|
||||
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */
|
||||
private fun emitHttpFrame(frame: Frame) {
|
||||
lastFrameMs = nowMs()
|
||||
if (!_events.tryEmit(frame)) {
|
||||
// The collector is starved beyond the 128-frame buffer: the frame
|
||||
// is dropped. Log it — a silent drop here loses user-visible
|
||||
// content (echoes, deltas, notifications).
|
||||
IrisLog.e("events buffer full — dropped frame ${frame.type} (id=${frame.id})")
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver the POST body's synchronous reply (or null) and handle a 401.
|
||||
* Shared by [sendMessage] and [sendFrame]. */
|
||||
private fun handlePostResult(res: HttpGateway.PostResult?) {
|
||||
res?.frame?.let { emitHttpFrame(it) }
|
||||
if (res?.status == 401) {
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal TLS failure: the presented certificate is untrusted and not
|
||||
* the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect
|
||||
* loop and let the UI ask the user to confirm the fingerprint. True when
|
||||
* the state was set. */
|
||||
private fun failTls(e: Exception): Boolean {
|
||||
val tls = tlsFingerprintRequired(e) ?: return false
|
||||
IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})")
|
||||
_state.value = State.TlsConfirmRequired(tls.fingerprint)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs).
|
||||
* [mediaRefs] reference completed uploads (POST /v1/media refs).
|
||||
* [autoThread] asks the gateway to mint a fresh thread for the message
|
||||
* (auto-threading, docs/06 §6.3). */
|
||||
* (auto-threading, docs/06 §6.3).
|
||||
* [onResult] is called with the POST's HTTP status — 0 means "no
|
||||
* response" (not connected, or the network failed); 2xx means the
|
||||
* gateway accepted it; 4xx is a gateway rejection (error frame already
|
||||
* delivered via [events]). Used to fail the optimistic bubble instead
|
||||
* of leaving it at "sending…" forever. */
|
||||
fun sendMessage(
|
||||
chatId: String,
|
||||
text: String,
|
||||
threadId: String? = null,
|
||||
mediaRefs: List<String> = emptyList(),
|
||||
autoThread: Boolean = false,
|
||||
onResult: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
val ws = socket ?: return
|
||||
val id = nextRequestId++
|
||||
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
|
||||
if (_state.value !is State.Connected) {
|
||||
onResult?.invoke(0)
|
||||
return
|
||||
}
|
||||
val id = nextId()
|
||||
scope.launch {
|
||||
val res =
|
||||
httpGateway()?.postFrame(
|
||||
messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread),
|
||||
)
|
||||
// The synchronous reply (e.g. the read receipt, or an error frame
|
||||
// on 4xx) comes back in the POST body, not on the event stream —
|
||||
// deliver it or it is lost (docs/19 §19.7).
|
||||
handlePostResult(res)
|
||||
onResult?.invoke(res?.status ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── M4: media upload / pull ───────────────────────────────────────────
|
||||
// ── Media upload / pull ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a local file as media (docs/07 §7.2): media.upload.start,
|
||||
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's
|
||||
* media_ref (for message.send media_refs) on success.
|
||||
* Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
|
||||
* Returns the server's media_ref (for message.send media_refs) on success.
|
||||
*/
|
||||
suspend fun uploadMedia(
|
||||
path: String,
|
||||
@@ -395,190 +506,140 @@ class GatewayClient(
|
||||
filename: String,
|
||||
mediaRef: String,
|
||||
): Result<String> {
|
||||
val ws = socket ?: return Result.failure(IllegalStateException("not connected"))
|
||||
val source = FileSource(path)
|
||||
val size = source.size()
|
||||
if (size <= 0) {
|
||||
source.close()
|
||||
return Result.failure(IllegalStateException("empty file"))
|
||||
}
|
||||
val id = nextRequestId++
|
||||
val reply = CompletableDeferred<Frame>()
|
||||
pending[id] = reply
|
||||
try {
|
||||
ws.send(mediaUploadStartFrame(id, mediaRef, kind, mime, filename, size).toWire())
|
||||
val sha = Sha256()
|
||||
source.use {
|
||||
val buf = ByteArray(UPLOAD_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = it.read(buf)
|
||||
if (n < 0) break
|
||||
if (n == 0) continue
|
||||
sha.update(buf, 0, n)
|
||||
ws.send(buf.copyOfRange(0, n).toByteString())
|
||||
}
|
||||
}
|
||||
ws.send(mediaUploadEndFrame(id, mediaRef, sha.hex()).toWire())
|
||||
val frame = withTimeout(UPLOAD_TIMEOUT_MS) { reply.await() }
|
||||
return when (frame.type) {
|
||||
TYPE_MEDIA_UPLOAD_ACK -> {
|
||||
val p = frame.payloadAs<MediaUploadAckPayload>()
|
||||
if (p != null && p.ok) {
|
||||
Result.success(p.mediaRef)
|
||||
} else {
|
||||
Result.failure(IllegalStateException("upload rejected by server"))
|
||||
}
|
||||
}
|
||||
|
||||
TYPE_ERROR -> {
|
||||
val e = frame.payloadAs<ErrorPayload>()
|
||||
Result.failure(IllegalStateException(e?.message ?: "upload failed"))
|
||||
}
|
||||
|
||||
else -> {
|
||||
Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
} finally {
|
||||
pending.remove(id)
|
||||
}
|
||||
val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
|
||||
return http.uploadMedia(path, mime, kind, filename, mediaRef)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until
|
||||
* media.pull.end. Each chunk is handed to [onChunk] (write to cache).
|
||||
* Pull offered media via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
|
||||
* chunk is handed to [onChunk] (write to cache).
|
||||
*/
|
||||
suspend fun pullMedia(
|
||||
mediaId: String,
|
||||
onChunk: suspend (ByteArray) -> Unit,
|
||||
): Result<Unit> =
|
||||
pullMutex.withLock {
|
||||
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
||||
val id = nextRequestId++
|
||||
val chunks = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
val end = CompletableDeferred<Frame>()
|
||||
pending[id] = end
|
||||
binarySession = BinarySession.Pulling(id, chunks, end)
|
||||
try {
|
||||
ws.send(mediaPullFrame(id, mediaId).toWire())
|
||||
val frame =
|
||||
withTimeout(PULL_TIMEOUT_MS) {
|
||||
for (chunk in chunks) onChunk(chunk)
|
||||
end.await()
|
||||
}
|
||||
when (frame.type) {
|
||||
TYPE_MEDIA_PULL_END -> {
|
||||
val p = frame.payloadAs<MediaPullEndPayload>()
|
||||
if (p != null && p.ok) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(IllegalStateException("pull failed"))
|
||||
}
|
||||
}
|
||||
|
||||
TYPE_ERROR -> {
|
||||
val e = frame.payloadAs<ErrorPayload>()
|
||||
Result.failure(IllegalStateException(e?.message ?: "pull failed"))
|
||||
}
|
||||
|
||||
else -> {
|
||||
Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
pending.remove(id)
|
||||
chunks.cancel()
|
||||
val s = binarySession
|
||||
if (s is BinarySession.Pulling && s.requestId == id) binarySession = null
|
||||
}
|
||||
val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
||||
http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */
|
||||
const val UPLOAD_CHUNK_BYTES = 256 * 1024
|
||||
const val UPLOAD_TIMEOUT_MS = 120_000L
|
||||
const val PULL_TIMEOUT_MS = 300_000L
|
||||
/** No frames (or keep-alive comments) from the receive stream for
|
||||
* this long (gateway still
|
||||
* healthy) = stale stream: force a fresh (re)connect. 3 min keeps an
|
||||
* idle app from churning while still recovering a stuck stream well
|
||||
* before a user notices (issue #6). */
|
||||
const val STALE_STREAM_TIMEOUT_MS = 180_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an arbitrary frame with a fresh request id (fire-and-forget).
|
||||
* The server replies (or broadcasts) a frame carrying the same id; the
|
||||
* app reconciles from [events]. Returns the id used, or -1 if not connected.
|
||||
* Send an arbitrary frame with a fresh request id (fire-and-forget). The
|
||||
* server replies (or broadcasts) a frame carrying the same id; the app
|
||||
* reconciles from [events]. Returns the id used, or -1 if not connected.
|
||||
*/
|
||||
fun sendFrame(frame: Frame): Int {
|
||||
val ws = socket ?: return -1
|
||||
val id = nextRequestId++
|
||||
ws.send(frame.copy(id = id).toWire())
|
||||
return id
|
||||
}
|
||||
|
||||
/** Send a ping (heartbeat). */
|
||||
fun ping() {
|
||||
socket?.send(pingFrame().toWire())
|
||||
}
|
||||
|
||||
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
|
||||
fun isStale(timeoutMs: Long = 60_000): Boolean =
|
||||
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
|
||||
|
||||
fun reapStale() {
|
||||
if (isStale()) {
|
||||
socket?.close(1000, "heartbeat timeout")
|
||||
if (_state.value !is State.Connected) return -1
|
||||
val id = nextId()
|
||||
scope.launch {
|
||||
val res = httpGateway()?.postFrame(frame.copy(id = id))
|
||||
// Single-frame responses (commands.catalog, channel.list, search,
|
||||
// history, sync, errors) come back in the POST body, not on the
|
||||
// event stream — deliver it or it is lost (docs/19 §19.7).
|
||||
handlePostResult(res)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ── One-shot hello test (Connect screen) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
|
||||
* Exercises the auth leg, not just TCP (docs/10 §10.8).
|
||||
* Real connection test: health probe + SSE open. The auth leg is proven
|
||||
* by the stream being accepted (200 vs 401) — we do NOT wait for the
|
||||
* hello event, because the server replays the outbox (up to 72 h of
|
||||
* frames) before it and a large outbox would time out a healthy gateway
|
||||
* (docs/10 §10.8).
|
||||
*/
|
||||
suspend fun testHello(
|
||||
url: String,
|
||||
token: String,
|
||||
): Result<Unit> {
|
||||
val dial = dial(url, token)
|
||||
return when (val result = dial.result) {
|
||||
DialResult.Connected -> {
|
||||
dial.socket.close(1000, "test complete")
|
||||
Result.success(Unit)
|
||||
}
|
||||
|
||||
is DialResult.AuthFailed -> {
|
||||
Result.failure(IllegalStateException(result.message))
|
||||
}
|
||||
|
||||
is DialResult.Failed -> {
|
||||
Result.failure(IllegalStateException(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat job ─────────────────────────────────────────────────────
|
||||
|
||||
fun startHeartbeat() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(20_000)
|
||||
if (_state.value is State.Connected) {
|
||||
ping()
|
||||
reapStale()
|
||||
val gw =
|
||||
HttpGateway(
|
||||
client,
|
||||
HttpGateway.deriveHttpUrl(url),
|
||||
// An already-paired device presents its per-device token
|
||||
// (docs/09 §9.3); a fresh pairing falls back to the entered
|
||||
// shared token (bootstrap).
|
||||
token = { store.deviceToken.ifBlank { token } },
|
||||
store.deviceId,
|
||||
deviceName = store.deviceName,
|
||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
||||
)
|
||||
return try {
|
||||
if (!gw.health()) {
|
||||
Result.failure(IllegalStateException("gateway unreachable"))
|
||||
} else {
|
||||
// Open the SSE stream briefly: 200 = auth leg proven, 401 =
|
||||
// bad token. Don't wait for the hello (outbox replay first).
|
||||
val opened = CompletableDeferred<Unit>()
|
||||
val job =
|
||||
scope.launch {
|
||||
try {
|
||||
gw.events(
|
||||
cursor = store.syncCursor,
|
||||
onOpen = { opened.complete(Unit) },
|
||||
onHello = { },
|
||||
onFrame = { },
|
||||
onCursor = { },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
opened.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (withTimeoutOrNull(10_000) { opened.await() } == null) {
|
||||
Result.failure(IllegalStateException("timeout opening the event stream"))
|
||||
} else {
|
||||
Result.success(Unit)
|
||||
}
|
||||
} catch (e: HttpGateway.HttpAuthException) {
|
||||
Result.failure(IllegalStateException("unauthorized — check the pairing token"))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Unwrap the pinning manager's signal so the Connect
|
||||
// screen can offer the fingerprint confirm dialog.
|
||||
Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}"))
|
||||
} finally {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(tlsFingerprintRequired(e) ?: e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Backoff that wakes early when [poke] is called (app foregrounded). */
|
||||
private suspend fun backoffOrWake(ms: Long) {
|
||||
var remaining = ms
|
||||
while (remaining > 0 && currentCoroutineContext().isActive) {
|
||||
delay(minOf(remaining, 500L))
|
||||
if (wakeRequested) {
|
||||
wakeRequested = false
|
||||
return
|
||||
}
|
||||
remaining -= 500L
|
||||
}
|
||||
}
|
||||
|
||||
private fun backoffMs(attempt: Int): Long {
|
||||
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
||||
val capped = minOf(base, 30_000L)
|
||||
return capped + Random.nextLong(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
|
||||
private fun nowMs(): Long = nowMillis()
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package iris.net
|
||||
|
||||
import iris.media.Sha256
|
||||
import iris.media.isValidMediaId
|
||||
import iris.protocol.ErrorPayload
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.HelloAckPayload
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.MediaUploadAckPayload
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* HTTP transport client (docs/19) — the only transport.
|
||||
*
|
||||
* The app sends over `POST /v1/frame` and receives over SSE
|
||||
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
|
||||
* media travels via `POST/GET /v1/media`.
|
||||
*
|
||||
* [events] is ONE SSE connection attempt (blocking read on
|
||||
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
|
||||
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
|
||||
* outbox cursor, so resume is just the last seen id).
|
||||
*/
|
||||
class HttpGateway(
|
||||
private val client: OkHttpClient,
|
||||
private val baseUrl: String,
|
||||
/** Live auth-token provider, read per request: the per-device token
|
||||
* (docs/09 §9.3) when the gateway minted one, else the shared
|
||||
* IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is
|
||||
* picked up without rebuilding the client. */
|
||||
private val token: () -> String,
|
||||
private val deviceId: String,
|
||||
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
|
||||
* upserts it into the device registry on every SSE open — the HTTP
|
||||
* equivalent of the old WS hello upsert). */
|
||||
private val deviceName: String? = null,
|
||||
/** Live push-token providers, read per request so a rotated FCM token or
|
||||
* a fresh ntfy topic is picked up without rebuilding the client. */
|
||||
private val fcmToken: () -> String? = { null },
|
||||
private val ntfyTopic: () -> String? = { null },
|
||||
) {
|
||||
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
|
||||
* with the same token can't succeed. */
|
||||
class HttpAuthException : IOException("unauthorized (HTTP 401)")
|
||||
|
||||
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
|
||||
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
|
||||
// with extended call timeouts (see the *Client() helpers below).
|
||||
private val healthClient: OkHttpClient = client.healthClient()
|
||||
private val streamClient: OkHttpClient = client.streamClient()
|
||||
private val pollClient: OkHttpClient = client.pollClient()
|
||||
private val mediaClient: OkHttpClient = client.mediaClient()
|
||||
|
||||
/** Close the per-purpose clients (and their idle connections). The
|
||||
* shared base [client] is owned by the caller. */
|
||||
fun close() {
|
||||
healthClient.dispatcher.executorService.shutdown()
|
||||
streamClient.dispatcher.executorService.shutdown()
|
||||
pollClient.dispatcher.executorService.shutdown()
|
||||
mediaClient.dispatcher.executorService.shutdown()
|
||||
healthClient.connectionPool.evictAll()
|
||||
streamClient.connectionPool.evictAll()
|
||||
pollClient.connectionPool.evictAll()
|
||||
mediaClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
/** POST /v1/frame result. [frame] is the handler's synchronous reply
|
||||
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
|
||||
* 202 accept-and-ack. */
|
||||
data class PostResult(
|
||||
val ok: Boolean,
|
||||
val status: Int,
|
||||
val frame: Frame?,
|
||||
)
|
||||
|
||||
/** Long-poll result: new high-water [cursor] + frames with cursor >
|
||||
* the requested one (may be empty at timeout). */
|
||||
data class PollResult(
|
||||
val cursor: Long,
|
||||
val frames: List<Frame>,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val JSON = "application/json".toMediaType()
|
||||
|
||||
/** Default port of the gateway's HTTP leg (WS default is 8790). */
|
||||
const val DEFAULT_PORT = 8791
|
||||
|
||||
/** Media transfer chunk (docs/07 §7.5). */
|
||||
private const val MEDIA_CHUNK_BYTES = 256 * 1024
|
||||
|
||||
/**
|
||||
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
|
||||
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
|
||||
* a different service, so the port is always replaced with the
|
||||
* HTTP leg's default. Pure function (unit-tested).
|
||||
*/
|
||||
fun deriveHttpUrl(wsUrl: String): String {
|
||||
val u = wsUrl.trim()
|
||||
val (scheme, rest) =
|
||||
when {
|
||||
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
|
||||
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
|
||||
else -> return u // already http(s)
|
||||
}
|
||||
val authority = rest.substringBefore('/')
|
||||
val host = authority.substringBefore(':')
|
||||
return "$scheme://$host:$DEFAULT_PORT"
|
||||
}
|
||||
}
|
||||
|
||||
private fun authHeaders(): Headers {
|
||||
val b =
|
||||
Headers
|
||||
.Builder()
|
||||
.add("Authorization", "Bearer ${token()}")
|
||||
.add("X-Iris-Device", deviceId)
|
||||
// Device registration (docs/19): the gateway upserts name + push
|
||||
// tokens from these headers on every SSE open (COALESCE — absent
|
||||
// headers never clobber a newer fcm.register value).
|
||||
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) }
|
||||
return b.build()
|
||||
}
|
||||
|
||||
/** Parse a response body as a protocol frame (null when not a frame,
|
||||
* e.g. the plain `{"ok":true}` ack). */
|
||||
private fun parseFrame(body: String): Frame? =
|
||||
try {
|
||||
if (body.startsWith("{")) {
|
||||
val obj = IrisJson.instance.parseToJsonElement(body)
|
||||
if (obj.jsonObject.containsKey("type")) {
|
||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Liveness probe (unauthenticated by design). True on 200. */
|
||||
suspend fun health(): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/health")
|
||||
.build()
|
||||
healthClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
response.body?.close()
|
||||
response.code == 200
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
|
||||
* (with the synchronous reply frame when the handler sent one); 4xx ->
|
||||
* the error frame as the body. Network failures (timeout, reset, DNS —
|
||||
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
|
||||
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
|
||||
* callers are fire-and-forget coroutines — an uncaught exception here
|
||||
* kills the app process.
|
||||
*/
|
||||
suspend fun postFrame(frame: Frame): PostResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/frame")
|
||||
.headers(authHeaders())
|
||||
.post(wire.toRequestBody(JSON))
|
||||
.build()
|
||||
try {
|
||||
client
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseFrame(body)
|
||||
PostResult(response.isSuccessful, response.code, parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
|
||||
PostResult(ok = false, status = 0, frame = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One SSE connection attempt: outbox catch-up from [cursor], then live
|
||||
* frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
|
||||
* leg is proven; the server may still replay a large outbox before the
|
||||
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
|
||||
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
|
||||
* cursor) when present; [onKeepAlive] for SSE comment lines (the
|
||||
* heartbeat) so callers can count keep-alives toward liveness. Returns
|
||||
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
|
||||
* on the IO thread.
|
||||
*/
|
||||
suspend fun events(
|
||||
cursor: Long,
|
||||
onOpen: (() -> Unit)? = null,
|
||||
onHello: (HelloAckPayload) -> Unit,
|
||||
onFrame: (Frame) -> Unit,
|
||||
onCursor: (Long) -> Unit,
|
||||
onKeepAlive: (() -> Unit)? = null,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/events?cursor=$cursor")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
streamClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (response.code == 401) throw HttpAuthException()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("SSE open failed: HTTP ${response.code}")
|
||||
}
|
||||
onOpen?.invoke()
|
||||
val source = response.body?.source() ?: throw IOException("empty SSE body")
|
||||
var eventId: String? = null
|
||||
val dataLines = mutableListOf<String>()
|
||||
while (true) {
|
||||
val line = source.readUtf8Line() ?: break // EOF
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (dataLines.isNotEmpty()) {
|
||||
val data = dataLines.joinToString("\n")
|
||||
try {
|
||||
val frame =
|
||||
IrisJson.instance.decodeFromString(
|
||||
Frame.serializer(),
|
||||
data,
|
||||
)
|
||||
when (eventId) {
|
||||
"hello" -> {
|
||||
onHello(
|
||||
frame.payloadAs<HelloAckPayload>()
|
||||
?: HelloAckPayload(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
onFrame(frame)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
|
||||
}
|
||||
}
|
||||
eventId = null
|
||||
dataLines.clear()
|
||||
}
|
||||
|
||||
line.startsWith(":") -> {
|
||||
// heartbeat comment
|
||||
onKeepAlive?.invoke()
|
||||
}
|
||||
|
||||
line.startsWith("id:") -> {
|
||||
line
|
||||
.removePrefix("id:")
|
||||
.trim()
|
||||
.toLongOrNull()
|
||||
?.let(onCursor)
|
||||
}
|
||||
|
||||
line.startsWith("event:") -> {
|
||||
eventId = line.removePrefix("event:").trim()
|
||||
}
|
||||
|
||||
line.startsWith("data:") -> {
|
||||
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
|
||||
* Returns the new high-water cursor + any frames with cursor > [cursor].
|
||||
*/
|
||||
suspend fun poll(cursor: Long): PollResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/poll?cursor=$cursor")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
pollClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (response.code == 401) throw HttpAuthException()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("poll failed: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
|
||||
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
|
||||
val frames =
|
||||
obj["frames"]
|
||||
?.jsonArray
|
||||
?.mapNotNull { el ->
|
||||
try {
|
||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.orEmpty()
|
||||
PollResult(newCursor, frames)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
|
||||
* with the whole file as the body and the metadata in `X-Iris-Media-*`
|
||||
* headers (sha256 precomputed in a first pass). Returns the server's
|
||||
* media_ref (for message.send media_refs) on success.
|
||||
*/
|
||||
suspend fun uploadMedia(
|
||||
path: String,
|
||||
mime: String,
|
||||
kind: String,
|
||||
filename: String,
|
||||
mediaRef: String,
|
||||
): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = File(path)
|
||||
if (!file.isFile() || file.length() <= 0) {
|
||||
return@withContext Result.failure(IllegalStateException("empty file"))
|
||||
}
|
||||
val sha = Sha256()
|
||||
file.inputStream().use { ins ->
|
||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = ins.read(buf)
|
||||
if (n < 0) break
|
||||
if (n > 0) sha.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/media")
|
||||
.headers(
|
||||
authHeaders()
|
||||
.newBuilder()
|
||||
.add("X-Iris-Media-Ref", mediaRef)
|
||||
.add("X-Iris-Media-Kind", kind)
|
||||
.add("X-Iris-Media-Filename", filename)
|
||||
.add("X-Iris-Media-Sha256", sha.hex())
|
||||
.build(),
|
||||
).post(file.asRequestBody(mime.toMediaType()))
|
||||
.build()
|
||||
try {
|
||||
mediaClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
val frame = parseFrame(body)
|
||||
if (response.isSuccessful) {
|
||||
val p = frame?.payloadAs<MediaUploadAckPayload>()
|
||||
if (p != null && p.ok) {
|
||||
Result.success(p.mediaRef)
|
||||
} else {
|
||||
Result.failure(IllegalStateException("upload rejected by server"))
|
||||
}
|
||||
} else {
|
||||
val e = frame?.payloadAs<ErrorPayload>()
|
||||
Result.failure(
|
||||
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Network failure mid-upload: report, don't throw (the caller
|
||||
// is a fire-and-forget coroutine — an uncaught exception kills
|
||||
// the app process).
|
||||
IrisLog.w("media upload failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
|
||||
* response body is the file, streamed to [onChunk] (write to cache).
|
||||
*/
|
||||
suspend fun pullMedia(
|
||||
mediaId: String,
|
||||
onChunk: suspend (ByteArray) -> Unit,
|
||||
): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Server-controlled id: reject path-traversal / URL-breaking
|
||||
// values before they reach the request line (M-2 / S-1).
|
||||
if (!isValidMediaId(mediaId)) {
|
||||
return@withContext Result.failure(IllegalStateException("invalid media id"))
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/media/$mediaId")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
try {
|
||||
mediaClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
|
||||
Result.failure(
|
||||
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
val source = response.body?.source() ?: throw IOException("empty body")
|
||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = source.read(buf)
|
||||
if (n < 0) break
|
||||
if (n == 0) continue
|
||||
onChunk(buf.copyOfRange(0, n))
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Network failure before/while opening the pull: report, don't
|
||||
// throw (fire-and-forget caller — uncaught = process death).
|
||||
IrisLog.w("media pull failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
|
||||
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
|
||||
* (25 s) — it would kill both receive paths while they are simply waiting
|
||||
* for the next byte, so the streaming clients override it. The read timeout
|
||||
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
|
||||
* comment every 15 s; a healthy poll answers within 25 s).
|
||||
*/
|
||||
|
||||
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
|
||||
* heartbeat (detects a dead connection within 45 s). */
|
||||
internal fun OkHttpClient.streamClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(45_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
|
||||
* hold + 15 s margin. */
|
||||
internal fun OkHttpClient.pollClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(40_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
internal fun OkHttpClient.healthClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(2_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Media transfers (upload/pull) can take a while on large files. */
|
||||
internal fun OkHttpClient.mediaClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(300_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
@@ -0,0 +1,118 @@
|
||||
package iris.net
|
||||
|
||||
import java.security.KeyStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
|
||||
*
|
||||
* A gateway behind `IRIS_HTTP_CERT` may present a
|
||||
* self-signed certificate the platform doesn't trust. Instead of forcing the
|
||||
* user to install it into the system trust store, the app shows the
|
||||
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
|
||||
* the user confirms it, the fingerprint is pinned in secure storage and
|
||||
* [PinningTrustManager] accepts exactly that certificate from then on.
|
||||
*
|
||||
* Hostname verification is NOT bypassed: OkHttp runs its own hostname check
|
||||
* on top of the trust manager, so a pinned cert still has to match the URL's
|
||||
* host. A *changed* certificate (different fingerprint) fails again with
|
||||
* [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH
|
||||
* host key.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The gateway presented a certificate the platform doesn't trust and that
|
||||
* doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the
|
||||
* user must confirm. Thrown by [PinningTrustManager] during the handshake;
|
||||
* the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired].
|
||||
*/
|
||||
class TlsFingerprintRequired(
|
||||
val fingerprint: String,
|
||||
) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)")
|
||||
|
||||
/**
|
||||
* SHA-256 of the certificate's DER encoding, colon-separated byte pairs
|
||||
* (SSH host-key style) — the string the user verifies on the gateway host.
|
||||
*/
|
||||
fun certFingerprint(cert: X509Certificate): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(cert.encoded)
|
||||
.joinToString(":") { String.format("%02X", it) }
|
||||
|
||||
/**
|
||||
* Wraps the platform default trust manager:
|
||||
* - CA-signed certs behave exactly as before (the default manager decides).
|
||||
* - A cert the default manager REJECTS is accepted only when its fingerprint
|
||||
* equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh
|
||||
* pin is picked up without rebuilding the client).
|
||||
* - Anything else fails with [TlsFingerprintRequired] carrying the
|
||||
* presented fingerprint, so the UI can offer the confirm dialog.
|
||||
*/
|
||||
class PinningTrustManager(
|
||||
private val pinnedFingerprint: () -> String,
|
||||
) : X509TrustManager {
|
||||
private val default: X509TrustManager = defaultTrustManager()
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) {
|
||||
default.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) {
|
||||
try {
|
||||
default.checkServerTrusted(chain, authType)
|
||||
} catch (e: CertificateException) {
|
||||
val fp = certFingerprint(chain.first())
|
||||
if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return
|
||||
throw TlsFingerprintRequired(fp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The platform default server trust manager (the JDK's CA store). */
|
||||
fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager)
|
||||
?: error("no X509TrustManager in the default trust store")
|
||||
}
|
||||
|
||||
/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */
|
||||
fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory {
|
||||
val ctx = SSLContext.getInstance("TLS")
|
||||
ctx.init(null, arrayOf<TrustManager>(tm), SecureRandom())
|
||||
return ctx.socketFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport
|
||||
* exception: the trust manager throws it during the handshake and the
|
||||
* JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when
|
||||
* the failure has nothing to do with an untrusted gateway certificate.
|
||||
*/
|
||||
fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? {
|
||||
var t: Throwable? = e
|
||||
var depth = 0
|
||||
while (t != null && depth < 10) {
|
||||
if (t is TlsFingerprintRequired) return t
|
||||
t = t.cause
|
||||
depth++
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -6,7 +6,7 @@ package iris.platform
|
||||
* The controller (commonMain) needs to know whether the app is in the
|
||||
* foreground (to decide between an in-app banner and a system notification)
|
||||
* and to post a system notification when a `notification` frame arrives while
|
||||
* the app is backgrounded but the WS is still live.
|
||||
* the app is backgrounded but the gateway connection is still live.
|
||||
*/
|
||||
|
||||
/** True when the app's UI is visible (Android: activity resumed). */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* A "Scan QR" button that launches the platform QR scanner and delivers the
|
||||
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
|
||||
*
|
||||
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
|
||||
* (the Connect screen hides it there).
|
||||
*/
|
||||
@Composable
|
||||
expect fun QrScanButton(onResult: (String?) -> Unit)
|
||||
@@ -12,9 +12,9 @@ import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
|
||||
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
|
||||
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
|
||||
* progress/end, commentary, reasoning (on message / message.stop).
|
||||
* See docs/04-wire-protocol.md. Defines all frame types and payloads:
|
||||
* hello/hello.ack, message (send + streaming), tool cards, commentary,
|
||||
* reasoning, channels, media, notifications, sync, and error frames.
|
||||
*/
|
||||
|
||||
const val PROTOCOL_VERSION = 1
|
||||
@@ -30,13 +30,10 @@ object IrisJson {
|
||||
|
||||
// ── Frame type constants ────────────────────────────────────────────────
|
||||
|
||||
const val TYPE_HELLO = "hello"
|
||||
const val TYPE_HELLO_ACK = "hello.ack"
|
||||
const val TYPE_MESSAGE = "message"
|
||||
const val TYPE_MESSAGE_SEND = "message.send"
|
||||
const val TYPE_ERROR = "error"
|
||||
const val TYPE_PING = "ping"
|
||||
const val TYPE_PONG = "pong"
|
||||
const val TYPE_TYPING = "typing"
|
||||
|
||||
// M2 — streaming / tools / commentary
|
||||
@@ -50,15 +47,16 @@ const val TYPE_MESSAGE_DELETED = "message.deleted"
|
||||
const val TYPE_TOOL_START = "tool.start"
|
||||
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
||||
const val TYPE_TOOL_END = "tool.end"
|
||||
|
||||
// Agent todo list (live planning state; ephemeral, never outboxed — a
|
||||
// reconnecting device re-learns it from the snapshot after hello.ack).
|
||||
const val TYPE_TODO_UPDATE = "todo.update"
|
||||
|
||||
const val TYPE_COMMENTARY = "commentary"
|
||||
|
||||
// M4 — media (upload / offer / pull)
|
||||
const val TYPE_MEDIA_UPLOAD_START = "media.upload.start"
|
||||
const val TYPE_MEDIA_UPLOAD_END = "media.upload.end"
|
||||
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
|
||||
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
||||
const val TYPE_MEDIA_OFFER = "media.offer"
|
||||
const val TYPE_MEDIA_PULL = "media.pull"
|
||||
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||
|
||||
// M5 — push / notifications / read receipt / gateway status
|
||||
const val TYPE_NOTIFICATION = "notification"
|
||||
@@ -83,24 +81,19 @@ const val TYPE_SEARCH_RESULTS = "search.results"
|
||||
|
||||
// Slash-command catalog (the composer's "/" drawer)
|
||||
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
|
||||
|
||||
// Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
|
||||
const val TYPE_PICKER_CHOICE = "picker.choice"
|
||||
const val TYPE_PICKER_SELECT = "picker.select"
|
||||
const val TYPE_SYNC = "sync"
|
||||
const val TYPE_SYNC_DONE = "sync.done"
|
||||
const val TYPE_HISTORY = "history"
|
||||
|
||||
// ── Error codes ─────────────────────────────────────────────────────────
|
||||
|
||||
const val ERR_AUTH = "auth"
|
||||
const val ERR_NOT_FOUND = "not_found"
|
||||
const val ERR_UNSUPPORTED = "unsupported"
|
||||
const val ERR_INTERNAL = "internal"
|
||||
const val ERR_MEDIA_TOO_LARGE = "media_too_large"
|
||||
|
||||
// M4 — media kinds (docs/07 §7.1)
|
||||
const val KIND_IMAGE = "image"
|
||||
const val KIND_AUDIO = "audio"
|
||||
const val KIND_VIDEO = "video"
|
||||
const val KIND_DOCUMENT = "document"
|
||||
const val KIND_VOICE = "voice"
|
||||
|
||||
// ── Roles ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -133,18 +126,6 @@ data class Frame(
|
||||
}
|
||||
}
|
||||
|
||||
// ── hello (app -> server) ───────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class HelloPayload(
|
||||
val token: String,
|
||||
@SerialName("device_id") val deviceId: String,
|
||||
@SerialName("device_name") val deviceName: String,
|
||||
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
|
||||
@SerialName("fcm_token") val fcmToken: String? = null,
|
||||
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||
)
|
||||
|
||||
// ── hello.ack (server -> app) ───────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -195,6 +176,11 @@ data class HelloAckPayload(
|
||||
* push backend (0 = never). Sync-replayed frames at/below it must not
|
||||
* re-post system notifications (dedupe, docs/08 §8.7). */
|
||||
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
|
||||
/** 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). */
|
||||
@SerialName("device_token") val deviceToken: String = "",
|
||||
)
|
||||
|
||||
// ── message (server -> app) ─────────────────────────────────────────────
|
||||
@@ -241,21 +227,6 @@ data class MediaRef(
|
||||
val filename: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaUploadStartPayload(
|
||||
@SerialName("media_ref") val mediaRef: String,
|
||||
val kind: String,
|
||||
val mime: String,
|
||||
val filename: String,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaUploadEndPayload(
|
||||
@SerialName("media_ref") val mediaRef: String,
|
||||
@SerialName("sha256") val sha256: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaUploadAckPayload(
|
||||
val ok: Boolean,
|
||||
@@ -272,16 +243,6 @@ data class MediaOfferPayload(
|
||||
@SerialName("message_id") val messageId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaPullPayload(
|
||||
@SerialName("media_id") val mediaId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaPullEndPayload(
|
||||
val ok: Boolean,
|
||||
)
|
||||
|
||||
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -315,6 +276,9 @@ data class ToolStartPayload(
|
||||
val name: String,
|
||||
val preview: String? = null,
|
||||
val args: JsonElement? = null,
|
||||
/** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji);
|
||||
* absent for unknown tools — the UI falls back to its default. */
|
||||
val emoji: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -333,6 +297,30 @@ data class ToolEndPayload(
|
||||
@SerialName("output_preview") val outputPreview: String? = null,
|
||||
)
|
||||
|
||||
// ── todo.update (server -> app): the agent's live todo list ────────────────
|
||||
|
||||
/** One item of the agent's todo list (hermes `todo` tool). [status] is one
|
||||
* of [TODO_PENDING], [TODO_IN_PROGRESS], [TODO_COMPLETED], [TODO_CANCELLED]. */
|
||||
@Serializable
|
||||
data class TodoItem(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val status: String,
|
||||
)
|
||||
|
||||
const val TODO_PENDING = "pending"
|
||||
const val TODO_IN_PROGRESS = "in_progress"
|
||||
const val TODO_COMPLETED = "completed"
|
||||
const val TODO_CANCELLED = "cancelled"
|
||||
|
||||
/** The agent's FULL current todo list for a lane (last-write-wins; the
|
||||
* gateway emits it whenever the `todo` tool completes — the tool result is
|
||||
* authoritative even for merge writes — and as a snapshot on connect). */
|
||||
@Serializable
|
||||
data class TodoUpdatePayload(
|
||||
val todos: List<TodoItem> = emptyList(),
|
||||
)
|
||||
|
||||
// ── M2: commentary frame (server -> app) ────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -353,7 +341,7 @@ data class MessageSendPayload(
|
||||
@SerialName("auto_thread") val autoThread: Boolean = false,
|
||||
)
|
||||
|
||||
// ── typing / error / ping ───────────────────────────────────────────────
|
||||
// ── typing / error ──────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class TypingPayload(
|
||||
@@ -366,11 +354,6 @@ data class ErrorPayload(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PingPayload(
|
||||
val ts: Long? = null,
|
||||
)
|
||||
|
||||
// ── M3: channel directory (app -> server requests) ──────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -459,6 +442,27 @@ data class CommandsCatalogPayload(
|
||||
val commands: List<SlashCommand> = emptyList(),
|
||||
)
|
||||
|
||||
// ── picker.choice / picker.select (interactive slash-command menus) ────────
|
||||
|
||||
/** One option in a choice picker. [isCurrent] marks the active value (the
|
||||
* UI shows a ✓ on it, like Telegram's inline keyboards). */
|
||||
@Serializable
|
||||
data class PickerChoice(
|
||||
val value: String,
|
||||
val label: String = value,
|
||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
/** An interactive choice picker (one tap → one value), sent by finite-choice
|
||||
* slash commands (/reasoning, /fast, …). The app renders it as a card with
|
||||
* buttons and answers via [pickerSelectFrame]. */
|
||||
@Serializable
|
||||
data class PickerChoicePayload(
|
||||
@SerialName("picker_id") val pickerId: String,
|
||||
val title: String,
|
||||
val choices: List<PickerChoice> = emptyList(),
|
||||
)
|
||||
|
||||
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -508,12 +512,9 @@ data class MessageDeletedPayload(
|
||||
// ── M5: push / notifications ────────────────────────────────────────────
|
||||
|
||||
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
||||
const val NOTIF_MESSAGE = "message"
|
||||
const val NOTIF_APPROVAL = "approval"
|
||||
const val NOTIF_CLARIFY = "clarify"
|
||||
const val NOTIF_CRON = "cron"
|
||||
const val NOTIF_CHANNEL = "channel"
|
||||
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
|
||||
|
||||
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */
|
||||
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
|
||||
@@ -548,28 +549,6 @@ data class StatusPayload(
|
||||
|
||||
// ── Frame builders ──────────────────────────────────────────────────────
|
||||
|
||||
fun helloFrame(
|
||||
token: String,
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
fcmToken: String? = null,
|
||||
ntfyTopic: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_HELLO,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
HelloPayload.serializer(),
|
||||
HelloPayload(
|
||||
token = token,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
fcmToken = fcmToken,
|
||||
ntfyTopic = ntfyTopic,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun messageSendFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
@@ -590,8 +569,6 @@ fun messageSendFrame(
|
||||
),
|
||||
)
|
||||
|
||||
fun pingFrame(): Frame = Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
|
||||
|
||||
// ── M3 frame builders ───────────────────────────────────────────────────
|
||||
|
||||
fun channelCreateFrame(
|
||||
@@ -710,6 +687,24 @@ fun searchFrame(
|
||||
* Answered by a `commands.catalog` frame carrying the same id. */
|
||||
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
|
||||
|
||||
/** Answer an interactive picker (picker.choice) with the chosen value.
|
||||
* The server runs the command's selection callback and delivers its reply
|
||||
* as a normal message in the picker's chat. */
|
||||
fun pickerSelectFrame(
|
||||
id: Int,
|
||||
pickerId: String,
|
||||
value: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_PICKER_SELECT,
|
||||
payload =
|
||||
buildJsonObject {
|
||||
put("picker_id", pickerId)
|
||||
put("value", value)
|
||||
},
|
||||
)
|
||||
|
||||
fun syncFrame(
|
||||
id: Int,
|
||||
cursor: Long,
|
||||
@@ -765,55 +760,6 @@ fun messageDeleteFrame(
|
||||
},
|
||||
)
|
||||
|
||||
// ── M4 frame builders ────────────────────────────────────────────────────
|
||||
|
||||
fun mediaUploadStartFrame(
|
||||
id: Int,
|
||||
mediaRef: String,
|
||||
kind: String,
|
||||
mime: String,
|
||||
filename: String,
|
||||
size: Long,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_UPLOAD_START,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadStartPayload.serializer(),
|
||||
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
|
||||
),
|
||||
)
|
||||
|
||||
fun mediaUploadEndFrame(
|
||||
id: Int,
|
||||
mediaRef: String,
|
||||
sha256: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_UPLOAD_END,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadEndPayload.serializer(),
|
||||
MediaUploadEndPayload(mediaRef, sha256),
|
||||
),
|
||||
)
|
||||
|
||||
fun mediaPullFrame(
|
||||
id: Int,
|
||||
mediaId: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_PULL,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MediaPullPayload.serializer(),
|
||||
MediaPullPayload(mediaId),
|
||||
),
|
||||
)
|
||||
|
||||
// ── M5 frame builders ───────────────────────────────────────────────────
|
||||
|
||||
fun fcmRegisterFrame(
|
||||
|
||||
@@ -8,6 +8,7 @@ import iris.data.MessageItem
|
||||
import iris.data.MsgStatus
|
||||
import iris.data.SecureStore
|
||||
import iris.media.MediaCache
|
||||
import iris.media.isValidMediaId
|
||||
import iris.media.kindFromMime
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.PickedFile
|
||||
@@ -28,6 +29,7 @@ import iris.protocol.MessagePayload
|
||||
import iris.protocol.MessageStopPayload
|
||||
import iris.protocol.NotificationPayload
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.ROLE_USER
|
||||
import iris.protocol.ReadReceiptPayload
|
||||
import iris.protocol.SearchHit
|
||||
import iris.protocol.SearchResultsPayload
|
||||
@@ -44,16 +46,17 @@ import iris.protocol.TYPE_ERROR
|
||||
import iris.protocol.TYPE_HISTORY
|
||||
import iris.protocol.TYPE_MEDIA_OFFER
|
||||
import iris.protocol.TYPE_MESSAGE
|
||||
import iris.protocol.TYPE_MESSAGE_DELETE
|
||||
import iris.protocol.TYPE_MESSAGE_DELETED
|
||||
import iris.protocol.TYPE_MESSAGE_START
|
||||
import iris.protocol.TYPE_MESSAGE_STOP
|
||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||
import iris.protocol.TYPE_NOTIFICATION
|
||||
import iris.protocol.TYPE_PICKER_CHOICE
|
||||
import iris.protocol.TYPE_READ_RECEIPT
|
||||
import iris.protocol.TYPE_SEARCH_RESULTS
|
||||
import iris.protocol.TYPE_STATUS
|
||||
import iris.protocol.TYPE_SYNC_DONE
|
||||
import iris.protocol.TYPE_TODO_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_END
|
||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
@@ -70,6 +73,7 @@ import iris.protocol.channelSetDefaultFrame
|
||||
import iris.protocol.commandsCatalogFrame
|
||||
import iris.protocol.historyFrame
|
||||
import iris.protocol.messageDeleteFrame
|
||||
import iris.protocol.pickerSelectFrame
|
||||
import iris.protocol.searchFrame
|
||||
import iris.protocol.syncFrame
|
||||
import iris.ui.theme.Backdrop
|
||||
@@ -78,6 +82,7 @@ import iris.ui.theme.UserTheme
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -85,6 +90,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@@ -133,19 +140,67 @@ class IrisController(
|
||||
}
|
||||
|
||||
/** Home channel id (from hello.ack; default until then). */
|
||||
private val _homeChannel = MutableStateFlow("android:default")
|
||||
private val _homeChannel = MutableStateFlow("default")
|
||||
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
||||
|
||||
/** Lanes whose full history has been loaded this session (in-memory; reset
|
||||
* on a process death, which is exactly when a reload is needed). The
|
||||
* `sync` delta can seed a lane with recent frames without it being opened,
|
||||
* so "lane is empty" is not a reliable first-open signal. */
|
||||
private val historyLoaded = mutableSetOf<String>()
|
||||
* so "lane is empty" is not a reliable first-open signal. Synchronized:
|
||||
* written by the frame collector, read by the UI thread (loadHistory). */
|
||||
private val historyLoaded = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
/** Gateway health state (M5: status frame; null = never received). */
|
||||
/** Gateway health state (M5: status frame; null = never received).
|
||||
* Note: "restarting" is deliberately NOT stored here — it posts the
|
||||
* chat notice immediately (see TYPE_STATUS) instead of showing a banner. */
|
||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
||||
|
||||
// ── M8: unread indicator ──────────────────────────────────────────────
|
||||
|
||||
/** True while the current lane's newest content sits at the bottom of the
|
||||
* viewport (set by the UI from its scroll state). Used to decide whether
|
||||
* an incoming message in the current lane is "being read" (not unread).
|
||||
* @Volatile: written on the UI thread, read on the frame-handler
|
||||
* coroutine (Dispatchers.Default). */
|
||||
@Volatile
|
||||
var currentLaneAtBottom: Boolean = true
|
||||
|
||||
/** App foreground state (M8: clear the current lane's unread when the app
|
||||
* is focused and the user is at the bottom). Mirrors the platform bridge
|
||||
* ([isAppForeground]); the bridges push changes via [setForeground]. */
|
||||
private val _foreground = MutableStateFlow(isAppForeground())
|
||||
val foreground: StateFlow<Boolean> = _foreground.asStateFlow()
|
||||
|
||||
/** Bridge entry point: the platform shell reports a focus change. */
|
||||
fun setForeground(fg: Boolean) {
|
||||
_foreground.value = fg
|
||||
}
|
||||
|
||||
/** 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
|
||||
* current lane, the app is focused, and the newest content is at the
|
||||
* bottom of the viewport). */
|
||||
private fun noteIncomingAssistantMessage(lane: String) {
|
||||
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
|
||||
if (!beingRead) chat.markUnread(lane)
|
||||
}
|
||||
|
||||
/** M8: the user is now viewing the current lane's newest content — clear
|
||||
* its unread count. */
|
||||
fun markCurrentLaneRead() {
|
||||
chat.markLaneRead(chat.currentLane.value)
|
||||
}
|
||||
|
||||
// Latches the restart pair: set when the "restarting" notice is posted
|
||||
// (on the gateway's status{restarting} frame), consumed by the matching
|
||||
// "online" notice on the next reconnect. A plain network drop never sets
|
||||
// it, so it never produces an "online" notice. @Volatile: written on the
|
||||
// frame-handler coroutine, read on the state-collector coroutine
|
||||
// (Dispatchers.Default).
|
||||
@Volatile
|
||||
private var restartAnnounced = false
|
||||
|
||||
/** M5: highest outbox cursor already delivered to this device via the
|
||||
* push backend (from hello.ack; 0 = never). Sync-replayed frames with
|
||||
* `cursor <= lastPushedCursor` already woke the device via push, so the
|
||||
@@ -158,6 +213,37 @@ class IrisController(
|
||||
* via push (live frames carry no cursor and are never suppressed). */
|
||||
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
|
||||
|
||||
/** M5: highest outbox cursor this device has CONSUMED (applied from the
|
||||
* event stream). The stream resumes from [store.syncCursor] on every
|
||||
* (re)connect, so this high-water mark is persisted there (debounced +
|
||||
* on backgrounding): without it, a restart re-delivers every frame
|
||||
* consumed since the last `sync.done` — and replayed `notification`
|
||||
* frames re-showed their banner on every app reopen (docs/08 §8.4).
|
||||
* @Volatile: written on the frame-handler coroutine, read on the
|
||||
* foreground/state collectors. */
|
||||
@Volatile
|
||||
private var consumedCursor: Long = store.syncCursor
|
||||
|
||||
/** Debounced persist of [consumedCursor] (one store write per burst,
|
||||
* not per frame). */
|
||||
private var cursorSaveJob: Job? = null
|
||||
|
||||
private fun persistCursorDebounced() {
|
||||
cursorSaveJob?.cancel()
|
||||
cursorSaveJob =
|
||||
scope.launch {
|
||||
delay(CACHE_SAVE_DEBOUNCE_MS)
|
||||
// maxOf: sync.done may have advanced the store past us.
|
||||
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush [consumedCursor] to the store now (app backgrounding / exit). */
|
||||
private fun persistCursorNow() {
|
||||
cursorSaveJob?.cancel()
|
||||
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
|
||||
}
|
||||
|
||||
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
||||
// Persisted (Settings → "Threads").
|
||||
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
|
||||
@@ -217,6 +303,9 @@ class IrisController(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Max simultaneous banners; persistent ones are exempt from the cap. */
|
||||
private const val MAX_BANNERS = 5
|
||||
|
||||
const val FONT_SCALE_MIN = 0.8f
|
||||
const val FONT_SCALE_MAX = 1.5f
|
||||
|
||||
@@ -328,8 +417,12 @@ class IrisController(
|
||||
// ── M4: media ─────────────────────────────────────────────────────────
|
||||
private val mediaCache = MediaCache(mediaCacheBaseDir())
|
||||
|
||||
/** A composer attachment: picked file being uploaded (or uploaded). */
|
||||
/** A composer attachment: picked file being uploaded (or uploaded).
|
||||
* [id] is a per-attachment identity used to apply the upload result to
|
||||
* the right placeholder — matching by filename would cross-update two
|
||||
* files picked with the same name (M-6). */
|
||||
data class PendingAttachment(
|
||||
val id: String,
|
||||
val filename: String,
|
||||
val mime: String,
|
||||
val size: Long,
|
||||
@@ -358,7 +451,10 @@ class IrisController(
|
||||
|
||||
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
|
||||
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
|
||||
private var bannerSeq = 0L
|
||||
|
||||
// Atomic: pushBanner can be called from the frame collector and the UI
|
||||
// thread; a lost update would mint a duplicate banner id.
|
||||
private val bannerSeq = AtomicLong(0)
|
||||
|
||||
fun dismissBanner(id: Long) {
|
||||
_banners.value = _banners.value.filterNot { it.id == id }
|
||||
@@ -372,8 +468,13 @@ class IrisController(
|
||||
threadId: String?,
|
||||
) {
|
||||
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
|
||||
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent)
|
||||
_banners.value = (_banners.value + banner).takeLast(5)
|
||||
val banner = Banner(bannerSeq.getAndIncrement(), kind, title, body, chatId, threadId, persistent)
|
||||
// Persistent banners (approval / clarify / cron) are never evicted by
|
||||
// the cap; only the transient ones compete for the remaining slots.
|
||||
val merged = _banners.value + banner
|
||||
val keptPersistent = merged.filter { it.persistent }
|
||||
val room = (MAX_BANNERS - keptPersistent.size).coerceAtLeast(0)
|
||||
_banners.value = keptPersistent + merged.filterNot { it.persistent }.takeLast(room)
|
||||
if (!persistent) {
|
||||
scope.launch {
|
||||
delay(5_000)
|
||||
@@ -396,7 +497,7 @@ class IrisController(
|
||||
) {
|
||||
if (isAppForeground()) return
|
||||
if (text.isBlank()) return
|
||||
val id = chatId ?: "android:default"
|
||||
val id = chatId ?: "default"
|
||||
val chatName = channels.byId(id)?.name
|
||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||
}
|
||||
@@ -450,17 +551,55 @@ class IrisController(
|
||||
scope.launch {
|
||||
chat.currentLane.debounce(CACHE_SAVE_DEBOUNCE_MS).collect { chatDb.metaPut(META_LAST_LANE, it) }
|
||||
}
|
||||
// M8: when the app returns to the foreground and the user is at the
|
||||
// bottom of the current lane, its newest content is on screen — clear
|
||||
// any unread that accumulated while backgrounded. (The UI separately
|
||||
// clears on scroll-to-bottom while already focused.)
|
||||
scope.launch {
|
||||
foreground.collect { fg ->
|
||||
if (!fg) persistCursorNow()
|
||||
if (fg && currentLaneAtBottom) markCurrentLaneRead()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
client.events.collect { frame ->
|
||||
try {
|
||||
// Consume the frame's outbox cursor BEFORE dispatching:
|
||||
// a frame that arrives a second time (SSE catch-up and
|
||||
// the explicit sync replay both carry the cursor) must
|
||||
// not re-notify — only its first consumption may.
|
||||
val cursor = frame.cursor
|
||||
val alreadyConsumed = cursor != null && cursor <= consumedCursor
|
||||
if (cursor != null && cursor > consumedCursor) {
|
||||
consumedCursor = cursor
|
||||
persistCursorDebounced()
|
||||
}
|
||||
when (frame.type) {
|
||||
TYPE_TOOL_START,
|
||||
TYPE_TOOL_PROGRESS,
|
||||
TYPE_TOOL_END,
|
||||
-> {
|
||||
// Tool cards are restored from the local cache
|
||||
// (anchored to their message); they are not part
|
||||
// of `history`. A sync replay (frame carries a
|
||||
// cursor) would create duplicate cards appended
|
||||
// AFTER the lane's restored messages — drop
|
||||
// them. Live tool frames (no cursor) flow through
|
||||
// as usual.
|
||||
if (frame.cursor == null) chat.onFrame(frame)
|
||||
}
|
||||
|
||||
TYPE_TODO_UPDATE -> {
|
||||
// The agent's live todo list (last-write-wins). Ephemeral:
|
||||
// never outboxed, so it never carries a cursor and is
|
||||
// always applied (a reconnect snapshot just re-sets it).
|
||||
chat.onFrame(frame)
|
||||
}
|
||||
|
||||
TYPE_MESSAGE,
|
||||
TYPE_MESSAGE_START,
|
||||
TYPE_MESSAGE_UPDATE,
|
||||
TYPE_MESSAGE_STOP,
|
||||
TYPE_TOOL_START,
|
||||
TYPE_TOOL_PROGRESS,
|
||||
TYPE_TOOL_END,
|
||||
TYPE_COMMENTARY,
|
||||
TYPE_MEDIA_OFFER,
|
||||
-> {
|
||||
@@ -478,7 +617,13 @@ class IrisController(
|
||||
when (frame.type) {
|
||||
TYPE_MESSAGE_STOP -> {
|
||||
frame.payloadAs<MessageStopPayload>()?.let {
|
||||
if (!isPushedReplay(frame)) {
|
||||
// M8: a finalized streaming reply is new
|
||||
// content — count it as unread unless the
|
||||
// user is reading this lane right now.
|
||||
frame.chatId?.let { cid ->
|
||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
||||
}
|
||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||
}
|
||||
}
|
||||
@@ -486,8 +631,14 @@ class IrisController(
|
||||
|
||||
TYPE_MESSAGE -> {
|
||||
frame.payloadAs<MessagePayload>()?.let {
|
||||
if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||
if (it.role == ROLE_ASSISTANT) {
|
||||
// M8: a finalized (non-streaming) reply.
|
||||
frame.chatId?.let { cid ->
|
||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
||||
}
|
||||
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,14 +698,25 @@ class IrisController(
|
||||
_searching.value = false
|
||||
}
|
||||
|
||||
TYPE_PICKER_CHOICE -> {
|
||||
// Interactive slash-command menu (/reasoning, /fast, …):
|
||||
// rendered as a tappable card in the lane.
|
||||
chat.onFrame(frame)
|
||||
}
|
||||
|
||||
TYPE_COMMANDS_CATALOG -> {
|
||||
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
|
||||
}
|
||||
|
||||
TYPE_SYNC_DONE -> {
|
||||
// Replayed frames already flowed through [events]; the
|
||||
// cursor is authoritative server-side (outbox).
|
||||
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
|
||||
// cursor is authoritative server-side (outbox). Keep the
|
||||
// consumed high-water mark in step (it may be ahead of
|
||||
// us — live frames consumed after the replay started).
|
||||
frame.payloadAs<SyncDonePayload>()?.let {
|
||||
consumedCursor = maxOf(consumedCursor, it.cursor)
|
||||
store.syncCursor = maxOf(store.syncCursor, it.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
TYPE_HISTORY -> {
|
||||
@@ -579,25 +741,35 @@ class IrisController(
|
||||
)
|
||||
// Mark the lane loaded only when the response
|
||||
// is actually processed: if the request or
|
||||
// response is lost in a WS drop, the lane
|
||||
// response is lost in a connection drop, the lane
|
||||
// stays unmarked and the next (re)connect
|
||||
// retries it.
|
||||
historyLoaded.add(lane)
|
||||
// Offline sends that never arrived go out
|
||||
// now (delivered duplicates were dropped by
|
||||
// loadHistory's dedupe above).
|
||||
reconcileFailedSends(lane)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TYPE_NOTIFICATION -> {
|
||||
frame.payloadAs<NotificationPayload>()?.let { p ->
|
||||
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||
// M5: WS is live but the app is backgrounded — the
|
||||
// in-app banner is invisible, so mirror to a system
|
||||
// notification (the push backend only fires when
|
||||
// there is no live subscriber). Suppressed for
|
||||
// sync replays that already woke the device via
|
||||
// push (docs/08 §8.7).
|
||||
if (!isAppForeground() && !isPushedReplay(frame)) {
|
||||
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
|
||||
// alreadyConsumed: this frame was applied earlier
|
||||
// (its cursor is at/below the high-water mark) —
|
||||
// a duplicate delivery (restart catch-up or the
|
||||
// sync replay) must not re-show the banner.
|
||||
if (!alreadyConsumed) {
|
||||
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||
// M5: the connection is live but the app is backgrounded — the
|
||||
// in-app banner is invisible, so mirror to a system
|
||||
// notification (the push backend only fires when
|
||||
// there is no live subscriber). Suppressed for
|
||||
// sync replays that already woke the device via
|
||||
// push (docs/08 §8.7).
|
||||
if (!isAppForeground() && !isPushedReplay(frame)) {
|
||||
postSystemNotification(p.chatId, null, p.title, p.body, p.threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,7 +791,22 @@ class IrisController(
|
||||
}
|
||||
|
||||
TYPE_STATUS -> {
|
||||
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
|
||||
frame.payloadAs<StatusPayload>()?.let { st ->
|
||||
if (st.state == "restarting") {
|
||||
// Gateway is going down (restart/stop):
|
||||
// post the notice IMMEDIATELY — the
|
||||
// connection can take up to the ping timeout
|
||||
// (~20 s) to actually drop, and waiting
|
||||
// for that transition would delay the
|
||||
// message. No banner for this state: the
|
||||
// chat notice replaces it (the reconnect
|
||||
// banner covers the wait).
|
||||
restartAnnounced = true
|
||||
chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG)
|
||||
} else {
|
||||
_gatewayStatus.value = st.state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TYPE_ERROR -> {
|
||||
@@ -647,8 +834,12 @@ class IrisController(
|
||||
val prev = prevState
|
||||
prevState = s
|
||||
if (s is GatewayClient.State.Connected) {
|
||||
// Clear any stale "restarting" latch from the previous
|
||||
// down phase (the gateway's own status{online} frame
|
||||
// follows on hello.ack and re-asserts the truth).
|
||||
_gatewayStatus.value = "online"
|
||||
// The lane/history fast path runs on [client.onHelloAck]
|
||||
// (promptly, on the WS thread) — see onConnectedLane. Here
|
||||
// (promptly, on the SSE thread) — see onConnectedLane. Here
|
||||
// we do the non-time-critical connect work.
|
||||
// M5: refresh the push-dedupe watermark (docs/08 §8.7).
|
||||
lastPushedCursor = s.lastPushedCursor
|
||||
@@ -666,22 +857,20 @@ class IrisController(
|
||||
// Slash-command catalog for the composer's "/" drawer
|
||||
// (static per gateway run; re-fetched on every (re)connect).
|
||||
requestCommandsCatalog()
|
||||
// Gateway came back after a restart -> announce it (hermes
|
||||
// routine, same icon + wording on all platforms). The core
|
||||
// does not send a startup/online notice to this platform, so
|
||||
// the app adds it.
|
||||
if (prev is GatewayClient.State.Reconnecting) {
|
||||
// Gateway is back from a RESTART (not just a network
|
||||
// drop) -> post the second half of the restart pair.
|
||||
if (restartAnnounced) {
|
||||
restartAnnounced = false
|
||||
chat.addSystemMessage(homeChannel.value, GATEWAY_ONLINE_MSG)
|
||||
}
|
||||
} else if (s is GatewayClient.State.Reconnecting && prev is GatewayClient.State.Connected) {
|
||||
// Gateway went away (restart / network drop): announce it
|
||||
// (hermes routine, same icon + wording on all platforms) and
|
||||
// close the in-flight turn's dangling tool cards / streaming
|
||||
// bubble (nothing spins forever). The app is the source of
|
||||
// truth for the "restarting" notice (the server's commentary
|
||||
// frame is dropped in ChatStore), so the order is guaranteed:
|
||||
// restarting (here) before online (on reconnect).
|
||||
chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG)
|
||||
// Gateway went away. The restart notice was already
|
||||
// posted on the status{restarting} frame (immediately,
|
||||
// not on this transition — the socket can take ~20 s to
|
||||
// drop); a plain network drop posts nothing, the
|
||||
// reconnect banner + status bubble cover it. Here we
|
||||
// just close the in-flight turn's dangling tool cards /
|
||||
// streaming bubble (nothing spins forever).
|
||||
chat.finalizeInterrupted()
|
||||
}
|
||||
}
|
||||
@@ -690,17 +879,21 @@ class IrisController(
|
||||
if (store.ntfyTopic.isBlank()) {
|
||||
store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
}
|
||||
client.startHeartbeat()
|
||||
// Prompt fast path: seed the channel directory + load the active lane's
|
||||
// history the moment hello.ack lands (on the WS thread), not after the
|
||||
// state collector (which can be starved for seconds on startup). This
|
||||
// gets the history request out early so its response lands inside a
|
||||
// flaky network's window.
|
||||
client.onHelloAck = { connected ->
|
||||
try {
|
||||
onConnectedLane(connected)
|
||||
// Auto-resend offline sends AFTER the outbox replay has been
|
||||
// processed: replayed frames precede the hello on the stream,
|
||||
// but the frame collector may still be draining them — a
|
||||
// delivered message whose POST response was lost must
|
||||
// reconcile (echo replaces the failed bubble) before we
|
||||
// decide to resend it.
|
||||
scope.launch {
|
||||
delay(2_000)
|
||||
reconcileAllFailedSends()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Must not throw on the WS thread (would break the connection).
|
||||
// Must not throw on the SSE thread (would break the connection).
|
||||
IrisLog.e("onConnectedLane failed: $e")
|
||||
}
|
||||
}
|
||||
@@ -708,7 +901,7 @@ class IrisController(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast-path connect handler (runs on the WS thread via [GatewayClient.onHelloAck],
|
||||
* Fast-path connect handler (runs on the gateway callback thread via [GatewayClient.onHelloAck],
|
||||
* promptly on every (re)connect). Seeds the channel directory and loads the
|
||||
* active lane's full history. The lane is the last-viewed one (restored from
|
||||
* the local cache) if it still exists on the server, else the home channel.
|
||||
@@ -717,7 +910,10 @@ class IrisController(
|
||||
* refreshes (skipped on a plain reconnect via historyLoaded).
|
||||
*/
|
||||
private fun onConnectedLane(connected: GatewayClient.State.Connected) {
|
||||
channels.setAll(connected.channels)
|
||||
// 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.
|
||||
if (connected.channels.isNotEmpty()) channels.setAll(connected.channels)
|
||||
val home = connected.channels.firstOrNull { it.isDefault }?.chatId
|
||||
_homeChannel.value = home ?: ChatStore.DEFAULT_LANE
|
||||
if (home == null) return
|
||||
@@ -755,6 +951,18 @@ class IrisController(
|
||||
|
||||
// ── M3: channel directory ops (server is authoritative) ───────────────
|
||||
|
||||
/** Answer an interactive choice picker (picker.choice card). Marks the
|
||||
* card resolved locally (optimistic) and sends picker.select; the
|
||||
* server's reply arrives as a normal message. An expired picker
|
||||
* (gateway restart) simply never replies. */
|
||||
fun selectPicker(
|
||||
pickerId: String,
|
||||
value: String,
|
||||
) {
|
||||
chat.resolvePicker(pickerId, value)
|
||||
client.sendFrame(pickerSelectFrame(0, pickerId, value))
|
||||
}
|
||||
|
||||
fun createChannel(name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
@@ -780,6 +988,7 @@ class IrisController(
|
||||
}
|
||||
|
||||
fun setDefaultChannel(chatId: String) {
|
||||
channels.setDefault(chatId)
|
||||
client.sendFrame(channelSetDefaultFrame(0, chatId))
|
||||
}
|
||||
|
||||
@@ -788,6 +997,7 @@ class IrisController(
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
) {
|
||||
channels.setFavorite(chatId, on)
|
||||
client.sendFrame(channelFavoriteFrame(0, chatId, on))
|
||||
}
|
||||
|
||||
@@ -797,6 +1007,7 @@ class IrisController(
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
) {
|
||||
channels.setAutomation(chatId, on)
|
||||
client.sendFrame(channelSetAutomationFrame(0, chatId, on))
|
||||
}
|
||||
|
||||
@@ -807,6 +1018,7 @@ class IrisController(
|
||||
icon: String?,
|
||||
color: String?,
|
||||
) {
|
||||
channels.setIcon(chatId, icon, color)
|
||||
client.sendFrame(channelIconFrame(0, chatId, icon, color))
|
||||
}
|
||||
|
||||
@@ -865,9 +1077,10 @@ class IrisController(
|
||||
val lane = chat.laneKey(chatId, threadId)
|
||||
if (lane in historyLoaded) return
|
||||
// Newest page, sized to restore a full working view on restart / first
|
||||
// open (older pages are reachable via scroll-up pagination). The lane
|
||||
// latest page only (older pages are not paginated in the current UI).
|
||||
// The lane
|
||||
// is marked loaded when the response arrives (TYPE_HISTORY), not here —
|
||||
// a request lost in a WS drop must be retryable on reconnect.
|
||||
// a request lost in a connection drop must be retryable on reconnect.
|
||||
client.sendFrame(historyFrame(0, chatId, threadId, limit = 200))
|
||||
}
|
||||
|
||||
@@ -893,11 +1106,36 @@ class IrisController(
|
||||
localPath = it.path,
|
||||
)
|
||||
}
|
||||
chat.addPending(trimmed, lane, media)
|
||||
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId))
|
||||
val messageId = chat.addPending(trimmed, lane, media)
|
||||
client.sendMessage(
|
||||
chatId,
|
||||
trimmed,
|
||||
threadId,
|
||||
refs,
|
||||
autoThread = wantsAutoThread(trimmed, threadId, chatId),
|
||||
onResult = { status -> onSendResult(messageId, status) },
|
||||
)
|
||||
_attachments.value = emptyList()
|
||||
}
|
||||
|
||||
/** POST result for an optimistic send: 2xx = accepted (the echo
|
||||
* reconciles the bubble); 0 = no response (offline / network failure) —
|
||||
* keep the bubble QUEUED (Pending) and remember it: it goes out on the
|
||||
* next (re)connect, so the user can compose and send while the network
|
||||
* is down; 4xx = gateway rejection — fail the bubble (tap to retry),
|
||||
* no auto-retry (the gateway said no). */
|
||||
private fun onSendResult(
|
||||
messageId: String,
|
||||
status: Int,
|
||||
) {
|
||||
if (status in 200..299) return
|
||||
if (status == 0) {
|
||||
networkFailed.add(messageId)
|
||||
} else {
|
||||
chat.failMessage(messageId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the
|
||||
* default channel's flat lane gets its own fresh thread, AI-named by the
|
||||
* gateway (Telegram topic-mode workflow). Threading is only active on
|
||||
@@ -927,9 +1165,53 @@ class IrisController(
|
||||
threadId,
|
||||
item.media.map { it.mediaId },
|
||||
autoThread = wantsAutoThread(item.text, threadId, chatId),
|
||||
onResult = { status -> onSendResult(messageId, status) },
|
||||
)
|
||||
}
|
||||
|
||||
/** User message ids that failed for NETWORK reasons (status 0 — not a
|
||||
* gateway error frame): queued (Pending) or failed bubbles that go out
|
||||
* automatically on the next (re)connect. In-memory only — a process
|
||||
* death leaves them as tap-to-retry (the local cache restore already
|
||||
* marks pending sends failed). Synchronized: written by the send-result
|
||||
* callback (UI thread) and the frame collector. */
|
||||
private val networkFailed = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
/** Resend queued/failed user messages of [lane] now that the link is
|
||||
* back: a message the server already has (the POST response was lost in
|
||||
* the drop) was reconciled by the echo / loadHistory dedupe, so anything
|
||||
* still queued or Failed here never arrived — send it. */
|
||||
private fun reconcileFailedSends(lane: String) {
|
||||
val items = chat.lanes.value[lane] ?: return
|
||||
for (item in items) {
|
||||
if (item !is MessageItem || item.role != ROLE_USER || item.id !in networkFailed) {
|
||||
continue
|
||||
}
|
||||
if (item.status != MsgStatus.Pending && item.status != MsgStatus.Failed) {
|
||||
continue
|
||||
}
|
||||
networkFailed.remove(item.id)
|
||||
chat.rearmForRetry(lane, item.id) // no-op for queued (already Pending)
|
||||
val (chatId, threadId) = chat.parseLane(lane)
|
||||
client.sendMessage(
|
||||
chatId,
|
||||
item.text,
|
||||
threadId,
|
||||
item.media.map { it.mediaId },
|
||||
autoThread = wantsAutoThread(item.text, threadId, chatId),
|
||||
onResult = { status -> onSendResult(item.id, status) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile every lane (offline sends may sit in any lane). */
|
||||
private fun reconcileAllFailedSends() {
|
||||
if (networkFailed.isEmpty()) return
|
||||
for (lane in chat.lanes.value.keys) {
|
||||
reconcileFailedSends(lane)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the given message(s) from the current lane (long-press select →
|
||||
* delete). The server removes them from the outbox and broadcasts
|
||||
* `message.deleted`; the local cache drops them on that frame (or
|
||||
@@ -947,8 +1229,13 @@ class IrisController(
|
||||
/** Stage a picked file: upload it, then keep it as a pending attachment. */
|
||||
fun attachFile(picked: PickedFile) {
|
||||
val kind = kindFromMime(picked.mime)
|
||||
// Per-attachment identity: the upload result is applied to THIS
|
||||
// placeholder by id, so two files with the same name don't
|
||||
// cross-update (M-6).
|
||||
val id = "att_${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
val placeholder =
|
||||
PendingAttachment(
|
||||
id = id,
|
||||
filename = picked.name,
|
||||
mime = picked.mime,
|
||||
size = picked.size,
|
||||
@@ -968,7 +1255,7 @@ class IrisController(
|
||||
)
|
||||
_attachments.value =
|
||||
_attachments.value.map {
|
||||
if (it.filename == picked.name && it.uploading) {
|
||||
if (it.id == id && it.uploading) {
|
||||
result.fold(
|
||||
{ ref -> it.copy(uploading = false, mediaRef = ref) },
|
||||
{ e -> it.copy(uploading = false, error = e.message) },
|
||||
@@ -987,6 +1274,12 @@ class IrisController(
|
||||
/** Pull offered media into the local cache and record the path. */
|
||||
private fun pullMedia(offer: MediaOfferPayload) {
|
||||
scope.launch {
|
||||
// Server-controlled id: reject path-traversal values before they
|
||||
// reach the cache or the pull URL (M-2 / S-1).
|
||||
if (!isValidMediaId(offer.mediaId)) {
|
||||
IrisLog.w("rejecting media offer with invalid id: ${offer.mediaId.take(40)}")
|
||||
return@launch
|
||||
}
|
||||
// Already cached? Skip the pull.
|
||||
mediaCache.path(offer.mediaId, offer.mime)?.let {
|
||||
chat.setMediaLocalPath(offer.mediaId, it)
|
||||
@@ -1014,6 +1307,14 @@ class IrisController(
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/** TLS fingerprint confirm (docs/09 §9.4): pin the gateway's presented
|
||||
* certificate so its self-signed cert is accepted from now on. The
|
||||
* caller re-runs [connect] (or the connect loop picks the pin up on its
|
||||
* next attempt — the trust manager reads the store live). */
|
||||
fun confirmTlsFingerprint(fingerprint: String) {
|
||||
store.pinnedCertFingerprint = fingerprint
|
||||
}
|
||||
|
||||
fun forget() {
|
||||
store.clear()
|
||||
// A different gateway means a different chat universe — wipe the cache.
|
||||
@@ -1025,11 +1326,13 @@ class IrisController(
|
||||
|
||||
fun dispose() {
|
||||
client.stop()
|
||||
// Final synchronous flush so the newest frames survive the process
|
||||
// death (the debounce window may still hold unsaved changes).
|
||||
// M-17: cancel the debounce job FIRST so a pending save can't fire
|
||||
// after our final flush and clobber it; then do the final synchronous
|
||||
// flush so the newest frames survive the process death (the debounce
|
||||
// window may still hold unsaved changes).
|
||||
job.cancel()
|
||||
chatDb.saveLanes(chat.lanes.value)
|
||||
chatDb.saveChannels(channels.channels.value)
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1050,12 +1353,15 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
|
||||
},
|
||||
)
|
||||
|
||||
// Gateway restart routine (hermes, same icon + wording on all platforms). The
|
||||
// app generates both notices locally, on the down/up state transition, so the
|
||||
// order is guaranteed (restarting before online) and there is no dependency on
|
||||
// the server frame (which may be dropped on shutdown or replayed out of order
|
||||
// by the sync catch-up). The core does not send a startup/online notice to this
|
||||
// platform, and its "restarting" commentary frame is dropped in ChatStore.
|
||||
// Gateway restart pair (hermes wording, same icon on all platforms). The app
|
||||
// generates both notices locally: "restarting" IMMEDIATELY on the gateway's
|
||||
// explicit status{state=restarting} frame (broadcast on its shutdown path —
|
||||
// not on the socket-drop transition, which can lag by the ~20 s ping
|
||||
// timeout) so a plain network drop doesn't claim a restart; and "online" on
|
||||
// the next reconnect, only when the "restarting" notice was posted (the
|
||||
// restartAnnounced latch). A plain network drop produces neither —
|
||||
// connection state is shown by the banner + status bubble only. The server's
|
||||
// lifecycle commentary frames are dropped in ChatStore.
|
||||
private const val GATEWAY_RESTARTING_MSG =
|
||||
"⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off."
|
||||
private const val GATEWAY_ONLINE_MSG =
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,10 +11,12 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -24,11 +26,16 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.net.TlsFingerprintRequired
|
||||
import iris.platform.QrScanButton
|
||||
import iris.platform.isDesktop
|
||||
import iris.state.IrisController
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.util.PairLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -41,21 +48,49 @@ fun ConnectScreen(
|
||||
prefillUrl: String = "",
|
||||
prefillToken: String = "",
|
||||
initialError: String? = null,
|
||||
/** Gateway certificate fingerprint awaiting user confirmation (docs/09
|
||||
* §9.4) — shown as a confirm dialog on entry. */
|
||||
tlsFingerprint: String? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
||||
// the LAN. A TLS gateway is reached by entering a secure (wss) URL instead.
|
||||
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
|
||||
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
|
||||
// the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
|
||||
var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
|
||||
var token by remember { mutableStateOf(prefillToken) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(initialError) }
|
||||
// Fingerprint the user still has to confirm (self-signed gateway cert,
|
||||
// docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a
|
||||
// connect attempt fails with an untrusted certificate.
|
||||
var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) }
|
||||
|
||||
// "Test & Connect" (also the dialog's confirm action): real hello test,
|
||||
// then save + (re)connect. An untrusted gateway certificate surfaces as
|
||||
// the fingerprint confirm dialog instead of a plain error.
|
||||
fun doConnect() {
|
||||
if (busy) return
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = controller.connect(url.trim(), token.trim())
|
||||
busy = false
|
||||
if (result.isFailure) {
|
||||
val ex = result.exceptionOrNull()
|
||||
if (ex is TlsFingerprintRequired) {
|
||||
pendingFingerprint = ex.fingerprint
|
||||
} else {
|
||||
error = ex?.message ?: "connection failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
@@ -69,19 +104,19 @@ fun ConnectScreen(
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(IrisColors.surface)
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(IrisColors.surface)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("Server URL") },
|
||||
// Example LAN URL; wss:// works too for TLS gateways.
|
||||
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
|
||||
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
||||
// Example LAN URL; https:// works too for TLS gateways.
|
||||
placeholder = { Text("http://192.168.1.10:8791") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -91,26 +126,29 @@ fun ConnectScreen(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Pairing token") },
|
||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||
placeholder = { Text("IRIS_TOKEN (64 hex)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (!isDesktop) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
QrScanButton { raw ->
|
||||
if (raw != null) {
|
||||
val link = PairLink.parse(raw)
|
||||
if (link != null) {
|
||||
url = link.url
|
||||
token = link.token
|
||||
} else {
|
||||
error = "Couldn't read that QR code."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (busy) return@Button
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = controller.connect(url.trim(), token.trim())
|
||||
busy = false
|
||||
if (result.isFailure) {
|
||||
error = result.exceptionOrNull()?.message ?: "connection failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { doConnect() },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
@@ -129,11 +167,49 @@ fun ConnectScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
|
||||
"Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
|
||||
"hermes gateway setup on the gateway host.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate
|
||||
// this device doesn't trust (self-signed). The user verifies the
|
||||
// fingerprint on the gateway host, then confirms — the pin is stored in
|
||||
// secure storage and the connect is retried, like an SSH host key.
|
||||
if (pendingFingerprint != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingFingerprint = null },
|
||||
title = { Text("Confirm gateway certificate") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"The gateway presents a certificate this device doesn't trust. " +
|
||||
"Verify the fingerprint on the gateway host, then confirm to pin it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
pendingFingerprint!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val fp = pendingFingerprint!!
|
||||
pendingFingerprint = null
|
||||
controller.confirmTlsFingerprint(fp)
|
||||
doConnect()
|
||||
},
|
||||
) { Text("Confirm & pin") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ fun SettingsScreen(
|
||||
val fontSizeScale by controller.fontSizeScale.collectAsState()
|
||||
val theme = LocalUserTheme.current
|
||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showForgetConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
||||
Column(
|
||||
@@ -391,6 +392,24 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Connection",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
SettingsCard {
|
||||
Text("🔌 Forget pairing", fontSize = 14.sp)
|
||||
Text(
|
||||
"Clears the gateway token and wipes local chat history",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = { showForgetConfirm = true }) {
|
||||
Text("Forget pairing", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (pickerTarget) {
|
||||
@@ -437,6 +456,32 @@ fun SettingsScreen(
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
if (showForgetConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showForgetConfirm = false },
|
||||
title = { Text("Forget pairing?") },
|
||||
text = {
|
||||
Text(
|
||||
"This clears the gateway token and wipes all local chat history on this device. You'll need to pair again to reconnect.",
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showForgetConfirm = false
|
||||
controller.forget()
|
||||
}) {
|
||||
Text("Forget")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showForgetConfirm = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package iris.ui.theme
|
||||
|
||||
/**
|
||||
* A bundled backdrop image shipped in the app (Android `assets/backdrops/`,
|
||||
* desktop classpath `backdrops/`), loaded via [iris.platform.readBackdropBytes].
|
||||
* A bundled backdrop image shipped in the app (Android: `androidApp` module
|
||||
* `assets/backdrops/`; desktop: classpath `backdrops/`), loaded via
|
||||
* [iris.platform.readBackdropBytes].
|
||||
*
|
||||
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
|
||||
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
|
||||
@@ -18,9 +19,12 @@ data class Backdrop(
|
||||
companion object {
|
||||
const val PATH_PREFIX = "backdrop://"
|
||||
|
||||
/** Default background for new chats / fresh installs. */
|
||||
val DEFAULT = Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli")
|
||||
|
||||
val ALL: List<Backdrop> =
|
||||
listOf(
|
||||
Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli"),
|
||||
DEFAULT,
|
||||
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
|
||||
Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
|
||||
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
|
||||
@@ -28,9 +32,6 @@ data class Backdrop(
|
||||
Backdrop("pexels-steve-29390703", "Steve"),
|
||||
)
|
||||
|
||||
/** Default background for new chats / fresh installs. */
|
||||
val DEFAULT: Backdrop = ALL.first { it.id == "pexels-yunszyveli-12368637" }
|
||||
|
||||
/** True when [path] references a bundled backdrop. */
|
||||
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
|
||||
|
||||
|
||||
@@ -35,8 +35,6 @@ object IrisColors {
|
||||
val textDim = Color(0xFF8A93A6)
|
||||
|
||||
// Bubbles
|
||||
val bubbleUser = primary
|
||||
val bubbleAssistant = Color(0xFF2A2E3B)
|
||||
val bubbleCommentary = Color(0xFF23262F)
|
||||
|
||||
// Panels, chips, rows
|
||||
@@ -47,7 +45,6 @@ object IrisColors {
|
||||
val divider = Color(0xFF2A2E3B)
|
||||
|
||||
// Status
|
||||
val statusGrey = Color(0xFF9E9E9E)
|
||||
val statusAmber = Color(0xFFFFC107)
|
||||
val statusGreen = Color(0xFF4CAF50)
|
||||
val statusRed = Color(0xFFF44336)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
|
||||
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
|
||||
* tapped `iris://pair` link (docs/20).
|
||||
*/
|
||||
data class PairLink(
|
||||
val url: String,
|
||||
val token: String,
|
||||
) {
|
||||
companion object {
|
||||
/** Default gateway HTTP port (docs/19). */
|
||||
const val DEFAULT_PORT = 8791
|
||||
|
||||
/**
|
||||
* Parse a pairing link into a [PairLink], or return null on any
|
||||
* malformation.
|
||||
*
|
||||
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
|
||||
* - required query params: `host` (non-empty) and `token` (non-empty).
|
||||
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
|
||||
* - `secure` defaults to `0`; `1` selects https.
|
||||
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
|
||||
*/
|
||||
fun parse(raw: String): PairLink? {
|
||||
val qIdx = raw.indexOf('?')
|
||||
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
|
||||
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
|
||||
|
||||
// authority is "iris://pair": scheme, then "://", then the URI host.
|
||||
val sep = authority.indexOf("://")
|
||||
if (sep <= 0) return null
|
||||
val scheme = authority.substring(0, sep)
|
||||
val uriHost = authority.substring(sep + 3)
|
||||
if (scheme.lowercase() != "iris") return null
|
||||
if (uriHost.lowercase() != "pair") return null
|
||||
|
||||
val params = parseQuery(query)
|
||||
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
if (host.isEmpty() || token.isEmpty()) return null
|
||||
|
||||
val portRaw = params["port"]
|
||||
val port =
|
||||
if (portRaw.isNullOrEmpty()) {
|
||||
DEFAULT_PORT
|
||||
} else {
|
||||
portRaw.toIntOrNull() ?: return null
|
||||
}
|
||||
if (port !in 1..65535) return null
|
||||
val secure = params["secure"]?.toIntOrNull() ?: 0
|
||||
val urlScheme = if (secure == 1) "https" else "http"
|
||||
return PairLink("$urlScheme://$host:$port", token)
|
||||
}
|
||||
|
||||
/** Split a `k=v&k=v` query string into a map (values may be empty). */
|
||||
private fun parseQuery(query: String): Map<String, String> {
|
||||
if (query.isEmpty()) return emptyMap()
|
||||
val map = LinkedHashMap<String, String>()
|
||||
for (pair in query.split('&')) {
|
||||
if (pair.isEmpty()) continue
|
||||
val eq = pair.indexOf('=')
|
||||
if (eq < 0) {
|
||||
map[pair] = ""
|
||||
} else {
|
||||
map[pair.substring(0, eq)] = pair.substring(eq + 1)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
|
||||
* — IPs and 64-hex tokens). A `%` that does not start a valid
|
||||
* two-hex-digit escape is kept literally.
|
||||
*/
|
||||
private fun percentDecode(s: String): String {
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '%' && i + 2 < s.length) {
|
||||
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
|
||||
if (code != null) {
|
||||
sb.append(code.toChar())
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,3 @@ expect fun localDayKey(epochMillis: Long): String
|
||||
|
||||
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
|
||||
expect fun nowMillis(): Long
|
||||
|
||||
/** Host part of a pairing URL (the "host:port" of the full gateway ws URL). */
|
||||
fun hostFromUrl(url: String): String {
|
||||
val noScheme = url.trim().substringAfter("://")
|
||||
return noScheme.substringBefore("/").ifBlank { url.trim() }
|
||||
}
|
||||
@@ -5,12 +5,20 @@
|
||||
|
||||
CREATE TABLE message (
|
||||
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
|
||||
id TEXT NOT NULL, -- message id (server id, or local_/sys_ for optimistic)
|
||||
id TEXT NOT NULL, -- message id (server id, or local<rand>/sys<rand> for optimistic)
|
||||
ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed)
|
||||
payload TEXT NOT NULL, -- serialized MessageItem
|
||||
PRIMARY KEY (lane, id)
|
||||
);
|
||||
|
||||
CREATE TABLE tool (
|
||||
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
|
||||
id TEXT NOT NULL, -- local tool card id (tool<rand>)
|
||||
seq INTEGER NOT NULL, -- card order within the lane (lane position)
|
||||
payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id)
|
||||
PRIMARY KEY (lane, id)
|
||||
);
|
||||
|
||||
CREATE TABLE channel (
|
||||
chat_id TEXT NOT NULL PRIMARY KEY,
|
||||
payload TEXT NOT NULL -- serialized ChannelInfo
|
||||
@@ -33,6 +41,18 @@ VALUES (?, ?, ?, ?);
|
||||
clearMessages:
|
||||
DELETE FROM message;
|
||||
|
||||
allTools:
|
||||
SELECT lane, seq, payload
|
||||
FROM tool
|
||||
ORDER BY lane, seq;
|
||||
|
||||
upsertTool:
|
||||
INSERT OR REPLACE INTO tool (lane, id, seq, payload)
|
||||
VALUES (?, ?, ?, ?);
|
||||
|
||||
clearTools:
|
||||
DELETE FROM tool;
|
||||
|
||||
allChannels:
|
||||
SELECT chat_id, payload
|
||||
FROM channel;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- v1 -> v2: persist tool cards (M-cache: tool cards survive a restart,
|
||||
-- anchored to the message they follow — see ChatDb.loadLanes).
|
||||
CREATE TABLE tool (
|
||||
lane TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (lane, id)
|
||||
);
|
||||
@@ -1,7 +1,15 @@
|
||||
package iris.data
|
||||
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.TYPE_TODO_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.TodoItem
|
||||
import iris.protocol.TodoUpdatePayload
|
||||
import iris.protocol.ToolStartPayload
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ChatStoreCacheTest {
|
||||
@Test
|
||||
@@ -9,21 +17,139 @@ class ChatStoreCacheTest {
|
||||
val store = ChatStore()
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
listOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||
MessageItem(id = "m2", role = "assistant", text = "hello", ts = 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "m2"), store.lanes.value["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadFromCacheEmptyIsNoOp() {
|
||||
val store = ChatStore()
|
||||
store.addPending("hello", "android:default")
|
||||
store.addPending("hello", "default")
|
||||
store.loadFromCache(emptyMap())
|
||||
assertEquals(1, store.lanes.value["android:default"]!!.size)
|
||||
assertEquals(1, store.lanes.value["default"]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadHistoryKeepsToolCardsAtAnchor() {
|
||||
val store = ChatStore()
|
||||
// Lane as restored from the cache: user message, tool card anchored
|
||||
// to it, and the final answer.
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
|
||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
||||
),
|
||||
),
|
||||
)
|
||||
// A history refresh (authoritative messages, no tool cards) must keep
|
||||
// the tool card between the user message and the answer — not push
|
||||
// it to the end.
|
||||
store.loadHistory(
|
||||
"default",
|
||||
listOf(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadHistoryDedupesAndKeepsUnanchoredToolsLast() {
|
||||
val store = ChatStore()
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
store.loadHistory(
|
||||
"default",
|
||||
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
|
||||
)
|
||||
// A card whose anchor is unknown falls to the end (degenerate case).
|
||||
assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolStartIdsNeverCollideWithRestoredCards() {
|
||||
val store = ChatStore()
|
||||
// Lane restored from the cache after a restart, holding a persisted
|
||||
// card minted by the PREVIOUS process.
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||
),
|
||||
),
|
||||
)
|
||||
// A new turn must not re-mint an id the restored lane already holds
|
||||
// (duplicate LazyColumn key / upsert overwrite under the same PK).
|
||||
store.onFrame(
|
||||
Frame(
|
||||
type = TYPE_TOOL_START,
|
||||
chatId = "iris:other",
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ToolStartPayload.serializer(),
|
||||
ToolStartPayload(index = 0, name = "terminal", preview = "ls"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val ids = store.lanes.value["default"]!!.map { it.id }
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun todoUpdateSetsLaneListLastWriteWins() {
|
||||
val store = ChatStore()
|
||||
assertNull(store.todos.value["default"])
|
||||
store.onFrame(todoFrame("default", listOf(TodoItem("1", "a", "in_progress"))))
|
||||
assertEquals(listOf("1"), store.todos.value["default"]!!.map { it.id })
|
||||
// A second update REPLACES the list (the gateway always sends the
|
||||
// full list), and other lanes are untouched.
|
||||
store.onFrame(
|
||||
todoFrame(
|
||||
"default",
|
||||
listOf(TodoItem("1", "a", "completed"), TodoItem("2", "b", "pending")),
|
||||
),
|
||||
)
|
||||
store.onFrame(todoFrame("chan_7", listOf(TodoItem("9", "z", "pending"))))
|
||||
assertEquals(listOf("1", "2"), store.todos.value["default"]!!.map { it.id })
|
||||
assertEquals("completed", store.todos.value["default"]!![0].status)
|
||||
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
|
||||
// An empty list clears the lane (the agent dropped its plan).
|
||||
store.onFrame(todoFrame("default", emptyList()))
|
||||
assertNull(store.todos.value["default"])
|
||||
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
|
||||
}
|
||||
|
||||
private fun todoFrame(
|
||||
chatId: String,
|
||||
todos: List<TodoItem>,
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_TODO_UPDATE,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
TodoUpdatePayload.serializer(),
|
||||
TodoUpdatePayload(todos = todos),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package iris.data
|
||||
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.PickerChoice
|
||||
import iris.protocol.PickerChoicePayload
|
||||
import iris.protocol.TYPE_PICKER_CHOICE
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ChatStorePickerTest {
|
||||
private fun pickerFrame(
|
||||
pickerId: String,
|
||||
title: String = "Reasoning",
|
||||
chatId: String = "chan_1",
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_PICKER_CHOICE,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
PickerChoicePayload.serializer(),
|
||||
PickerChoicePayload(
|
||||
pickerId = pickerId,
|
||||
title = title,
|
||||
choices =
|
||||
listOf(
|
||||
PickerChoice(value = "low", label = "Low"),
|
||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun onPickerChoiceAddsItem() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
val items = store.lanes.value["chan_1"]!!
|
||||
assertEquals(1, items.size)
|
||||
val picker = assertIs<PickerItem>(items[0])
|
||||
assertEquals("pk_1", picker.id)
|
||||
assertEquals("Reasoning", picker.title)
|
||||
assertEquals(2, picker.choices.size)
|
||||
assertNull(picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onPickerChoiceIsIdempotent() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.onFrame(pickerFrame("pk_1")) // duplicate (e.g. outbox re-delivery)
|
||||
val items = store.lanes.value["chan_1"]!!
|
||||
assertEquals(1, items.size)
|
||||
assertEquals("pk_1", items[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerMarksSelected() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_1", "low")
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertEquals("low", picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerIsOneShot() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_1", "low")
|
||||
store.resolvePicker("pk_1", "high") // second tap is ignored
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertEquals("low", picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerUnknownIdIsNoop() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_missing", "low") // stale select after restart
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertNull(picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickerItemSerializationRoundTrip() {
|
||||
val item =
|
||||
PickerItem(
|
||||
id = "pk_1",
|
||||
title = "Reasoning",
|
||||
choices =
|
||||
listOf(
|
||||
PickerChoice(value = "low", label = "Low"),
|
||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
||||
),
|
||||
ts = 1234L,
|
||||
selected = "high",
|
||||
)
|
||||
val json = IrisJson.instance.encodeToString(PickerItem.serializer(), item)
|
||||
val back = IrisJson.instance.decodeFromString(PickerItem.serializer(), json)
|
||||
assertEquals(item, back)
|
||||
assertTrue(back.choices[1].isCurrent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package iris.data
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* M8: per-lane unread tracking (the data behind the channel-view / header
|
||||
* unread indicator). The increment/clear *policy* (which incoming message is
|
||||
* "being read") lives in IrisController; here we cover the store's count
|
||||
* semantics that the UI and controller build on.
|
||||
*/
|
||||
class ChatStoreUnreadTest {
|
||||
@Test
|
||||
fun markUnreadIncrementsPerLane() {
|
||||
val store = ChatStore()
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
store.markUnread("default")
|
||||
store.markUnread("default")
|
||||
store.markUnread("chan_7")
|
||||
assertEquals(2, store.unreadFor("default"))
|
||||
assertEquals(1, store.unreadFor("chan_7"))
|
||||
// A lane that never had a message stays at 0.
|
||||
assertEquals(0, store.unreadFor("chan_9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markLaneReadClearsOnlyThatLane() {
|
||||
val store = ChatStore()
|
||||
store.markUnread("default")
|
||||
store.markUnread("chan_7")
|
||||
store.markLaneRead("default")
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
// Other lanes are untouched.
|
||||
assertEquals(1, store.unreadFor("chan_7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markLaneReadIsIdempotent() {
|
||||
val store = ChatStore()
|
||||
store.markLaneRead("default") // no unread -> no-op
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
store.markUnread("default")
|
||||
store.markLaneRead("default")
|
||||
store.markLaneRead("default") // clearing twice is safe
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadMapReflectsCounts() {
|
||||
val store = ChatStore()
|
||||
assertNull(store.unread.value["default"])
|
||||
store.markUnread("default")
|
||||
assertEquals(1, store.unread.value["default"])
|
||||
store.markLaneRead("default")
|
||||
// A cleared lane is removed from the map (absent == 0 unread).
|
||||
assertNull(store.unread.value["default"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearWipesUnread() {
|
||||
val store = ChatStore()
|
||||
store.markUnread("default")
|
||||
store.markUnread("chan_7")
|
||||
store.clear()
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
assertEquals(0, store.unreadFor("chan_7"))
|
||||
assertEquals(emptyMap(), store.unread.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package iris.net
|
||||
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.MessagePayload
|
||||
import iris.protocol.TYPE_MESSAGE
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* docs/19: unit tests for the HTTP fallback leg client.
|
||||
*
|
||||
* The SSE line parser is the trickiest pure logic (event/id/data fields,
|
||||
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
|
||||
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
|
||||
* pure function (unit-tested). Full transport behavior (health, POST, SSE
|
||||
* catch-up, long-poll, delivery counting) is covered by the gateway-side
|
||||
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
|
||||
*/
|
||||
class HttpGatewayTest {
|
||||
// ── URL derivation ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun deriveHttpUrlReplacesSchemeAndPort() {
|
||||
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
|
||||
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
|
||||
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
|
||||
// No explicit port on the WS URL: still the HTTP leg's default port.
|
||||
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveHttpUrlPassesThroughHttpUrls() {
|
||||
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
|
||||
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
|
||||
}
|
||||
|
||||
// ── SSE parser ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun sseParsesHelloAndFrames() {
|
||||
val parser = SseParser()
|
||||
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
|
||||
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
|
||||
val lines =
|
||||
listOf(
|
||||
"event: hello",
|
||||
"data: $hello",
|
||||
"",
|
||||
"id: 7",
|
||||
"event: frame",
|
||||
"data: $frame",
|
||||
"",
|
||||
)
|
||||
var helloCount = 0
|
||||
var frameCount = 0
|
||||
var lastCursor: Long? = null
|
||||
for (line in lines) {
|
||||
parser.feed(
|
||||
line,
|
||||
onHello = { helloCount++ },
|
||||
onFrame = { frameCount++ },
|
||||
onCursor = { lastCursor = it },
|
||||
)
|
||||
}
|
||||
assertEquals(1, helloCount)
|
||||
assertEquals(1, frameCount)
|
||||
assertEquals(7L, lastCursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseIgnoresHeartbeatComments() {
|
||||
val parser = SseParser()
|
||||
var frames = 0
|
||||
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||
assertEquals(0, frames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseMultiLineDataJoinsWithNewline() {
|
||||
val parser = SseParser()
|
||||
var frame: Frame? = null
|
||||
// SSE data may span multiple `data:` lines; the parser must join
|
||||
// them with "\n" so the reassembled JSON still decodes. Split at a
|
||||
// legal JSON whitespace point (right after a comma, between tokens).
|
||||
val full =
|
||||
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
|
||||
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
|
||||
val l1 = full.substring(0, cut)
|
||||
val l2 = full.substring(cut)
|
||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
|
||||
assertEquals("a\nb", frame?.payloadAsText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseTracksLastEventIdAcrossFrames() {
|
||||
val parser = SseParser()
|
||||
val ids = mutableListOf<Long>()
|
||||
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
|
||||
for (id in listOf(1L, 2L, 3L)) {
|
||||
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
|
||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||
}
|
||||
assertEquals(listOf(1L, 2L, 3L), ids)
|
||||
assertEquals(3L, parser.lastEventId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseMalformedLineDoesNotThrow() {
|
||||
val parser = SseParser()
|
||||
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||
// No exception, no frame emitted for the malformed data.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseDecodesFramePayload() {
|
||||
val parser = SseParser()
|
||||
var text: String? = null
|
||||
val frame =
|
||||
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
|
||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
|
||||
assertEquals("hello", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
|
||||
|
||||
/**
|
||||
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
|
||||
* terminators); a blank line dispatches the buffered event. [lastEventId]
|
||||
* is the most recent `id:` field (the outbox cursor) — the resume point for
|
||||
* a reconnect.
|
||||
*/
|
||||
class SseParser {
|
||||
var lastEventId: Long? = null
|
||||
private set
|
||||
|
||||
private var eventId: String? = null
|
||||
private val dataLines = mutableListOf<String>()
|
||||
|
||||
fun feed(
|
||||
line: String,
|
||||
onHello: (String) -> Unit,
|
||||
onFrame: (Frame) -> Unit,
|
||||
onCursor: (Long) -> Unit,
|
||||
) {
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (dataLines.isNotEmpty()) {
|
||||
val data = dataLines.joinToString("\n")
|
||||
val frame =
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(Frame.serializer(), data)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
if (frame != null) {
|
||||
when (eventId) {
|
||||
"hello" -> onHello(data)
|
||||
else -> onFrame(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
eventId = null
|
||||
dataLines.clear()
|
||||
}
|
||||
|
||||
line.startsWith(":") -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
// comment / heartbeat
|
||||
line.startsWith("id:") -> {
|
||||
val id = line.removePrefix("id:").trim().toLongOrNull()
|
||||
if (id != null) {
|
||||
lastEventId = id
|
||||
onCursor(id)
|
||||
}
|
||||
}
|
||||
|
||||
line.startsWith("event:") -> {
|
||||
eventId = line.removePrefix("event:").trim()
|
||||
}
|
||||
|
||||
line.startsWith("data:") -> {
|
||||
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text
|
||||
@@ -0,0 +1,163 @@
|
||||
package iris.net
|
||||
|
||||
import com.sun.net.httpserver.HttpsConfigurator
|
||||
import com.sun.net.httpserver.HttpsServer
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Base64
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real
|
||||
* TLS handshake: a local HTTPS server presents the embedded self-signed
|
||||
* certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like
|
||||
* [GatewayClient] (pinning socket factory + trust manager).
|
||||
*
|
||||
* 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the
|
||||
* presented fingerprint from the nested exception.
|
||||
* 2. After "confirming" (setting the pin): the SAME client connects — the
|
||||
* pin is read live, no client rebuild.
|
||||
*
|
||||
* The embedded key is a throwaway test key, not a secret.
|
||||
*/
|
||||
class TlsPinningIntegrationTest {
|
||||
private companion object {
|
||||
// Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname
|
||||
// verifier requires a SAN; CN-only certs are rejected even when pinned).
|
||||
val CERT_PEM =
|
||||
"""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL
|
||||
BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z
|
||||
NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF
|
||||
/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C
|
||||
Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT
|
||||
qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs
|
||||
PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a
|
||||
GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7
|
||||
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd
|
||||
WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm
|
||||
EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3
|
||||
Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE
|
||||
dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw
|
||||
zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0
|
||||
Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU=
|
||||
-----END CERTIFICATE-----
|
||||
""".trimIndent()
|
||||
|
||||
val KEY_PEM =
|
||||
"""
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G
|
||||
uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc
|
||||
fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H
|
||||
eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7
|
||||
bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC
|
||||
qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7
|
||||
OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5
|
||||
PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd
|
||||
sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r
|
||||
yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g
|
||||
WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN
|
||||
ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE
|
||||
BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f
|
||||
utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem
|
||||
Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK
|
||||
ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu
|
||||
OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H
|
||||
yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w
|
||||
Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6
|
||||
190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF
|
||||
JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/
|
||||
LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M
|
||||
JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI
|
||||
MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z
|
||||
OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO
|
||||
sZ1EltfJHT57a0ub22mRtVc=
|
||||
-----END PRIVATE KEY-----
|
||||
""".trimIndent()
|
||||
|
||||
// `openssl x509 -noout -fingerprint -sha256` over CERT_PEM.
|
||||
const val EXPECTED_FINGERPRINT =
|
||||
"9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44"
|
||||
|
||||
fun pemBody(pem: String): ByteArray {
|
||||
val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "")
|
||||
return Base64.getDecoder().decode(base64)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfSignedGatewayRequiresConfirmThenPins() {
|
||||
val cert =
|
||||
CertificateFactory
|
||||
.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray()))
|
||||
.let { it as java.security.cert.X509Certificate }
|
||||
val key =
|
||||
KeyFactory
|
||||
.getInstance("RSA")
|
||||
.generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM)))
|
||||
|
||||
// Local HTTPS server presenting the self-signed cert.
|
||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
ks.load(null, null)
|
||||
ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert))
|
||||
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||
kmf.init(ks, CharArray(0))
|
||||
val serverCtx = SSLContext.getInstance("TLS")
|
||||
serverCtx.init(kmf.keyManagers, null, null)
|
||||
|
||||
val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
server.httpsConfigurator = HttpsConfigurator(serverCtx)
|
||||
server.createContext("/v1/health") { exchange ->
|
||||
val body = "ok".toByteArray()
|
||||
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||
exchange.responseBody.use { it.write(body) }
|
||||
}
|
||||
server.start()
|
||||
|
||||
// The client is wired exactly like GatewayClient: pinning socket
|
||||
// factory + trust manager, pin read live from a mutable holder.
|
||||
var pin = ""
|
||||
val tm = PinningTrustManager { pin }
|
||||
val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build()
|
||||
val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build()
|
||||
|
||||
try {
|
||||
// 1. Unpinned: the handshake fails; the unwrap finds the
|
||||
// presented fingerprint in the nested exception chain.
|
||||
val e =
|
||||
assertFailsWith<Exception> {
|
||||
client.newCall(request).execute().use { it.body!!.string() }
|
||||
}
|
||||
val tls = tlsFingerprintRequired(e)
|
||||
assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e")
|
||||
assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint)
|
||||
|
||||
// 2. User "confirms" the fingerprint: the SAME client now
|
||||
// connects (the pin is read live, no client rebuild).
|
||||
pin = EXPECTED_FINGERPRINT
|
||||
client.newCall(request).execute().use { response ->
|
||||
assertEquals(200, response.code)
|
||||
assertEquals("ok", response.body!!.string())
|
||||
}
|
||||
} finally {
|
||||
server.stop(0)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package iris.net
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow.
|
||||
*
|
||||
* The embedded certificate is a self-signed cert (CN=iris-test-gateway) the
|
||||
* platform trust store does NOT contain, so it exercises the exact path a
|
||||
* self-signed gateway hits: default trust manager rejects → the pinning
|
||||
* manager either offers the fingerprint for confirmation or accepts the
|
||||
* user-confirmed pin.
|
||||
*/
|
||||
class TlsPinningTest {
|
||||
private companion object {
|
||||
// Self-signed, 10-year validity — generated with:
|
||||
// openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway"
|
||||
val SELF_SIGNED_PEM =
|
||||
"""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL
|
||||
BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3
|
||||
WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC
|
||||
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp
|
||||
9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V
|
||||
LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs
|
||||
BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc
|
||||
HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66
|
||||
UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC
|
||||
AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY
|
||||
MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
|
||||
hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj
|
||||
GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/
|
||||
Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S
|
||||
vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA
|
||||
I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2
|
||||
vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs=
|
||||
-----END CERTIFICATE-----
|
||||
""".trimIndent()
|
||||
|
||||
// `openssl x509 -noout -fingerprint -sha256` over the same cert.
|
||||
const val EXPECTED_FINGERPRINT =
|
||||
"C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA"
|
||||
|
||||
val CERT: X509Certificate by lazy {
|
||||
CertificateFactory
|
||||
.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray()))
|
||||
.let { it as X509Certificate }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun certFingerprintMatchesOpenSsl() {
|
||||
assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() {
|
||||
val tm = PinningTrustManager { "" }
|
||||
val e =
|
||||
assertFailsWith<TlsFingerprintRequired> {
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedPinAcceptsTheSelfSignedCert() {
|
||||
val tm = PinningTrustManager { EXPECTED_FINGERPRINT }
|
||||
// Must not throw: the user confirmed exactly this certificate.
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongPinStillFailsWithThePresentedFingerprint() {
|
||||
val tm = PinningTrustManager { "DE:AD:BE:EF" }
|
||||
val e =
|
||||
assertFailsWith<TlsFingerprintRequired> {
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinIsReadLive() {
|
||||
// The provider is a lambda: a pin saved AFTER the manager was built
|
||||
// (the confirm dialog's action) takes effect without rebuilding it.
|
||||
var pin = ""
|
||||
val tm = PinningTrustManager { pin }
|
||||
assertFailsWith<TlsFingerprintRequired> {
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
pin = EXPECTED_FINGERPRINT
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unwrapFindsNestedTlsFingerprintRequired() {
|
||||
val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT)
|
||||
// The JSSE/OkHttp layers wrap the trust manager's exception in an
|
||||
// (SSL) handshake IOException — the unwrap must find it nested.
|
||||
val wrapped = IOException("PKIX path building failed", inner)
|
||||
val found = tlsFingerprintRequired(wrapped)
|
||||
assertNotNull(found)
|
||||
assertEquals(EXPECTED_FINGERPRINT, found.fingerprint)
|
||||
// Unrelated failures must not be misread as a confirm request.
|
||||
assertNull(tlsFingerprintRequired(IOException("remote host closed connection")))
|
||||
assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinningSocketFactoryCreatesSockets() {
|
||||
val tm = PinningTrustManager { "" }
|
||||
val factory = pinningSslSocketFactory(tm)
|
||||
val socket = factory.createSocket()
|
||||
assertTrue(socket.javaClass.name.contains("SSL"))
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** Wire tests for the hello.ack payload (docs/04). */
|
||||
class HelloAckWireTest {
|
||||
@Test
|
||||
fun helloAckDeserializesDeviceToken() {
|
||||
val raw =
|
||||
"""
|
||||
{"v":1,"type":"hello.ack","payload":{"sync_cursor":5,
|
||||
"last_pushed_cursor":3,"device_token":"9f2c64hex"}}
|
||||
""".trimIndent()
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
assertEquals("hello.ack", frame.type)
|
||||
val p = frame.payloadAs<HelloAckPayload>()
|
||||
assertEquals("9f2c64hex", p?.deviceToken)
|
||||
assertEquals(5L, p?.syncCursor)
|
||||
assertEquals(3L, p?.lastPushedCursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun helloAckDefaultsDeviceTokenToEmpty() {
|
||||
// Legacy gateways (pre per-device tokens) omit the field entirely.
|
||||
val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}"""
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
val p = frame.payloadAs<HelloAckPayload>()
|
||||
assertEquals("", p?.deviceToken)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package iris.platform
|
||||
|
||||
import iris.state.IrisController
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* M6: bridge between the desktop shell (tray / window) and the shared
|
||||
@@ -12,19 +13,35 @@ typealias NotificationListener =
|
||||
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
|
||||
|
||||
object DesktopBridge {
|
||||
/** True while the window has focus (set by the shell). A change is
|
||||
* forwarded to the controller (M8: unread clear on focus). */
|
||||
@Volatile
|
||||
var foreground: Boolean = true
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
controller?.setForeground(value)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var controller: IrisController? = null
|
||||
|
||||
private val notificationListeners = mutableListOf<NotificationListener>()
|
||||
// M-13: CopyOnWriteArrayList — listeners are added from the UI thread and
|
||||
// iterated from the OkHttp callback thread; a plain mutableListOf's
|
||||
// .toList() copy is not atomic with a concurrent add.
|
||||
private val notificationListeners = CopyOnWriteArrayList<NotificationListener>()
|
||||
|
||||
fun onNotification(listener: NotificationListener) {
|
||||
notificationListeners.add(listener)
|
||||
}
|
||||
|
||||
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) {
|
||||
notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
|
||||
fun notifyListeners(
|
||||
chatId: String?,
|
||||
title: String,
|
||||
body: String,
|
||||
threadId: String?,
|
||||
) {
|
||||
notificationListeners.forEach { it(chatId, title, body, threadId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -35,7 +34,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
@@ -62,7 +60,6 @@ import kotlin.random.Random
|
||||
*/
|
||||
@Composable
|
||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -71,16 +68,10 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
) {
|
||||
Text("Attach a file:", fontSize = 13.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
busy = true
|
||||
val picked = pickFile()
|
||||
busy = false
|
||||
onPicked(picked)
|
||||
},
|
||||
enabled = !busy,
|
||||
) {
|
||||
Text(if (busy) "Choosing…" else "Choose file…")
|
||||
// pickFile() blocks the UI thread (JFileChooser is modal), so there's
|
||||
// no in-flight state to show while the dialog is open.
|
||||
Button(onClick = { onPicked(pickFile()) }) {
|
||||
Text("Choose file…")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +129,8 @@ private fun guessMime(name: String): String {
|
||||
"mov" -> "video/quicktime"
|
||||
"mkv" -> "video/x-matroska"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"m4a", "aac" -> "audio/mp4"
|
||||
"m4a" -> "audio/mp4"
|
||||
"aac" -> "audio/aac"
|
||||
"ogg", "opus" -> "audio/ogg"
|
||||
"wav" -> "audio/wav"
|
||||
"flac" -> "audio/flac"
|
||||
|
||||
@@ -59,7 +59,7 @@ object DesktopNotifier {
|
||||
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
|
||||
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
|
||||
"\$n.Visible = \$true; " +
|
||||
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
|
||||
"\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
|
||||
"[System.Windows.Forms.ToolTipIcon]::Info); " +
|
||||
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
|
||||
)
|
||||
@@ -71,4 +71,16 @@ object DesktopNotifier {
|
||||
}
|
||||
|
||||
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
||||
|
||||
// M-16: PowerShell uses backtick escaping (not backslash) and treats `$`
|
||||
// as a variable reference, so a body containing `$foo` would be mangled or
|
||||
// error. Escape backtick first (so the backticks we add aren't doubled),
|
||||
// then `$` and `"`. Newlines collapse to spaces (balloon tips are single
|
||||
// line).
|
||||
private fun psEsc(s: String): String =
|
||||
s
|
||||
.replace("`", "``")
|
||||
.replace("$", "`$")
|
||||
.replace("\"", "`\"")
|
||||
.replace("\n", " ")
|
||||
}
|
||||
@@ -27,7 +27,30 @@ class DesktopSecureStore : SecureStore {
|
||||
private val baseDir = File(System.getProperty("user.home"), ".iris")
|
||||
private val settingsFile = File(baseDir, "settings.json")
|
||||
private val legacyFile = File(baseDir, "pairing.json")
|
||||
private val secret = SecretBackend(baseDir)
|
||||
private val secret =
|
||||
SecretBackend(
|
||||
baseDir,
|
||||
keyringService = "iris-gateway-token",
|
||||
keyringLabel = "Iris gateway token",
|
||||
keyringAttr = "iris",
|
||||
encFileName = "pairing.enc",
|
||||
)
|
||||
|
||||
// Per-device token (docs/09 §9.3): a second secret slot, same backends.
|
||||
private val deviceSecret =
|
||||
SecretBackend(
|
||||
baseDir,
|
||||
keyringService = "iris-device-token",
|
||||
keyringLabel = "Iris device token",
|
||||
keyringAttr = "iris-device",
|
||||
encFileName = "device_token.enc",
|
||||
)
|
||||
|
||||
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
|
||||
// connect attempt) don't re-read + re-parse the file on every access.
|
||||
// Invalidated on every save(). DesktopSecureStore is a per-process
|
||||
// singleton (created once in Main.kt), so a per-instance cache is safe.
|
||||
private var cached: Settings? = null
|
||||
|
||||
@Serializable
|
||||
private data class Settings(
|
||||
@@ -50,6 +73,7 @@ class DesktopSecureStore : SecureStore {
|
||||
val fontSizeScale: Float = 1.0f,
|
||||
val runtimeFooterEnabled: Boolean = false,
|
||||
val runtimeFooterFields: String = "",
|
||||
val pinnedCertFingerprint: String = "",
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -77,24 +101,32 @@ class DesktopSecureStore : SecureStore {
|
||||
),
|
||||
)
|
||||
if (legacy.token.isNotBlank()) secret.write(legacy.token)
|
||||
// L-49: only delete the legacy file after a successful migration;
|
||||
// a parse failure (legacy == null) must not destroy the data.
|
||||
legacyFile.delete()
|
||||
}
|
||||
legacyFile.delete()
|
||||
}
|
||||
|
||||
private fun load(): Settings =
|
||||
if (settingsFile.exists()) {
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
||||
} catch (_: Exception) {
|
||||
private fun load(): Settings {
|
||||
cached?.let { return it }
|
||||
val s =
|
||||
if (settingsFile.exists()) {
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
||||
} catch (_: Exception) {
|
||||
Settings()
|
||||
}
|
||||
} else {
|
||||
Settings()
|
||||
}
|
||||
} else {
|
||||
Settings()
|
||||
}
|
||||
cached = s
|
||||
return s
|
||||
}
|
||||
|
||||
private fun save(data: Settings) {
|
||||
baseDir.mkdirs()
|
||||
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
|
||||
cached = data
|
||||
}
|
||||
|
||||
override var serverUrl: String
|
||||
@@ -110,6 +142,12 @@ class DesktopSecureStore : SecureStore {
|
||||
if (value.isBlank()) secret.clear() else secret.write(value.trim())
|
||||
}
|
||||
|
||||
override var deviceToken: String
|
||||
get() = deviceSecret.read().orEmpty()
|
||||
set(value) {
|
||||
if (value.isBlank()) deviceSecret.clear() else deviceSecret.write(value.trim())
|
||||
}
|
||||
|
||||
override val deviceId: String
|
||||
get() {
|
||||
val d = load()
|
||||
@@ -206,7 +244,7 @@ class DesktopSecureStore : SecureStore {
|
||||
}
|
||||
|
||||
override var backgroundMode: String
|
||||
get() = load().backgroundMode.ifBlank { "color" }
|
||||
get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(backgroundMode = value))
|
||||
@@ -247,6 +285,13 @@ class DesktopSecureStore : SecureStore {
|
||||
save(d.copy(runtimeFooterFields = value))
|
||||
}
|
||||
|
||||
override var pinnedCertFingerprint: String
|
||||
get() = load().pinnedCertFingerprint
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(pinnedCertFingerprint = value.trim()))
|
||||
}
|
||||
|
||||
override fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
@@ -254,12 +299,31 @@ class DesktopSecureStore : SecureStore {
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = url.trim()))
|
||||
if (token.isBlank()) secret.clear() else secret.write(token.trim())
|
||||
// A (re-)pair may target a different gateway: the old per-device
|
||||
// token is dead there. The next hello.ack re-mints/returns it.
|
||||
deviceSecret.clear()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
// M-10: clearing pairing must also wipe the device identity + push
|
||||
// state, otherwise a re-pair to a different gateway would keep the old
|
||||
// deviceId/syncCursor/ntfyTopic and the server would treat the new
|
||||
// pairing as the same device.
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = ""))
|
||||
save(
|
||||
d.copy(
|
||||
serverUrl = "",
|
||||
deviceId = "",
|
||||
syncCursor = 0L,
|
||||
fcmToken = "",
|
||||
ntfyTopic = "",
|
||||
ntfyServer = "",
|
||||
pushBackend = "",
|
||||
pinnedCertFingerprint = "",
|
||||
),
|
||||
)
|
||||
secret.clear()
|
||||
deviceSecret.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,20 +342,33 @@ private data class PairingData(
|
||||
/**
|
||||
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
|
||||
* All backend failures degrade to the encrypted file (never plaintext).
|
||||
*
|
||||
* Parameterized so the shared gateway token and the per-device token
|
||||
* (docs/09 §9.3) each get their own keyring entry / encrypted file.
|
||||
*/
|
||||
private class SecretBackend(
|
||||
private val baseDir: File,
|
||||
private val keyringService: String,
|
||||
private val keyringLabel: String,
|
||||
private val keyringAttr: String,
|
||||
encFileName: String,
|
||||
) {
|
||||
private val encFile = File(baseDir, "pairing.enc")
|
||||
private val encFile = File(baseDir, encFileName)
|
||||
private val keyFile = File(baseDir, ".key")
|
||||
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
|
||||
private val keyring: KeyringBackend? =
|
||||
KeyringBackend(keyringService, keyringLabel, keyringAttr).takeIf { it.available }
|
||||
|
||||
fun read(): String? = keyring?.read() ?: readEncrypted()
|
||||
|
||||
fun write(value: String) {
|
||||
if (keyring != null) {
|
||||
keyring.write(value)
|
||||
if (keyring.read() == value) return
|
||||
if (keyring.read() == value) {
|
||||
// L-50: the keyring now holds the secret; drop the stale
|
||||
// encrypted file so the old token can't be read back.
|
||||
encFile.delete()
|
||||
return
|
||||
}
|
||||
// Keyring accepted the write but did not persist it (e.g. KWallet
|
||||
// without a live daemon). Drop the stale entry and fall back to
|
||||
// the encrypted file so the token survives a restart.
|
||||
@@ -355,7 +432,11 @@ private class SecretBackend(
|
||||
}
|
||||
|
||||
/** OS keyring via the platform CLI (best effort). */
|
||||
private class KeyringBackend {
|
||||
private class KeyringBackend(
|
||||
private val service: String,
|
||||
private val label: String,
|
||||
private val attr: String,
|
||||
) {
|
||||
private val os = System.getProperty("os.name").lowercase()
|
||||
private val isMac = os.contains("mac")
|
||||
private val isLinux = os.contains("linux")
|
||||
@@ -374,9 +455,9 @@ private class KeyringBackend {
|
||||
fun read(): String? =
|
||||
try {
|
||||
if (isMac) {
|
||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", service, "-w"))
|
||||
} else {
|
||||
out(listOf("secret-tool", "lookup", "app", "iris"))
|
||||
out(listOf("secret-tool", "lookup", "app", attr))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
@@ -384,28 +465,28 @@ private class KeyringBackend {
|
||||
|
||||
fun write(value: String) {
|
||||
try {
|
||||
if (isMac) {
|
||||
ProcessBuilder(
|
||||
"security",
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-a",
|
||||
"iris",
|
||||
"-s",
|
||||
"iris-gateway-token",
|
||||
"-w",
|
||||
value,
|
||||
).inheritIO().start().waitFor()
|
||||
} else {
|
||||
ProcessBuilder(
|
||||
"secret-tool",
|
||||
"store",
|
||||
"--label=Iris gateway token",
|
||||
"app",
|
||||
"iris",
|
||||
"token",
|
||||
value,
|
||||
).inheritIO().start().waitFor()
|
||||
// M-9: pass the secret on stdin, not as a CLI argument. A trailing
|
||||
// argument is visible in the process list (`ps`); `secret-tool
|
||||
// store` and `security add-generic-password -w` both read the
|
||||
// secret from stdin when no value argument is given.
|
||||
val cmd =
|
||||
if (isMac) {
|
||||
listOf(
|
||||
"security",
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-a",
|
||||
"iris",
|
||||
"-s",
|
||||
service,
|
||||
"-w",
|
||||
)
|
||||
} else {
|
||||
listOf("secret-tool", "store", "--label=$label", "app", attr)
|
||||
}
|
||||
ProcessBuilder(cmd).start().apply {
|
||||
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
|
||||
waitFor()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
@@ -420,14 +501,14 @@ private class KeyringBackend {
|
||||
"-a",
|
||||
"iris",
|
||||
"-s",
|
||||
"iris-gateway-token",
|
||||
service,
|
||||
).inheritIO().start().waitFor()
|
||||
} else {
|
||||
ProcessBuilder(
|
||||
"secret-tool",
|
||||
"clear",
|
||||
"app",
|
||||
"iris",
|
||||
attr,
|
||||
).inheritIO().start().waitFor()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -4,13 +4,41 @@ import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import iris.db.IrisDatabase
|
||||
import java.io.File
|
||||
import java.sql.DriverManager
|
||||
import java.util.Properties
|
||||
|
||||
actual fun appDataDir(): String = File(System.getProperty("user.home"), ".iris").apply { mkdirs() }.absolutePath
|
||||
|
||||
actual fun createCacheDriver(): SqlDriver {
|
||||
val driver = JdbcSqliteDriver("jdbc:sqlite:${File(appDataDir(), "iris_cache.db").absolutePath}")
|
||||
// v1: create the schema (no migrations yet; add .sqm files +
|
||||
// `Schema.migrate` when the schema changes).
|
||||
IrisDatabase.Schema.create(driver)
|
||||
return driver
|
||||
actual fun createCacheDriver(): SqlDriver = createCacheDriver(File(appDataDir(), "iris_cache.db"))
|
||||
|
||||
/** Schema-aware driver for the cache DB at [file]: creates the schema on
|
||||
* first run, applies pending .sqm migrations on existing DBs (e.g. v1 ->
|
||||
* v2: the `tool` table), and persists the schema version (PRAGMA
|
||||
* user_version). */
|
||||
internal fun createCacheDriver(file: File): SqlDriver {
|
||||
stampLegacyV1(file)
|
||||
return JdbcSqliteDriver("jdbc:sqlite:${file.absolutePath}", Properties(), IrisDatabase.Schema)
|
||||
}
|
||||
|
||||
/** One-time shim for pre-existing desktop cache DBs: the old code called
|
||||
* `Schema.create()` directly, which never stamped PRAGMA user_version — so
|
||||
* those files hold the v1 schema at user_version 0, which the schema-aware
|
||||
* driver would treat as a fresh DB and crash on (`CREATE TABLE message` on
|
||||
* an existing table). Stamp them as v1 so the 1 -> 2 migration runs. */
|
||||
internal fun stampLegacyV1(file: File) {
|
||||
if (!file.exists()) return
|
||||
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { conn ->
|
||||
val userVersion =
|
||||
conn.createStatement().use { st ->
|
||||
st.executeQuery("PRAGMA user_version").use { rs -> if (rs.next()) rs.getInt(1) else 0 }
|
||||
}
|
||||
if (userVersion != 0) return
|
||||
val hasMessageTable =
|
||||
conn.createStatement().use { st ->
|
||||
st
|
||||
.executeQuery("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'message'")
|
||||
.use { rs -> rs.next() }
|
||||
}
|
||||
if (hasMessageTable) conn.createStatement().use { st -> st.execute("PRAGMA user_version = 1") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Desktop has no camera, so there is nothing to scan. The Connect screen hides
|
||||
* this button on desktop (`isDesktop`), so this is never rendered.
|
||||
*/
|
||||
@Composable
|
||||
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
||||
// No-op on desktop.
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package iris.data
|
||||
|
||||
import iris.platform.createCacheDriver
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.sql.DriverManager
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** Migration path for pre-existing desktop cache DBs: the schema-aware
|
||||
* driver plus the legacy user_version-0 shim (DesktopStorage). */
|
||||
class CacheMigrationTest {
|
||||
private val v1Schema =
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
lane TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (lane, id)
|
||||
);
|
||||
CREATE TABLE channel (
|
||||
chat_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (chat_id)
|
||||
);
|
||||
CREATE TABLE meta (
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (key)
|
||||
);
|
||||
""".trimIndent()
|
||||
|
||||
private fun tempDb(name: String): File = Files.createTempDirectory("iris_cache_test").toFile().let { File(it, name) }
|
||||
|
||||
private fun File.queryInt(sql: String): Int =
|
||||
DriverManager.getConnection("jdbc:sqlite:$absolutePath").use { conn ->
|
||||
conn.createStatement().use { st ->
|
||||
st.executeQuery(sql).use { rs -> if (rs.next()) rs.getInt(1) else -1 }
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.writeV1(userVersion: Int) {
|
||||
DriverManager.getConnection("jdbc:sqlite:$absolutePath").use { conn ->
|
||||
conn.createStatement().use { st ->
|
||||
v1Schema
|
||||
.split(";")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.forEach { st.execute(it) }
|
||||
st.execute("INSERT INTO message (lane, id, ts, payload) VALUES ('l', 'm1', 1, '{}')")
|
||||
// (row simulates a legacy DB with data; must survive migration)
|
||||
if (userVersion > 0) st.execute("PRAGMA user_version = $userVersion")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyV1DbWithZeroUserVersionMigrates() {
|
||||
val file = tempDb("legacy0.db")
|
||||
try {
|
||||
// The old desktop code called Schema.create() directly, which
|
||||
// never stamped user_version: v1 tables at user_version 0.
|
||||
file.writeV1(userVersion = 0)
|
||||
// Must migrate (1 -> 2 via the shim), not crash on
|
||||
// `CREATE TABLE message` against the existing table.
|
||||
createCacheDriver(file).use { }
|
||||
assertEquals(1, file.queryInt("SELECT COUNT(*) FROM message"))
|
||||
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
|
||||
} finally {
|
||||
file.parentFile?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyV1DbWithStampedVersionMigrates() {
|
||||
val file = tempDb("legacy1.db")
|
||||
try {
|
||||
file.writeV1(userVersion = 1)
|
||||
createCacheDriver(file).use { }
|
||||
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
|
||||
} finally {
|
||||
file.parentFile?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDbCreatesSchema() {
|
||||
val file = tempDb("fresh.db")
|
||||
try {
|
||||
createCacheDriver(file).use { }
|
||||
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
|
||||
} finally {
|
||||
file.parentFile?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package iris.media
|
||||
|
||||
import java.io.File
|
||||
|
||||
actual class FileSource actual constructor(path: String) : AutoCloseable {
|
||||
private val file = File(path)
|
||||
private val input = file.inputStream()
|
||||
|
||||
actual fun size(): Long = file.length()
|
||||
|
||||
actual fun read(buf: ByteArray): Int = input.read(buf)
|
||||
|
||||
override fun close() {
|
||||
input.close()
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,44 @@ import java.io.FileOutputStream
|
||||
|
||||
actual interface MediaWriter : AutoCloseable {
|
||||
actual val path: String
|
||||
|
||||
actual fun write(bytes: ByteArray)
|
||||
}
|
||||
|
||||
actual class MediaCache actual constructor(baseDir: String) {
|
||||
actual class MediaCache actual constructor(
|
||||
baseDir: String,
|
||||
) {
|
||||
private val maxBytes: Long = 500L * 1024 * 1024
|
||||
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
|
||||
|
||||
actual fun path(mediaId: String, mime: String): String? {
|
||||
val f = File(mediaDir, "$mediaId${extForMime(mime)}")
|
||||
/**
|
||||
* Resolve the cache file for [mediaId]. The id is server-controlled
|
||||
* (`media.offer`); reject path-traversal values so a hostile gateway can't
|
||||
* write outside [mediaDir] (M-2 / S-1). Returns null when the id is
|
||||
* invalid.
|
||||
*/
|
||||
private fun fileFor(
|
||||
mediaId: String,
|
||||
mime: String,
|
||||
): File? = if (!isValidMediaId(mediaId)) null else File(mediaDir, "$mediaId${extForMime(mime)}")
|
||||
|
||||
actual fun path(
|
||||
mediaId: String,
|
||||
mime: String,
|
||||
): String? {
|
||||
val f = fileFor(mediaId, mime) ?: return null
|
||||
return if (f.exists()) f.absolutePath else null
|
||||
}
|
||||
|
||||
actual fun openWriter(mediaId: String, mime: String): MediaWriter =
|
||||
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this)
|
||||
actual fun openWriter(
|
||||
mediaId: String,
|
||||
mime: String,
|
||||
): MediaWriter = JvmMediaWriter(fileFor(mediaId, mime) ?: throw IllegalArgumentException("invalid media id"), this)
|
||||
|
||||
actual fun remove(mediaId: String, mime: String) {
|
||||
actual fun remove(
|
||||
mediaId: String,
|
||||
mime: String,
|
||||
) {
|
||||
path(mediaId, mime)?.let { File(it).delete() }
|
||||
}
|
||||
|
||||
@@ -36,7 +58,10 @@ actual class MediaCache actual constructor(baseDir: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private class JvmMediaWriter(private val target: File, private val cache: MediaCache) : MediaWriter {
|
||||
private class JvmMediaWriter(
|
||||
private val target: File,
|
||||
private val cache: MediaCache,
|
||||
) : MediaWriter {
|
||||
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
|
||||
|
||||
override val path: String get() = target.absolutePath
|
||||
@@ -56,4 +81,4 @@ private class JvmMediaWriter(private val target: File, private val cache: MediaC
|
||||
}
|
||||
cache.evict()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import iris.protocol.ChannelInfo
|
||||
import iris.protocol.RuntimeMeta
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Cache DB round-trip tests (JDBC in-memory SQLite; shared by both host targets). */
|
||||
class ChatDbTest {
|
||||
@@ -41,31 +43,89 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
listOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
|
||||
msg("m2", role = "assistant", text = "hi", ts = 200),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash"),
|
||||
),
|
||||
"android:default::thr_1" to listOf(msg("m3", ts = 300)),
|
||||
"default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadLanes()
|
||||
assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys)
|
||||
// Tool cards are ephemeral — not persisted.
|
||||
assertEquals(listOf("m1", "m2"), loaded["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id })
|
||||
// Ordered by ts.
|
||||
assertEquals(100L, loaded["android:default"]!![0].ts)
|
||||
assertEquals(200L, loaded["android:default"]!![1].ts)
|
||||
assertEquals(setOf("default", "default::thr_1"), loaded.keys)
|
||||
// Tool cards are persisted and restored at their anchored position
|
||||
// (after the message they follow, before the answer).
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
|
||||
assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
|
||||
// Messages ordered by ts.
|
||||
assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
|
||||
assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCardsShareAnchorKeepOrder() {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
|
||||
ToolItem(id = "tool_2", index = 1, name = "terminal", anchorId = "m1"),
|
||||
msg("m2", role = "assistant", ts = 200),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCardWithMissingAnchorFallsToEnd() {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
|
||||
msg("m2", role = "assistant", ts = 200),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreClosesOpenToolCard() {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
|
||||
ToolItem(id = "tool_2", index = 1, name = "ls", done = true, ok = true, anchorId = "m1"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val lane = db.loadLanes()["default"]!!
|
||||
// The process died before tool.end — the open card is closed as
|
||||
// interrupted, not left spinning.
|
||||
val open = lane.first { it.id == "tool_1" } as ToolItem
|
||||
assertTrue(open.done)
|
||||
assertFalse(open.ok)
|
||||
// A completed card is untouched.
|
||||
val done = lane.first { it.id == "tool_2" } as ToolItem
|
||||
assertTrue(done.ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveLanesReplacesPreviousSnapshot() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2"))))
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m2"))))
|
||||
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
|
||||
db.saveLanes(mapOf("default" to listOf(msg("m2"))))
|
||||
assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,7 +133,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf(
|
||||
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
|
||||
msg("s1", role = "assistant", streaming = true, ts = 500),
|
||||
@@ -81,22 +141,23 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
val lane = db.loadLanes()["android:default"]!!
|
||||
val lane = db.loadLanes()["default"]!!
|
||||
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
|
||||
// A pending send becomes failed (tap to retry); the gateway never
|
||||
// acknowledged it before the process died.
|
||||
assertEquals(MsgStatus.Failed, lane.first { it.id == "p1" }.status)
|
||||
assertEquals(false, lane.first { it.id == "p1" }.pending)
|
||||
assertEquals(MsgStatus.Failed, msgItem("p1").status)
|
||||
assertEquals(false, msgItem("p1").pending)
|
||||
// A streaming bubble is restored as finalized.
|
||||
assertEquals(false, lane.first { it.id == "s1" }.streaming)
|
||||
assertEquals(false, msgItem("s1").streaming)
|
||||
// Read status is preserved.
|
||||
assertEquals(MsgStatus.Read, lane.first { it.id == "r1" }.status)
|
||||
assertEquals(MsgStatus.Read, msgItem("r1").status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemMessagesAreNotPersisted() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("sys_1", role = "system", isSystem = true))))
|
||||
assertEquals(emptyMap<String, List<MessageItem>>(), db.loadLanes())
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
||||
assertTrue(db.loadLanes().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -121,8 +182,8 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
db.saveLanes(mapOf("android:default" to listOf(item)))
|
||||
val loaded = db.loadLanes()["android:default"]!!.first()
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
|
||||
val loaded = db.loadLanes()["default"]!!.first() as MessageItem
|
||||
assertEquals("gpt", loaded.runtime?.model)
|
||||
assertEquals("/tmp/a.png", loaded.media.first().localPath)
|
||||
}
|
||||
@@ -132,28 +193,28 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveChannels(
|
||||
listOf(
|
||||
ChannelInfo(chatId = "android:default", name = "General", isDefault = true),
|
||||
ChannelInfo(chatId = "android:chan_1", name = "Work"),
|
||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"),
|
||||
ChannelInfo(chatId = "default", name = "General", isDefault = true),
|
||||
ChannelInfo(chatId = "chan_1", name = "Work"),
|
||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadChannels()
|
||||
assertEquals(3, loaded.size)
|
||||
assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name)
|
||||
assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
|
||||
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
|
||||
assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault)
|
||||
assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metaRoundTripAndClearAll() {
|
||||
val db = newDb()
|
||||
assertNull(db.metaGet("last_lane"))
|
||||
db.metaPut("last_lane", "android:chan_1")
|
||||
assertEquals("android:chan_1", db.metaGet("last_lane"))
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m1"))))
|
||||
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General")))
|
||||
db.metaPut("last_lane", "chan_1")
|
||||
assertEquals("chan_1", db.metaGet("last_lane"))
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
|
||||
db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
|
||||
db.clearAll()
|
||||
assertEquals(emptyMap<String, List<MessageItem>>(), db.loadLanes())
|
||||
assertTrue(db.loadLanes().isEmpty())
|
||||
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
|
||||
assertNull(db.metaGet("last_lane"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
|
||||
* `pairing.qr_payload` on the gateway side.
|
||||
*/
|
||||
class PairLinkTest {
|
||||
private val token = "ab".repeat(32) // 64 hex chars
|
||||
|
||||
@Test
|
||||
fun parsesValidLink() {
|
||||
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
|
||||
assertEquals("http://192.168.1.50:8791", link?.url)
|
||||
assertEquals(token, link?.token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsPortTo8791() {
|
||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
|
||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secureOneSelectsHttps() {
|
||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
|
||||
assertEquals("https://10.0.0.5:8443", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemeIsCaseInsensitive() {
|
||||
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
|
||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentDecodesHost() {
|
||||
// A hostname with a space would be %20 on the wire.
|
||||
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
|
||||
assertEquals("http://my host:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingToken() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsEmptyToken() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingHost() {
|
||||
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsBadPort() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongScheme() {
|
||||
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongHost() {
|
||||
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNoAuthority() {
|
||||
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsBoundaryPorts() {
|
||||
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
|
||||
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -11,8 +11,8 @@ create.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Native feel.** Real Android app (Kotlin/Compose), not a WebView. Desktop
|
||||
app that is the same app, resized for a big screen.
|
||||
- **Native feel.** Real native app (Iris on Android, Kotlin/Compose), not a
|
||||
WebView. Desktop app that is the same app, resized for a big screen.
|
||||
- **First-class gateway citizen.** The app is a hermes *messaging platform*, so
|
||||
everything the gateway already does "just works": slash commands, cron
|
||||
delivery, `send_message` routing, coexistence with Telegram/Discord/etc.
|
||||
@@ -40,13 +40,13 @@ Everything in the feature checklist below.
|
||||
## Feature checklist → where it's handled
|
||||
|
||||
| Requirement | Gateway plugin | App |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| Input box, auto-grow (max height) | — | Compose `TextField` + bounded `heightIn` |
|
||||
| Menu button → all slash commands | Dispatches `/…`; serves command catalog | Bottom-sheet menu + `/` autocomplete |
|
||||
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
|
||||
| Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message |
|
||||
| Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble |
|
||||
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=android:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
|
||||
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=iris:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
|
||||
| Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle |
|
||||
| Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview |
|
||||
| Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service |
|
||||
@@ -55,9 +55,9 @@ Everything in the feature checklist below.
|
||||
## Locked decisions (from planning)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
|
||||
| Push backend | **Both** — FCM primary, ntfy fallback (`ANDROID_PUSH_BACKEND`) |
|
||||
| Push backend | **Both** — ntfy default, FCM optional (`IRIS_PUSH_BACKEND`) |
|
||||
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
|
||||
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
|
||||
|
||||
@@ -66,7 +66,7 @@ Everything in the feature checklist below.
|
||||
1. **`hermes-agent/` is a read-only research reference.** It lives next to this
|
||||
folder for study only. It is **git-ignored** and must **never** be committed,
|
||||
pushed, or included in any artifact. Our plugin is *installed* into a live
|
||||
hermes home (`~/.hermes/plugins/android`); we never edit hermes core files.
|
||||
hermes home (`~/.hermes/plugins/iris`); we never edit hermes core files.
|
||||
2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
|
||||
Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start`
|
||||
to install, launch, and debug the app on-device throughout the build.
|
||||
@@ -74,7 +74,7 @@ Everything in the feature checklist below.
|
||||
## Verified environment state (2026-08-19)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| OS | CachyOS (Arch-based), `pacman` present |
|
||||
| JDK | **Not installed** → Milestone M0 (`pacman -S jdk17-openjdk`) |
|
||||
| Android SDK | **Not installed** → M0 (cmdline-tools + sdkmanager) |
|
||||
@@ -88,6 +88,6 @@ Everything in the feature checklist below.
|
||||
## Naming
|
||||
|
||||
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
|
||||
- hermes platform name: **`android`** (the plugin registers `Platform("android")`).
|
||||
- hermes platform name: **`iris`** (the plugin registers `Platform("iris")`).
|
||||
- WS default port: **8790** (configurable).
|
||||
- Default chat id: **`android:default`** (the home channel).
|
||||
- Default chat id: **`default`** (the home channel).
|
||||
+10
-10
@@ -11,8 +11,8 @@
|
||||
│ │ │ │ │
|
||||
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
|
||||
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
|
||||
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │
|
||||
│ │ │ IRIS PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
|
||||
│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
|
||||
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
|
||||
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
|
||||
│ │ │ • media cache │ │ WSS │ │
|
||||
@@ -26,7 +26,7 @@
|
||||
│ │ Google FCM cloud │
|
||||
▼ │ │ │
|
||||
┌────────────────────────┐ │ ▼ │
|
||||
│ ANDROID APP │◄──┴── (wake) ┌──────────┐
|
||||
│ IRIS APP (ANDROID) │◄──┴── (wake) ┌──────────┐
|
||||
│ (Kotlin / Compose) │ WSS │ PHONE │
|
||||
│ • WS client (OkHttp) │◄───────────►│ MIX 2S │
|
||||
│ • ExoPlayer │ │ (API 29) │
|
||||
@@ -40,8 +40,8 @@
|
||||
## Process model
|
||||
|
||||
- **One `hermes gateway` process** hosts the agent core, the session store, the
|
||||
cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket
|
||||
server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`).
|
||||
cron scheduler, *and* our `iris` platform plugin. The plugin's WebSocket
|
||||
server runs on the gateway's asyncio loop (started in `IrisAdapter.connect()`).
|
||||
- **The app is a client.** It *initiates* the WS connection to the gateway
|
||||
(outbound), so no inbound port is needed on the phone. For LAN/remote access
|
||||
the user points the app at the gateway's LAN IP / Tailscale name / a WSS
|
||||
@@ -56,7 +56,7 @@ serves Telegram/Discord/… and cron) and the **`tui_gateway`** JSON-RPC backend
|
||||
(used by the TUI and the existing Electron desktop app). We deliberately use the
|
||||
**messaging gateway** because:
|
||||
|
||||
1. **Cron delivery is native.** Cron jobs resolve `deliver=android:<chat>[:<thread>]`
|
||||
1. **Cron delivery is native.** Cron jobs resolve `deliver=iris:<chat>[:<thread>]`
|
||||
through the platform registry and call our adapter's `send()`. No bridging.
|
||||
2. **`send_message` tool routing** works out of the box (plugin
|
||||
`parse_target_ref_fn`).
|
||||
@@ -72,7 +72,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
|
||||
## Key architectural decisions + rationale
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| **Community-style platform plugin** (`register(ctx)` → `ctx.register_platform`) | Zero hermes-core changes; survives hermes updates; follows `ADDING_A_PLATFORM.md` "Plugin Path". |
|
||||
| **Single WS transport** for chat, streaming, tools, media, pairing, FCM-token | One connection, one auth, one dependency (`websockets`, already core). Media as chunked binary frames avoids adding an HTTP server. |
|
||||
| **`websockets` + `httpx` only** | Both are hermes *core* deps → the plugin adds **zero** new Python dependencies (respects hermes supply-chain pinning policy). |
|
||||
@@ -80,13 +80,13 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
|
||||
| **Reasoning split in the adapter** | The gateway prepends reasoning to the final text when `show_reasoning` is on; the adapter splits the stable prefix into a `reasoning` field so the app renders a clean collapsible block. |
|
||||
| **Channels/threads = `chat_id`/`thread_id`** | The gateway's `SessionSource` already models this; cron delivery already targets `platform:chat_id:thread_id`. We map app concepts onto existing primitives. |
|
||||
| **SQLite outbox + sync cursor** | Offline delivery + reconnect catch-up without re-reading full history. |
|
||||
| **Compose Multiplatform** | Desktop is "the Android app, tweaked" → share protocol/state/UI; only platform services + layout differ. |
|
||||
| **Compose Multiplatform** | Desktop is "the Iris app, tweaked" → share protocol/state/UI; only platform services + layout differ. |
|
||||
|
||||
## Data flow (one turn)
|
||||
|
||||
1. App sends `message.send {text}` (or `/cmd`).
|
||||
2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) →
|
||||
`AndroidAdapter.handle_message(event)`.
|
||||
`IrisAdapter.handle_message(event)`.
|
||||
3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent.
|
||||
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
|
||||
`adapter.send()` (first) / `adapter.edit_message()` (updates) →
|
||||
@@ -103,4 +103,4 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
|
||||
> (not the ACP-only event-native `render_message_event` path). We therefore map
|
||||
> the legacy `send`/`edit_message`/progress calls to our frames. The exact
|
||||
> tool-progress vs commentary classification is verified empirically in M2 by
|
||||
> running the real gateway with a test WS client (see `13-testing.md`).
|
||||
> running the real gateway with a test WS client (see `13-testing.md`).
|
||||
+12
-9
@@ -13,10 +13,10 @@ iris_x_hermes/
|
||||
│
|
||||
├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored)
|
||||
│
|
||||
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/android
|
||||
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/iris
|
||||
│ ├── plugin.yaml # manifest (kind: platform, env vars, home channel)
|
||||
│ ├── __init__.py
|
||||
│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx)
|
||||
│ ├── adapter.py # IrisAdapter(BasePlatformAdapter) + register(ctx)
|
||||
│ ├── ws_server.py # websockets server, connection registry, framing
|
||||
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
|
||||
│ ├── media.py # inbound cache + outbound chunked streaming
|
||||
@@ -50,9 +50,10 @@ iris_x_hermes/
|
||||
## Module responsibilities
|
||||
|
||||
### `gateway-plugin/` (Python)
|
||||
- **`plugin.yaml`** — manifest: `name: android-platform`, `kind: platform`,
|
||||
|
||||
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
|
||||
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
|
||||
- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`.
|
||||
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
|
||||
The heart of the plugin. See `03-gateway-plugin.md`.
|
||||
- **`ws_server.py`** — `websockets` server, per-device connection registry,
|
||||
frame encode/decode, heartbeat, broadcast routing to all connected devices.
|
||||
@@ -61,13 +62,14 @@ iris_x_hermes/
|
||||
- **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound
|
||||
`media.offer`/`media.pull` chunked streaming.
|
||||
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
|
||||
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
|
||||
`NtfyBackend` (reuses hermes ntfy publish). Selected by `ANDROID_PUSH_BACKEND`.
|
||||
- **`push.py`** — `PushBackend` interface; `NtfyBackend` (default) and
|
||||
`FcmBackend` (httpx, FCM HTTP v1). Selected by `IRIS_PUSH_BACKEND`.
|
||||
- **`pairing.py`** — token generation/verification (constant-time), device
|
||||
registry (SQLite), QR payload.
|
||||
- **`search.py`** — FTS5 query bridge over the hermes session store.
|
||||
|
||||
### `app/shared` (Kotlin KMP)
|
||||
|
||||
- **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient`
|
||||
(OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI
|
||||
(design system, screens). ~80% of app code.
|
||||
@@ -77,13 +79,14 @@ iris_x_hermes/
|
||||
window management, `MediaPlayer` actual.
|
||||
|
||||
### `app/androidApp` / `app/desktopApp`
|
||||
|
||||
Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
|
||||
(Desktop). They compose the `shared` UI and inject platform services.
|
||||
|
||||
## Build systems
|
||||
|
||||
- **Python plugin:** no build step (pure Python, stdlib + hermes core deps).
|
||||
Installed by copying/symlinking into `~/.hermes/plugins/android`. Tested with
|
||||
Installed by copying/symlinking into `~/.hermes/plugins/iris`. Tested with
|
||||
hermes's `scripts/run_tests.sh`.
|
||||
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
|
||||
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
|
||||
@@ -124,7 +127,7 @@ keystore.jks
|
||||
|
||||
## Install layout (runtime)
|
||||
|
||||
- **Plugin:** `~/.hermes/plugins/android/` ← copy of `gateway-plugin/`
|
||||
- **Plugin:** `~/.hermes/plugins/iris/` ← copy of `gateway-plugin/`
|
||||
(or a symlink for dev). Discovered by hermes's `PluginManager`.
|
||||
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
|
||||
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
|
||||
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
|
||||
+78
-71
@@ -1,6 +1,6 @@
|
||||
# 03 — Gateway Plugin (Python)
|
||||
|
||||
The plugin is a **community-style hermes platform plugin** named `android`.
|
||||
The plugin is a **community-style hermes platform plugin** named `iris`.
|
||||
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
|
||||
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
||||
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
|
||||
@@ -11,8 +11,8 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
||||
## 3.1 `plugin.yaml` (manifest)
|
||||
|
||||
```yaml
|
||||
name: android-platform
|
||||
label: Android
|
||||
name: iris-platform
|
||||
label: Iris
|
||||
kind: platform
|
||||
version: 0.1.0
|
||||
description: >
|
||||
@@ -22,63 +22,63 @@ description: >
|
||||
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
||||
author: <you>
|
||||
requires_env:
|
||||
- name: ANDROID_TOKEN
|
||||
- name: IRIS_TOKEN
|
||||
description: "Shared pairing token the app presents on connect"
|
||||
prompt: "Android pairing token"
|
||||
prompt: "Iris pairing token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: ANDROID_WS_HOST
|
||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||
prompt: "WS host"
|
||||
- name: IRIS_HTTP_HOST
|
||||
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||
prompt: "HTTP host"
|
||||
password: false
|
||||
- name: ANDROID_WS_PORT
|
||||
description: "WS port (default 8790)"
|
||||
prompt: "WS port"
|
||||
- name: IRIS_HTTP_PORT
|
||||
description: "HTTP port (default 8791)"
|
||||
prompt: "HTTP port"
|
||||
password: false
|
||||
- name: ANDROID_HOME_CHANNEL
|
||||
description: "Default chat id for cron/notification delivery (default android:default)"
|
||||
- name: IRIS_HOME_CHANNEL
|
||||
description: "Default chat id for cron/notification delivery (default default)"
|
||||
prompt: "Home channel"
|
||||
password: false
|
||||
- name: ANDROID_ALLOWED_USERS
|
||||
- name: IRIS_ALLOWED_USERS
|
||||
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
||||
prompt: "Allowed device ids"
|
||||
password: false
|
||||
- name: ANDROID_ALLOW_ALL_USERS
|
||||
- name: IRIS_ALLOW_ALL_USERS
|
||||
description: "Allow any paired device (dev only)"
|
||||
prompt: "Allow all devices? (true/false)"
|
||||
password: false
|
||||
- name: ANDROID_PUSH_BACKEND
|
||||
description: "Push backend: fcm (default) or ntfy"
|
||||
- name: IRIS_PUSH_BACKEND
|
||||
description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
|
||||
prompt: "Push backend"
|
||||
password: false
|
||||
- name: ANDROID_FCM_SERVICE_ACCOUNT
|
||||
- name: IRIS_FCM_SERVICE_ACCOUNT
|
||||
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
||||
prompt: "FCM service account path"
|
||||
password: true
|
||||
- name: ANDROID_FCM_SERVER_KEY
|
||||
- name: IRIS_FCM_SERVER_KEY
|
||||
description: "Legacy FCM server key (fallback if no service account)"
|
||||
prompt: "FCM server key"
|
||||
password: true
|
||||
- name: NTFY_TOPIC
|
||||
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
|
||||
description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
|
||||
prompt: "ntfy topic"
|
||||
password: false
|
||||
- name: NTFY_SERVER_URL
|
||||
description: "ntfy server URL (default https://ntfy.sh)"
|
||||
prompt: "ntfy server URL"
|
||||
password: false
|
||||
- name: ANDROID_WS_CERT
|
||||
description: "TLS cert path for WSS (optional)"
|
||||
prompt: "WSS cert"
|
||||
- name: IRIS_HTTP_CERT
|
||||
description: "TLS cert path for HTTPS (optional)"
|
||||
prompt: "HTTPS cert"
|
||||
password: false
|
||||
- name: ANDROID_WS_KEY
|
||||
description: "TLS key path for WSS (optional)"
|
||||
prompt: "WSS key"
|
||||
- name: IRIS_HTTP_KEY
|
||||
description: "TLS key path for HTTPS (optional)"
|
||||
prompt: "HTTPS key"
|
||||
password: false
|
||||
```
|
||||
|
||||
Behavioral (non-secret) settings live in `config.yaml` under
|
||||
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention,
|
||||
`gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
|
||||
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
|
||||
only.)
|
||||
|
||||
@@ -87,21 +87,21 @@ only.)
|
||||
```python
|
||||
def register(ctx):
|
||||
ctx.register_platform(
|
||||
name="android",
|
||||
label="Android",
|
||||
adapter_factory=lambda cfg: AndroidAdapter(cfg),
|
||||
name="iris",
|
||||
label="Iris",
|
||||
adapter_factory=lambda cfg: IrisAdapter(cfg),
|
||||
check_fn=check_requirements, # passive: websockets importable + token set
|
||||
validate_config=validate_config, # host/port/token present
|
||||
is_connected=is_connected,
|
||||
required_env=["ANDROID_TOKEN"],
|
||||
required_env=["IRIS_TOKEN"],
|
||||
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
||||
setup_fn=interactive_setup, # hermes gateway setup flow
|
||||
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
|
||||
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
||||
cron_deliver_env_var="IRIS_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
|
||||
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
|
||||
allowed_users_env="ANDROID_ALLOWED_USERS",
|
||||
allow_all_env="ANDROID_ALLOW_ALL_USERS",
|
||||
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
|
||||
allowed_users_env="IRIS_ALLOWED_USERS",
|
||||
allow_all_env="IRIS_ALLOW_ALL_USERS",
|
||||
max_message_length=0, # 0 = no limit (WS has none)
|
||||
emoji="📱",
|
||||
pii_safe=False,
|
||||
@@ -123,26 +123,29 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
|
||||
`ensure_deps_fn`.
|
||||
|
||||
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
|
||||
`ANDROID_TOKEN` is set. Never installs.
|
||||
`IRIS_TOKEN` is set. Never installs.
|
||||
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
|
||||
(host/port/home_channel/push_backend) + a `home_channel` key
|
||||
`{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status`
|
||||
`{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
|
||||
and cron home-channel resolution work without instantiating the adapter.
|
||||
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
|
||||
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`.
|
||||
- **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
|
||||
`ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional
|
||||
`:t_<n>` thread suffix; friendly names resolve via the channel directory.
|
||||
Returns `(chat_id, thread_id)` or `None`.
|
||||
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
|
||||
push backend + credentials, prints a QR code (pairing) and the app URL.
|
||||
|
||||
## 3.3 `AndroidAdapter(BasePlatformAdapter)`
|
||||
## 3.3 `IrisAdapter(BasePlatformAdapter)`
|
||||
|
||||
Constructor: `super().__init__(config=config, platform=Platform("android"))`.
|
||||
Constructor: `super().__init__(config=config, platform=Platform("iris"))`.
|
||||
Reads `config.extra` (env overrides win). Initializes: WS server (not started
|
||||
until `connect()`), connection registry, outbox (SQLite under
|
||||
`get_hermes_home()/"android"`), push backend, pairing store, channel directory.
|
||||
`get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- **`connect(*, is_reconnect=False) -> bool`**
|
||||
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`)
|
||||
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`)
|
||||
so two profiles can't bind the same port/identity.
|
||||
- Start the `websockets` server on `host:port` (TLS if cert/key set).
|
||||
- `_mark_connected()`; return True.
|
||||
@@ -150,6 +153,7 @@ until `connect()`), connection registry, outbox (SQLite under
|
||||
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
|
||||
|
||||
### Inbound (app → agent)
|
||||
|
||||
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
|
||||
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
|
||||
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
|
||||
@@ -171,6 +175,7 @@ until `connect()`), connection registry, outbox (SQLite under
|
||||
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
|
||||
|
||||
### Outbound (agent → app)
|
||||
|
||||
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
|
||||
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
|
||||
- If **any** device is connected: broadcast `message` frame to all.
|
||||
@@ -195,7 +200,9 @@ until `connect()`), connection registry, outbox (SQLite under
|
||||
channel directory, return it (used by cron "continuable" threads).
|
||||
|
||||
### Streaming hooks
|
||||
|
||||
The main gateway drives delivery through the **legacy callback path**:
|
||||
|
||||
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
|
||||
`edit_message()` (updates) → `message.start` / `message.update`.
|
||||
- `tool_progress_callback` → progress queue → `send_progress_messages` →
|
||||
@@ -208,29 +215,28 @@ The adapter tracks per-chat **turn state** (in-turn, current streaming
|
||||
`message` vs `tool.*` vs `commentary`. The exact classification markers are
|
||||
verified empirically in M2 (see `13-testing.md`).
|
||||
|
||||
## 3.4 WebSocket server (`ws_server.py`)
|
||||
## 3.4 HTTP server (`http_server.py`)
|
||||
|
||||
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host,
|
||||
port, ssl=ctx)`.
|
||||
- **Handler** per connection:
|
||||
1. Await first frame; must be `hello {token, device_id, device_name, caps,
|
||||
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
|
||||
`error {code:"auth"}` and close.
|
||||
2. On success: register in connection registry
|
||||
(`device_id → {ws, caps, fcm_token}`), send
|
||||
`hello.ack {server_caps, sync_cursor, channels[]}`.
|
||||
3. Loop: decode frames, dispatch to adapter inbound handlers.
|
||||
4. On close: deregister; if no devices remain, ensure pending outbox
|
||||
frames have push fired.
|
||||
- Library: **stdlib `http.server`** (`ThreadingHTTPServer` +
|
||||
`BaseHTTPRequestHandler`) in a daemon thread; bridges into the gateway's
|
||||
asyncio loop via `asyncio.run_coroutine_threadsafe`. Optional TLS via
|
||||
`ssl.SSLContext` (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`). Full design:
|
||||
`19-http-fallback-transport.md`.
|
||||
- **Auth:** `Authorization: Bearer <token>` (constant-time `verify_token`)
|
||||
- device allowlist via `X-Iris-Device`; `401` on failure.
|
||||
- **Endpoints:** `GET /v1/health` (unauthenticated liveness),
|
||||
`POST /v1/frame` (any JSON frame the protocol accepts),
|
||||
`GET /v1/events?cursor=N` (SSE: outbox catch-up + live frames),
|
||||
`GET /v1/poll?cursor=N` (long-poll fallback), `POST /v1/media` +
|
||||
`GET /v1/media/{id}` (media upload/pull).
|
||||
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
||||
devices (no per-chat subscribe; single-user model). Global frames
|
||||
(`channel.*`, `status`) also broadcast to all.
|
||||
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
|
||||
- **Backpressure:** per-connection send queue with a bounded buffer; drop
|
||||
`message.update` (coalesce to latest) under pressure, never drop
|
||||
`message`/`tool.end`/`notification`.
|
||||
- **Limits:** 64 KiB request body cap, per-device token-bucket rate limit
|
||||
(20/s, burst 40) → `429`; media uploads bounded by the per-upload total
|
||||
cap. No CORS (app clients only).
|
||||
|
||||
## 3.5 State & storage (all under `get_hermes_home()/"android"`)
|
||||
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
|
||||
|
||||
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
|
||||
> Never hardcode `~/.hermes`.
|
||||
@@ -245,19 +251,20 @@ verified empirically in M2 (see `13-testing.md`).
|
||||
|
||||
## 3.6 Config resolution
|
||||
|
||||
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
|
||||
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
||||
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
|
||||
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
||||
`max_upload_bytes`, `tls`.
|
||||
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
|
||||
`IRIS_FCM_SERVER_KEY`, `IRIS_HTTP_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
||||
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
|
||||
`http_port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
||||
`max_upload_bytes`, `http_cert`/`http_key`.
|
||||
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
||||
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
||||
so multiplexed profiles don't leak each other's tokens.
|
||||
|
||||
## 3.7 Failure & lifecycle safety
|
||||
|
||||
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
|
||||
- All outbound sends are best-effort; a dead socket latches and the frame falls
|
||||
to the outbox.
|
||||
- `disconnect()` cancels the server task and closes sockets cleanly.
|
||||
- Token/PII redaction in all logs (hermes PII policy).
|
||||
- HTTP server bind failure → non-fatal: log a warning, disable the HTTP leg,
|
||||
show it in the inspector (the plugin keeps working for other platforms).
|
||||
- All outbound sends are best-effort; a dead stream latches and the frame
|
||||
falls to the outbox.
|
||||
- `disconnect()` stops the HTTP server and closes streams cleanly.
|
||||
- Token/PII redaction in all logs (hermes PII policy).
|
||||
+75
-22
@@ -12,7 +12,7 @@ Every frame:
|
||||
"v": 1,
|
||||
"id": 42, // optional; present on requests + their responses
|
||||
"type": "message", // frame type (below)
|
||||
"chat_id": "android:default", // optional; scope for chat-scoped frames
|
||||
"chat_id": "default", // optional; scope for chat-scoped frames
|
||||
"thread_id": "t_123", // optional
|
||||
"payload": { } // type-specific object
|
||||
}
|
||||
@@ -43,7 +43,8 @@ Pairing succeeded.
|
||||
"search":true,"push":"fcm","pickers":true},
|
||||
"sync_cursor":1042,
|
||||
"last_pushed_cursor":1040,
|
||||
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}]
|
||||
"device_token":"9f2c…(64 hex)",
|
||||
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
|
||||
}}
|
||||
```
|
||||
|
||||
@@ -52,12 +53,18 @@ device via the push backend (0 = never). The app skips system notifications
|
||||
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
|
||||
woke the device via push (dedupe, `08-push.md` §8.7).
|
||||
|
||||
`device_token` is the per-device token minted at pairing (docs/09 §9.3):
|
||||
the app stores it and presents it in the `Authorization` header INSTEAD of
|
||||
the shared `IRIS_TOKEN` from then on, so the gateway can revoke one device
|
||||
without affecting the others. Empty when the gateway didn't issue one
|
||||
(legacy).
|
||||
|
||||
### `message`
|
||||
|
||||
A final / standalone message.
|
||||
|
||||
```json
|
||||
{"type":"message","chat_id":"android:default","thread_id":null,
|
||||
{"type":"message","chat_id":"default","thread_id":null,
|
||||
"payload":{
|
||||
"message_id":"m_9001","role":"assistant",
|
||||
"text":"Here is the answer…",
|
||||
@@ -107,7 +114,7 @@ them drop the message(s) from their cache. Also outboxed, so a device that was
|
||||
offline learns of the deletion on its next `sync`.
|
||||
|
||||
```json
|
||||
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null,
|
||||
{"type":"message.deleted","id":30,"chat_id":"default","thread_id":null,
|
||||
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||
```
|
||||
|
||||
@@ -126,7 +133,7 @@ truncated / nothing).
|
||||
|
||||
```json
|
||||
{"type":"tool.start","chat_id":"…","payload":{
|
||||
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}}
|
||||
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"},"emoji":"💻"}}
|
||||
{"type":"tool.progress","chat_id":"…","payload":{"index":3,"name":"terminal","note":"running…"}}
|
||||
{"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
|
||||
"output_preview":"12 passed"}}
|
||||
@@ -135,6 +142,36 @@ truncated / nothing).
|
||||
`args` may be large; the app truncates per its setting. `output_preview` is a
|
||||
short tail (full output is not streamed — it lives in agent history).
|
||||
|
||||
`emoji` is a cosmetic per-tool glyph resolved server-side via hermes'
|
||||
`get_tool_emoji` (active-skin overrides, then the tool registry's per-tool
|
||||
`emoji` — e.g. `read_file` 📖, `write_file` ✍️, `terminal` 💻). Omitted when
|
||||
the tool is unknown, so the app falls back to its own default glyph.
|
||||
|
||||
### `todo.update`
|
||||
|
||||
The agent's **full current todo list** for a chat/thread lane (last-write-wins).
|
||||
Emitted whenever the `todo` tool completes — the tool *result* is the
|
||||
authoritative list, which also covers `merge` writes (whose args carry only
|
||||
the changed items) and read-only calls — and, as a **snapshot**, right after
|
||||
`hello` when a device opens its event stream.
|
||||
|
||||
```json
|
||||
{"type":"todo.update","chat_id":"default","thread_id":null,"payload":{
|
||||
"todos":[
|
||||
{"id":"1","content":"Scaffold the module","status":"completed"},
|
||||
{"id":"2","content":"Wire the store","status":"in_progress"},
|
||||
{"id":"3","content":"Add tests","status":"pending"}
|
||||
]}}
|
||||
```
|
||||
|
||||
`status` is one of `pending | in_progress | completed | cancelled`. The frame
|
||||
is **ephemeral**: it is never outboxed, so a reconnecting device learns the
|
||||
current list from the snapshot (not a replay), and a gateway restart drops it
|
||||
(the agent re-emits on the next `todo` call). The app renders it as a compact
|
||||
scrollable strip above the composer (max 3 lines; auto-scrolls to the item
|
||||
whose status just changed) and hides it once the list is empty or fully
|
||||
resolved.
|
||||
|
||||
### `typing` / `typing.stop`
|
||||
|
||||
```json
|
||||
@@ -156,6 +193,16 @@ In-app banner (foreground) and/or push mirror (background).
|
||||
|
||||
Interactive prompts. App renders a native picker; answers via `picker.select`.
|
||||
|
||||
`picker.choice` is implemented (the generic finite-choice menu used by
|
||||
`/reasoning`, `/fast`, and any future finite-choice slash command — hermes
|
||||
calls the adapter's `send_choice_picker` when the platform supports it).
|
||||
The server runs the command's selection callback on `picker.select` and
|
||||
delivers its reply as a normal `message` in the picker's chat. The frame is
|
||||
outboxed (a reconnecting device re-renders a still-pending picker); pending
|
||||
state is in-memory only, so a gateway restart expires it (a stale
|
||||
`picker.select` is a no-op). With no live device the adapter reports failure
|
||||
and hermes falls back to the text status card.
|
||||
|
||||
```json
|
||||
{"type":"picker.model","chat_id":"…","payload":{
|
||||
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
|
||||
@@ -171,7 +218,7 @@ Channel directory updates. **Broadcast to all connected devices** (no explicit
|
||||
subscribe; the server pushes to every open WS).
|
||||
|
||||
```json
|
||||
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
|
||||
{"type":"channel.created","payload":{"chat_id":"chan_7","name":"Cron Reports",
|
||||
"kind":"channel","parent_chat_id":null}}
|
||||
```
|
||||
|
||||
@@ -185,7 +232,7 @@ title.
|
||||
Response to a `history` request. Returns a page of messages for a chat/thread.
|
||||
|
||||
```json
|
||||
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
|
||||
{"type":"history","id":20,"chat_id":"default","thread_id":null,
|
||||
"payload":{
|
||||
"messages":[
|
||||
{"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000},
|
||||
@@ -239,9 +286,9 @@ Response to a `commands.complete` request. Autocomplete matches for a typed pref
|
||||
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
|
||||
|
||||
```json
|
||||
{"type":"agent.busy","chat_id":"android:default","thread_id":null,
|
||||
{"type":"agent.busy","chat_id":"default","thread_id":null,
|
||||
"payload":{"reason":"processing"}}
|
||||
{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}}
|
||||
{"type":"agent.idle","chat_id":"default","thread_id":null,"payload":{}}
|
||||
```
|
||||
|
||||
`reason` ∈ `processing | tool | waiting_input | cron`.
|
||||
@@ -251,7 +298,7 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`
|
||||
```json
|
||||
{"type":"search.results","id":7,"payload":{
|
||||
"query":"deploy","scope":"all","hits":[
|
||||
{"message_id":"m_123","chat_id":"android:chan_7","thread_id":null,
|
||||
{"message_id":"m_123","chat_id":"chan_7","thread_id":null,
|
||||
"role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}}
|
||||
```
|
||||
|
||||
@@ -270,7 +317,7 @@ The gateway acknowledges that the agent has received and started processing
|
||||
the user's message. The app uses it to show ✓✓ on user bubbles.
|
||||
|
||||
```json
|
||||
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}}
|
||||
{"type":"read.receipt","chat_id":"default","payload":{"message_id":"m_9001"}}
|
||||
```
|
||||
|
||||
Emitted to the originating connection when a `message.send` is accepted for
|
||||
@@ -280,7 +327,13 @@ messages only.
|
||||
### `status`
|
||||
|
||||
Gateway health state. Broadcast to all connected clients at startup
|
||||
(`state: "online"`); `restarting` / `degraded` are reserved for future use.
|
||||
(`state: "online"`) and to late joiners on `hello.ack`. The gateway also
|
||||
broadcasts `state: "restarting"` on its shutdown path (restart/stop), right
|
||||
before closing the sockets — the app posts the "Gateway restarting" chat
|
||||
notice immediately on that frame (the socket can take up to the ~20 s ping
|
||||
timeout to actually drop, so the notice must not wait for the disconnect);
|
||||
a plain network drop shows just the reconnect banner. `degraded` is reserved
|
||||
for future use.
|
||||
|
||||
```json
|
||||
{"type":"status","payload":{"state":"online"}}
|
||||
@@ -308,7 +361,7 @@ First frame; auth + caps.
|
||||
|
||||
```json
|
||||
{"type":"hello","payload":{
|
||||
"token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
|
||||
"token":"<IRIS_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
|
||||
"caps":{"min_protocol":1,"media":true,"push":"fcm"},
|
||||
"fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}}
|
||||
```
|
||||
@@ -318,7 +371,7 @@ First frame; auth + caps.
|
||||
Send text (or a `/slash-command`).
|
||||
|
||||
```json
|
||||
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null,
|
||||
{"type":"message.send","id":10,"chat_id":"default","thread_id":null,
|
||||
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
|
||||
"auto_thread":false}}
|
||||
```
|
||||
@@ -372,8 +425,8 @@ Answer an interactive picker.
|
||||
|
||||
```json
|
||||
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
|
||||
{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}}
|
||||
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}}
|
||||
{"type":"channel.rename","id":15,"chat_id":"chan_7","payload":{"name":"Reports"}}
|
||||
{"type":"channel.set_default","id":16,"chat_id":"chan_7","payload":{}}
|
||||
```
|
||||
|
||||
`channel.delete` is a **hard delete**: the channel/thread row is removed from
|
||||
@@ -385,7 +438,7 @@ removes its threads. The default channel cannot be deleted.
|
||||
|
||||
```json
|
||||
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
|
||||
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}}
|
||||
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"chan_7","thread_id":null}}
|
||||
```
|
||||
|
||||
`scope` ∈ `all | chat`.
|
||||
@@ -397,7 +450,7 @@ broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
|
||||
mark messages as read locally (✓✓ on user bubbles).
|
||||
|
||||
```json
|
||||
{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}}
|
||||
{"type":"read.receipt","payload":{"chat_id":"default","message_id":"m_9001"}}
|
||||
```
|
||||
|
||||
### `history`
|
||||
@@ -405,7 +458,7 @@ mark messages as read locally (✓✓ on user bubbles).
|
||||
Load a page of messages for a chat/thread (initial open, scroll-up pagination).
|
||||
|
||||
```json
|
||||
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
|
||||
{"type":"history","id":20,"chat_id":"default","thread_id":null,
|
||||
"payload":{"before_message_id":"m_8990","limit":50}}
|
||||
```
|
||||
|
||||
@@ -422,7 +475,7 @@ message already gone (pruned by retention) still yields a `message.deleted`
|
||||
broadcast so live caches drop it.
|
||||
|
||||
```json
|
||||
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
|
||||
{"type":"message.delete","id":30,"chat_id":"default","thread_id":null,
|
||||
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||
```
|
||||
|
||||
@@ -447,7 +500,7 @@ Autocomplete for a typed `/prefix`.
|
||||
Stop the current agent turn (abort generation / tool execution).
|
||||
|
||||
```json
|
||||
{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}}
|
||||
{"type":"agent.stop","id":23,"chat_id":"default","thread_id":null,"payload":{}}
|
||||
```
|
||||
|
||||
### `agent.steer`
|
||||
@@ -455,7 +508,7 @@ Stop the current agent turn (abort generation / tool execution).
|
||||
Inject a steering message mid-turn (redirects the agent without a new turn).
|
||||
|
||||
```json
|
||||
{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null,
|
||||
{"type":"agent.steer","id":24,"chat_id":"default","thread_id":null,
|
||||
"payload":{"text":"Actually, focus on the error case."}}
|
||||
```
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
|
||||
while the user is at the bottom.
|
||||
|
||||
**Streaming on/off.** Two levels:
|
||||
- **Gateway side:** hermes `display.platforms.android.streaming` (default
|
||||
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
|
||||
follows global). When off, the app just gets one final `message` frame.
|
||||
- **App side (per device):** Settings → "Streaming" toggle (default on). When
|
||||
off, the app ignores `message.start`/`message.update` frames and
|
||||
@@ -49,11 +49,11 @@ chosen by `reasoning_style` (`gateway/display_config.py:37`):
|
||||
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
||||
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
||||
|
||||
**Plugin config.** Set for the `android` platform:
|
||||
**Plugin config.** Set for the `iris` platform:
|
||||
```yaml
|
||||
display:
|
||||
platforms:
|
||||
android:
|
||||
iris:
|
||||
show_reasoning: true
|
||||
reasoning_style: code # we split on the code-fence form
|
||||
```
|
||||
|
||||
@@ -9,10 +9,10 @@ gateway identity concepts**.
|
||||
|
||||
| App concept | hermes primitive | Example |
|
||||
| --- | --- | --- |
|
||||
| Default chat | home channel `chat_id` | `android:default` |
|
||||
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` |
|
||||
| A user-created channel | a new `chat_id` | `android:chan_7` |
|
||||
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=android:chan_7, thread_id=t_31` |
|
||||
| Default chat | home channel `chat_id` | `default` |
|
||||
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=default, thread_id=t_12` |
|
||||
| A user-created channel | a new `chat_id` | `chan_7` |
|
||||
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=chan_7, thread_id=t_31` |
|
||||
|
||||
- **`chat_id`** = the conversation lane (a channel or the default chat).
|
||||
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
|
||||
@@ -23,10 +23,10 @@ gateway identity concepts**.
|
||||
## 6.2 Default chat
|
||||
|
||||
- On first connect, the plugin ensures a **default channel** exists:
|
||||
`chat_id = ANDROID_HOME_CHANNEL` (default `android:default`), `kind=default`,
|
||||
`chat_id = IRIS_HOME_CHANNEL` (default `default`), `kind=default`,
|
||||
`is_default=true`, name "Default".
|
||||
- It is also the **cron home channel** (`cron_deliver_env_var=
|
||||
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here.
|
||||
IRIS_HOME_CHANNEL`), so `deliver=iris` (bare) routes here.
|
||||
- The app opens the default chat on launch.
|
||||
|
||||
## 6.3 Threads (toggle for overview)
|
||||
@@ -37,7 +37,7 @@ gateway identity concepts**.
|
||||
- **Threads OFF** — flat conversation; all messages use `thread_id=null`.
|
||||
- **Threads ON** — the app groups the conversation into topic-like lanes.
|
||||
Each new "topic" mints a `thread_id` (via `channel.create {kind:thread,
|
||||
parent_chat_id:android:default}` or an implicit thread). The UI shows a
|
||||
parent_chat_id:default}` or an implicit thread). The UI shows a
|
||||
topic switcher (like Telegram topics) above the message list.
|
||||
- Threads are **app-organized** but **gateway-real**: each `thread_id` is a
|
||||
distinct hermes session lane, so context is isolated per thread and cron can
|
||||
@@ -82,7 +82,7 @@ lane (nothing to title / session-scoped, not conversation starters).
|
||||
- **Requirement:** the user creates new channels so **cron job outputs can be
|
||||
delegated to them** instead of the default chat.
|
||||
- **`channel.create {name, kind:"channel"}`** → plugin mints
|
||||
`chat_id = android:chan_<n>`, stores in directory, broadcasts
|
||||
`chat_id = chan_<n>`, stores in directory, broadcasts
|
||||
`channel.created` to all devices. The new channel appears in the channel list.
|
||||
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
|
||||
directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
|
||||
@@ -104,9 +104,9 @@ lane (nothing to title / session-scoped, not conversation starters).
|
||||
- **Cron targeting** (the key payoff): because the plugin registers
|
||||
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
|
||||
`send_message` tool can target any channel/thread:
|
||||
- `deliver="android"` → home (default) channel.
|
||||
- `deliver="android:android:chan_7"` → that channel.
|
||||
- `deliver="android:android:chan_7:t_31"` → that channel's thread.
|
||||
- `deliver="iris"` → home (default) channel.
|
||||
- `deliver="iris:chan_7"` → that channel.
|
||||
- `deliver="iris:chan_7:t_31"` → that channel's thread.
|
||||
- In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron
|
||||
Reports* channel"; the gateway resolves the name via the channel directory.
|
||||
- **In-app affordance:** each channel's menu has "Set as cron target" / shows a
|
||||
@@ -118,7 +118,7 @@ lane (nothing to title / session-scoped, not conversation starters).
|
||||
- Cron resolves delivery targets in `cron/scheduler.py:2148`
|
||||
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
|
||||
calls `tools.send_message_tool.resolve_send_target`, which uses our
|
||||
`parse_target_ref_fn` to parse `android:<chat>[:<thread>]`.
|
||||
`parse_target_ref_fn` to parse `iris:<chat>[:<thread>]`.
|
||||
- Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway
|
||||
running) → our WS `message` frame (or outbox+push if the app is offline).
|
||||
- Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes;
|
||||
|
||||
+80
-32
@@ -1,12 +1,15 @@
|
||||
# 07 — Media (upload, download, playback)
|
||||
|
||||
Media travels **over the WebSocket** as chunked binary frames (decision: no
|
||||
separate HTTP server; keeps the plugin to `websockets` only). Both directions
|
||||
use the same chunking.
|
||||
Media travels **over HTTP** (`POST /v1/media` for upload,
|
||||
`GET /v1/media/{id}` for pull; see `19-http-fallback-transport.md` §19.15).
|
||||
HTTP is the only transport — the WebSocket leg (chunked binary frames) was
|
||||
removed entirely. The contracts below (kinds, sha256, re-sniffing, delivery
|
||||
validation) apply to both directions.
|
||||
|
||||
## 7.1 Kinds & MIME
|
||||
|
||||
`kind` ∈ `image | audio | video | document | voice`.
|
||||
|
||||
- `image` — `image/*` (jpg/png/webp/gif/heic).
|
||||
- `audio` — `audio/*` (mp3/m4a/ogg/…) — music.
|
||||
- `video` — `video/*` (mp4/webm/mov).
|
||||
@@ -20,32 +23,38 @@ receipt (don't trust the client) using hermes helpers
|
||||
## 7.2 Inbound (app → agent) — `media.upload`
|
||||
|
||||
**Flow:**
|
||||
1. App picks a file (SAF) → reads size + MIME.
|
||||
2. App sends `media.upload.start {media_ref, kind, mime, size, filename}`.
|
||||
3. App streams the file as **binary WS frames** (e.g. 256 KiB chunks).
|
||||
4. App sends `media.upload.end {media_ref, sha256}`.
|
||||
5. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
|
||||
|
||||
1. App picks a file (SAF) → reads size + MIME, computes sha256.
|
||||
2. App `POST /v1/media` with the raw file body; metadata in
|
||||
`X-Iris-Media-*` headers (`media_ref`, `kind`, `mime`, `filename`,
|
||||
`sha256`).
|
||||
3. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
|
||||
cache via hermes `cache_*_from_bytes`:
|
||||
- image → `cache_image_from_bytes`
|
||||
- audio/voice → `cache_audio_from_bytes`
|
||||
- video → `cache_video_from_bytes`
|
||||
- document → `cache_document_from_bytes`
|
||||
→ returns a local path.
|
||||
5b. Plugin replies `media.upload.ack {ok, media_ref}` (failures use `error`).
|
||||
6. The path is attached to the next `message.send` via `media_refs`, becoming
|
||||
- document → `cache_document_from_bytes`
|
||||
→ returns a local path.
|
||||
4. Plugin replies `media.upload.ack {ok, media_ref}` (failures use `error`).
|
||||
5. The path is attached to the next `message.send` via `media_refs`, becoming
|
||||
`MessageEvent.media_urls` + `media_types`
|
||||
(`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then
|
||||
read the file.
|
||||
|
||||
**Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size`
|
||||
(`base.py:758/779`) enforce the cap; over-limit → `error {code:"media_too_large"}`.
|
||||
**Limits:** two caps apply — the plugin's `max_upload_bytes` (default
|
||||
100 MiB) and hermes's `gateway.max_inbound_media_bytes` (default 128 MiB,
|
||||
enforced by `get_inbound_media_max_bytes()` / `validate_inbound_media_size`,
|
||||
`base.py:758/779`); over-limit → 413 + `error {code:"media_too_large"}`.
|
||||
Effective limit is the **min** of both; see §7.7. (The 1 MiB
|
||||
`MAX_BODY_BYTES` cap applies to JSON *frame* bodies only, not media uploads.)
|
||||
|
||||
**Backpressure:** large uploads use the WS flow control; the plugin reads
|
||||
binary frames into a temp file (not memory) to bound RAM.
|
||||
**Single-shot:** no chunking/resumability — HTTP carries the body; single-user
|
||||
scale makes a one-shot upload sufficient.
|
||||
|
||||
## 7.3 Outbound (agent → app) — `media.offer` / `media.pull`
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. Agent produces/references media (e.g. generates an image, or replies with a
|
||||
`MEDIA:` tag / image URL). hermes base `extract_media` / `extract_images`
|
||||
(`base.py:4439/4884`) pull these out and call the adapter's
|
||||
@@ -54,14 +63,13 @@ binary frames into a temp file (not memory) to bound RAM.
|
||||
2. Adapter stages the file in the media cache, mints a `media_id`, and emits
|
||||
`media.offer {media_id, kind, mime, size, filename}` (inside/with the
|
||||
`message` frame's `media[]`).
|
||||
3. App sends `media.pull {media_id}`.
|
||||
4. Plugin streams the file as **binary WS frames**; ends with
|
||||
`media.pull.end {ok:true}`.
|
||||
5. App writes to its cache dir and hands the path to the player/viewer.
|
||||
3. App `GET /v1/media/{id}` — the full file body.
|
||||
4. App writes to its cache dir and hands the path to the player/viewer.
|
||||
|
||||
**Security:** `validate_media_delivery_path` (`base.py:1684`) + the media
|
||||
delivery root/recency/denied-path checks (`base.py:1312-1480`) ensure the plugin
|
||||
only serves files hermes is allowed to deliver (no arbitrary file read).
|
||||
only serves files hermes is allowed to deliver (no arbitrary file read). The
|
||||
delivery-path check is re-run **at pull time**, not just at offer time.
|
||||
|
||||
## 7.4 Live playback (AI-sent music/video)
|
||||
|
||||
@@ -79,19 +87,59 @@ only serves files hermes is allowed to deliver (no arbitrary file read).
|
||||
a desktop backend (see `11-desktop-app.md`): a `libmpv`/`mpv`-backed surface
|
||||
or a WebView fallback for video, and a desktop audio player for music.
|
||||
|
||||
## 7.5 Chunking parameters
|
||||
## 7.5 Integrity
|
||||
|
||||
- Chunk size: **256 KiB** (tunable).
|
||||
- Binary frames carry raw bytes only; framing/metadata is in the JSON header +
|
||||
end frames.
|
||||
- Reassembly is ordered (WS preserves order); a gap/corruption → abort +
|
||||
`error {code:"internal"}` + retry the whole transfer.
|
||||
- `sha256` in `media.upload.end` / a size check on pull verify integrity.
|
||||
- Upload: `sha256` (precomputed by the app, sent in `X-Iris-Media-Sha256`)
|
||||
is verified by the plugin; mismatch → `media.upload.ack {ok:false}`.
|
||||
- Pull: the app checks the received size against the offered `size`.
|
||||
- A failed transfer → retry the whole upload (single-shot, no resume).
|
||||
|
||||
## 7.6 App-side storage
|
||||
|
||||
- Cache dir: app-specific external cache (`getExternalCacheDir()/media`).
|
||||
- LRU eviction by size (configurable, default 500 MB) so old media doesn't fill
|
||||
the device.
|
||||
- Cache dir: app-specific external cache (`getExternalCacheDir()/media`,
|
||||
internal `cacheDir` fallback; desktop: `~/.iris/cache/media`).
|
||||
- LRU eviction by size (hardcoded 500 MB, `MediaCacheJvm.kt` `maxBytes`) so old
|
||||
media doesn't fill the device.
|
||||
- A `MediaRepository` tracks `{media_id, local_path, kind, size, ts}` in Room so
|
||||
bubbles can re-render players after process death.
|
||||
bubbles can re-render players after process death.
|
||||
|
||||
## 7.7 Size limits & where files live
|
||||
|
||||
**Inbound (app → agent) — two caps, effective limit is the min:**
|
||||
|
||||
| Cap | Default | Set via | Enforced by |
|
||||
| --- | --- | --- | --- |
|
||||
| Plugin `max_upload_bytes` | 100 MiB | `gateway.platforms.iris.extra.max_upload_bytes` (config.yaml) | `http_server.py` (Content-Length pre-check) + `media.py` `UploadSession.feed` (mid-stream) → `media_too_large` |
|
||||
| Hermes `gateway.max_inbound_media_bytes` | 128 MiB | `gateway.max_inbound_media_bytes` (config.yaml) | `validate_inbound_media_size` inside `cache_*_from_bytes` (`base.py:758/779`) → `media_too_large` |
|
||||
|
||||
Both are **pure config** — raising the limit needs no code change in the plugin
|
||||
or hermes-agent. The app itself has no upload cap.
|
||||
|
||||
**Why the caps exist:** the upload is streamed to a temp file (disk, bounded
|
||||
RAM in flight), but at completion the plugin reads the **entire blob into
|
||||
memory** (`UploadSession.read_bytes()` → `cache_*_from_bytes` →
|
||||
`write_bytes`), so peak RAM ≈ file size per upload. Hermes's cap exists to
|
||||
prevent OOM-killing the gateway (comment at `base.py:740-749`).
|
||||
|
||||
**Inbound storage (gateway host):**
|
||||
|
||||
- In flight: temp file `upl_*` under `~/.hermes/iris/media/tmp` (removed after
|
||||
completion or failure).
|
||||
- After caching: hermes media cache — `~/.hermes/cache/images/img_<uuid12><ext>`,
|
||||
`cache/audio/audio_<uuid12><ext>`, `cache/videos/video_<uuid12><ext>`,
|
||||
`cache/documents/doc_<uuid12>_<original filename>`. Video/image/audio lose
|
||||
their original filename; documents keep it.
|
||||
- Lifetime: hermes `cleanup_video_cache(max_age_hours=24)` deletes videos older
|
||||
than 24 h.
|
||||
|
||||
**Outbound (agent → app) — no size cap at the gateway.** `media.offer` carries
|
||||
metadata; `GET /v1/media/{id}` streams the full file in 256 KiB chunks
|
||||
regardless of size. The only constraints are the delivery-path re-validation at
|
||||
pull time and the 24 h offer TTL (`MediaStore.prune_outbound`).
|
||||
|
||||
**The effective outbound limit is set by the app:** the device media cache is
|
||||
LRU-capped at 500 MB (§7.6) and `evict()` runs right after each pull completes.
|
||||
A single file > 500 MB makes the eviction loop delete everything — *including
|
||||
the file it just downloaded*. So files > ~500 MB arrive but are immediately
|
||||
discarded. To retain large agent→app files, raise `maxBytes` in
|
||||
`MediaCacheJvm.kt` (or exempt files from eviction).
|
||||
+46
-10
@@ -1,17 +1,51 @@
|
||||
# 08 — Push Notifications, Outbox & Sync
|
||||
|
||||
The gateway can't reach a sleeping phone directly. Push goes through a cloud
|
||||
relay. **Decision: FCM primary, ntfy fallback** (`ANDROID_PUSH_BACKEND`).
|
||||
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
|
||||
Privacy: FCM push metadata (notification title, device token) is routed
|
||||
through Google's servers — for truly private communication use ntfy
|
||||
(self-hosted), which keeps everything on your own infrastructure.
|
||||
|
||||
## 8.1 When push fires
|
||||
|
||||
- A frame targets a `chat_id` whose device is **disconnected** (WS closed) →
|
||||
drop to **outbox** + fire **push**.
|
||||
- A frame targets a `chat_id` whose device is **disconnected** (no live
|
||||
SSE/long-poll subscriber) → drop to **outbox** + fire **push** — with one
|
||||
refinement, *turn-aware push* (below).
|
||||
- Also fire push for high-priority foreground events the user should see even if
|
||||
the app is backgrounded (approvals, clarifies, cron completions) — the app
|
||||
decides whether to also show an in-app banner.
|
||||
- If the device is **connected**, no push (the live frame is enough).
|
||||
|
||||
### 8.1.1 Turn-aware push (one push per turn, final answer as body)
|
||||
|
||||
An agent turn can span minutes and emit several completed status messages
|
||||
("researching X…", "found Y…", "writing findings…", final answer). Pushing each
|
||||
parked message would spam an offline user with the steps in between. So while
|
||||
the agent's turn is **in flight** (hermes holds the typing indicator on from
|
||||
turn start until the handler's `finally` at turn end), normal-priority
|
||||
`message` / `message.stop` / `media.offer` frames that park with no live
|
||||
device are **held back** per chat instead of pushing; the latest one is pushed
|
||||
when the turn ends (`stop_typing`), so the offline user gets **one push with
|
||||
the final answer**. Details:
|
||||
|
||||
- Turn state is tracked per `chat_id` from the typing indicator
|
||||
(`send_typing` → in flight, `stop_typing` → ended; hermes fires
|
||||
`stop_typing` in the handler's `finally`, after the final send, so the
|
||||
flush always sees the final frame).
|
||||
- The held-back frame is still parked in the outbox — sync catch-up is
|
||||
unaffected; only the push is deferred.
|
||||
- **High-priority notifications** (approval/clarify/cron) push immediately,
|
||||
even mid-turn — they need user action.
|
||||
- If the device **reconnects mid-turn** (SSE/long-poll open), the held-back
|
||||
push is dropped: the app syncs the parked frames and must not get a
|
||||
duplicate push at turn end.
|
||||
- If the turn ends while the device is live, nothing is pushed (the frames
|
||||
were delivered live / synced).
|
||||
- Turns without a typing indicator (e.g. typing disabled in config) and
|
||||
non-turn deliveries (cron, standalone sends) push immediately as before.
|
||||
- Best-effort: a gateway crash mid-turn loses the held-back push (the frames
|
||||
remain in the outbox and sync on reconnect).
|
||||
|
||||
## 8.2 `PushBackend` interface (`push.py`)
|
||||
|
||||
```python
|
||||
@@ -23,13 +57,14 @@ class PushBackend(Protocol):
|
||||
def configured(self) -> bool: ...
|
||||
```
|
||||
|
||||
Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
|
||||
Selected at adapter init by `IRIS_PUSH_BACKEND` (`ntfy` default, `fcm`).
|
||||
|
||||
### 8.2.1 `FcmBackend` (optional; metadata via Google)
|
||||
|
||||
### 8.2.1 `FcmBackend` (primary)
|
||||
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
|
||||
account** (`ANDROID_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
|
||||
OAuth2 access token (cached, refreshed before expiry).
|
||||
- Fallback: legacy **server key** (`ANDROID_FCM_SERVER_KEY`) if no service
|
||||
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
|
||||
OAuth2 access token (cached, refreshed before expiry).
|
||||
- Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service
|
||||
account (simpler, but legacy).
|
||||
- Target = the device's **FCM token** (registered via `hello` /
|
||||
`fcm.register`, stored in `devices.db`).
|
||||
@@ -42,7 +77,8 @@ Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
|
||||
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
|
||||
devices).
|
||||
|
||||
### 8.2.2 `NtfyBackend` (fallback, self-host friendly)
|
||||
### 8.2.2 `NtfyBackend` (default, self-host friendly)
|
||||
|
||||
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
|
||||
- Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
|
||||
`httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a
|
||||
@@ -132,4 +168,4 @@ device push watermark:
|
||||
Residual edge: FCM is at-least-once, so a lost device ack can still produce a
|
||||
duplicate *system-displayed* notification (two `FCM-Notification:*` ids). The
|
||||
designed evolution is the data-only push option (§8.2.1), which moves display
|
||||
into the app and lets it use a stable per-message notification id.
|
||||
into the app and lets it use a stable per-message notification id.
|
||||
+89
-33
@@ -13,21 +13,24 @@
|
||||
## 9.2 Pairing flow
|
||||
|
||||
1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either
|
||||
uses an existing `ANDROID_TOKEN` or generates a fresh high-entropy token
|
||||
uses an existing `IRIS_TOKEN` or generates a fresh high-entropy token
|
||||
(e.g. 32 bytes → 64 hex chars) and stores it in `.env`.
|
||||
2. **Present to the app.** Two options:
|
||||
- **QR code:** the setup prints a QR encoding
|
||||
`iris://pair?host=<lan-ip>&port=8790&token=<token>` (or a WSS URL). The
|
||||
phone scans it with the app's camera (or a system scanner) → pre-fills
|
||||
`iris://pair?host=<lan-ip>&port=8791&secure=0&token=<token>` (or a WSS
|
||||
URL when `secure=1`). The phone scans it with the app's **Scan QR**
|
||||
button (or a system scanner → `iris://pair` deep link) → pre-fills
|
||||
settings.
|
||||
- **Manual:** user types the server URL + token in the app's Connect screen.
|
||||
3. **App connects.** First WS frame is `hello {token, device_id, device_name,
|
||||
caps, fcm_token?}`.
|
||||
4. **Server verifies.** Constant-time compare of `token` vs `ANDROID_TOKEN`
|
||||
4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
|
||||
(`hmac.compare_digest`). Optionally check `device_id` against
|
||||
`ANDROID_ALLOWED_USERS` (if set) or `ANDROID_ALLOW_ALL_USERS`.
|
||||
5. **On success:** register the device in `devices.db`, send `hello.ack`.
|
||||
**On failure:** send `error {code:"auth"}` and close.
|
||||
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
|
||||
5. **On success:** register the device in `devices.db`, **mint its
|
||||
per-device token** (if it has none yet) and return it in
|
||||
`hello.ack.device_token`. **On failure:** send `error {code:"auth"}`
|
||||
and close.
|
||||
|
||||
`device_id` is a stable, app-generated UUID (persisted in the app's
|
||||
secure storage). It identifies the device for routing + push, **not** as a
|
||||
@@ -35,37 +38,89 @@ security principal (the token is).
|
||||
|
||||
## 9.3 Auth model
|
||||
|
||||
- **Token = the security principal.** Any connection presenting the valid
|
||||
`ANDROID_TOKEN` is authorized (it's the user's own token).
|
||||
- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated
|
||||
`device_id`s) restricts which *devices* may connect even with the token —
|
||||
useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the
|
||||
allowlist (dev only).
|
||||
- **Per-device tokens (stretch):** mint a unique token per device at pairing
|
||||
(revocable) instead of one shared token. v1 uses the shared token + optional
|
||||
device allowlist.
|
||||
- **Re-pairing:** rotating `ANDROID_TOKEN` invalidates all devices; they must
|
||||
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
|
||||
- **Two tokens, one principal per device.**
|
||||
- **Shared `IRIS_TOKEN` (bootstrap):** the setup token from
|
||||
`hermes gateway setup`. It authorizes *pairing* — a NEW device (no row
|
||||
in `devices.db` yet) presents it to connect, and the gateway mints a
|
||||
per-device token for it (returned in `hello.ack.device_token`). It
|
||||
keeps working for devices that never received a per-device token
|
||||
(legacy apps), so an upgrade never bricks a pairing.
|
||||
- **Per-device token (revocable):** minted once at pairing
|
||||
(`DeviceRegistry.issue_token`, 64 hex chars, stored in the `devices`
|
||||
table of `devices.db`). The app stores it in secure storage and
|
||||
presents it INSTEAD of the shared token from the next request on
|
||||
(`Authorization: Bearer <device-token>`). Both tokens are compared in
|
||||
constant time (`verify_token`); a revoked device is rejected before
|
||||
either comparison runs.
|
||||
- **Per-device revocation.** Two control surfaces (run on the gateway host):
|
||||
- **Setup flow** — `hermes gateway setup` → *Iris*: on an existing setup
|
||||
(devices already paired) it asks **"Remove a paired device?"** (default
|
||||
No). If yes: a numbered select menu (name, device id, last seen) whose
|
||||
LAST option is *Exit* (leaves the removal loop, continues the setup);
|
||||
picking a device asks for confirmation, then returns to the menu so
|
||||
several devices can be removed in a row.
|
||||
- **CLI** — `gateway-plugin/tools/iris_devices.py`:
|
||||
- `list` — paired devices (id, name, token minted?, last seen) + revoked ids.
|
||||
- `revoke <device_id>` — drops the device's row (token, push tokens,
|
||||
cursor) AND adds its id to the `revoked` denylist: the device can no
|
||||
longer connect with its device token **or** the shared token, while
|
||||
every other device is unaffected. This is the isolation primitive a
|
||||
shared token alone can't provide (a compromised device can't be cut
|
||||
off without rotating the token for everyone).
|
||||
- `unrevoke <device_id>` — removes it from the denylist so it can pair
|
||||
again (a fresh token is minted at the next pairing).
|
||||
- `reissue <device_id>` — rotates the device's token (the old one stops
|
||||
working; the app picks up the new one on its next (re)connect via
|
||||
`hello.ack`).
|
||||
Re-pairing a revoked device also works by giving the app a fresh
|
||||
`device_id` (e.g. `adb shell pm clear dev.iris.app`), which bootstraps
|
||||
with the shared token like any new device.
|
||||
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
|
||||
`device_id`s) restricts which *devices* may connect even with a valid
|
||||
token — useful if the shared token is exposed. `IRIS_ALLOW_ALL_USERS=true`
|
||||
disables the allowlist (dev only).
|
||||
- **Re-pairing / rotation.** Rotating `IRIS_TOKEN` no longer invalidates
|
||||
paired devices: they authenticate with their per-device tokens, which
|
||||
survive the rotation. Only bootstrap of NEW devices needs the new shared
|
||||
token. (Legacy devices without a per-device token still re-pair, as
|
||||
before.)
|
||||
|
||||
## 9.4 Transport security
|
||||
|
||||
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
|
||||
- **Default (LAN/dev):** plain `http://` on the trusted LAN. Fine for a home
|
||||
network.
|
||||
- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY`
|
||||
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user
|
||||
confirms fingerprint on first pair, like a SSH host key).
|
||||
- **HTTPS (recommended for remote):** set `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`
|
||||
(self-signed or CA-signed). CA-signed certs work out of the box.
|
||||
For a **self-signed** cert the app shows its SHA-256 fingerprint on first
|
||||
pair (like a SSH host key); once the user confirms it, the fingerprint is
|
||||
pinned in secure storage (`SecureStore.pinnedCertFingerprint`) and the
|
||||
app's `PinningTrustManager` accepts exactly that certificate from then on
|
||||
(hostname verification still applies). A *changed* certificate fails
|
||||
again with a fresh confirm request — the user must re-confirm, like a
|
||||
changed SSH host key. No system trust-store install needed. Note: the cert
|
||||
must carry a **SAN** for the URL host (OkHttp's hostname verifier rejects
|
||||
CN-only certs even when pinned) — e.g. `openssl req -x509 ... -addext
|
||||
"subjectAltName=DNS:myhost,IP:192.168.1.10"`.
|
||||
- **Remote reachability options** (documented, user's choice):
|
||||
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
|
||||
app connects over the private mesh. No public exposure.
|
||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
||||
at the edge, forward WS to `127.0.0.1:8790`.
|
||||
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
||||
at the edge, forward to `127.0.0.1:8791`.
|
||||
- **Public bind** (`0.0.0.0`) + HTTPS + strong token — last resort.
|
||||
- **HTTP transport (docs/19):** the gateway serves the same frames over plain
|
||||
HTTP (`IRIS_HTTP_PORT`, default 8791) — the only device-facing transport.
|
||||
It shares the same lock as everything else: the same Bearer token
|
||||
(constant-time `verify_token`) + the same device allowlist
|
||||
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit.
|
||||
Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
||||
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
||||
not reflect tokens, device ids, or versions).
|
||||
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
||||
in secure storage.
|
||||
|
||||
## 9.5 Secret & PII handling
|
||||
|
||||
- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy
|
||||
- **Tokens/keys never logged.** Redact `IRIS_TOKEN`, FCM tokens/keys, ntfy
|
||||
tokens in all log output (hermes PII policy; `agent/redact.py` patterns).
|
||||
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
|
||||
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
|
||||
@@ -76,7 +131,7 @@ security principal (the token is).
|
||||
|
||||
## 9.6 Profile safety
|
||||
|
||||
- All plugin state lives under `get_hermes_home()/"android"` (profile-aware).
|
||||
- All plugin state lives under `get_hermes_home()/"iris"` (profile-aware).
|
||||
- Secrets are read with the scope-aware `_get_scoped_secret` pattern (see
|
||||
`plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak
|
||||
each other's tokens (fail-closed under `gateway.multiplex_profiles`).
|
||||
@@ -102,16 +157,17 @@ M7 research pass. "verified" = implemented and covered by
|
||||
"gap" = known limitation with the planned mitigation.
|
||||
|
||||
| # | Item | Status | Evidence / mitigation |
|
||||
|---|------|--------|-----------------------|
|
||||
| --- | ------ | -------- | ----------------------- |
|
||||
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
|
||||
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) |
|
||||
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
|
||||
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`http_server.py`, `broadcast`/`send_to`). Inbound: per-device token bucket on JSON frames (20/s, burst 40) → `429` on exceed (`http_server.py`, `_rate_limited`); media uploads bounded by the per-request body cap + per-upload total cap (see gap 1) |
|
||||
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | Per-request body cap in `http_server.py`; per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
|
||||
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
|
||||
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
|
||||
| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default |
|
||||
| 6 | HTTPS + cert pinning for remote | implemented | TLS supported server-side (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`, `http_server.py`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `http://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
|
||||
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
|
||||
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
||||
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap |
|
||||
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
||||
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: per-device token bucket in `http_server.py` (`_rate_limited`). Media uploads are bounded by the per-request body cap + the per-upload total cap |
|
||||
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
|
||||
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
|
||||
| 12 | Gap: in-app QR scanner | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later |
|
||||
| 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
|
||||
| 13 | Gap: per-device tokens (revocation) | implemented | `DeviceRegistry.issue_token` mints a 64-hex per-device token at pairing (stored in `devices.db`, returned in `hello.ack.device_token`); the app stores it in secure storage and presents it instead of the shared `IRIS_TOKEN` (bootstrap path unchanged). `tools/iris_devices.py revoke <device_id>` drops the device + denylists its id (rejected even with the shared token); `unrevoke`/`reissue` for re-pairing/rotation. `docs/09` §9.3 |
|
||||
+21
-4
@@ -1,4 +1,4 @@
|
||||
# 10 — Android App (Kotlin + Jetpack Compose)
|
||||
# 10 — Iris App (Android; Kotlin + Jetpack Compose)
|
||||
|
||||
Native client. Lives in the Compose Multiplatform project at `app/`; the bulk of
|
||||
the code is in `app/shared` (commonMain) so the Desktop app reuses it.
|
||||
@@ -115,7 +115,7 @@ app/shared/src/
|
||||
|
||||
- `ToolCard` renders `tool.start/progress/end` frames.
|
||||
- The gateway always supplies the **full** tool data: it forces
|
||||
`display.platforms.android.tool_progress: verbose` (so the progress line
|
||||
`display.platforms.iris.tool_progress: verbose` (so the progress line
|
||||
carries the full args JSON → `tool.start.args`) and captures each completed
|
||||
call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
|
||||
`duration` / `ok`). The app decides how much to show.
|
||||
@@ -257,8 +257,17 @@ payloads** so the schema does not drift with the Kotlin model fields
|
||||
|
||||
- `message(lane PK, id PK, ts, payload)` — one row per persisted
|
||||
`MessageItem`; `lane` is the lane key (`chatId` or `chatId::threadId`),
|
||||
`ts` for ordering. Tool cards and local system notices are **not**
|
||||
persisted (ephemeral; they are not part of `history` either).
|
||||
`ts` for ordering. Local system notices are **not** persisted (ephemeral).
|
||||
- `tool(lane PK, id PK, seq, payload)` — one row per persisted `ToolItem`
|
||||
(tool-activity card). Tool cards are **not** part of the gateway's
|
||||
`history` (which carries final messages only), so the app persists them
|
||||
itself to restore them across a restart. The payload carries `anchor_id`
|
||||
(the id of the message the card follows — the last non-streaming message
|
||||
when the tool started); on load the card is inserted after its anchor, so
|
||||
the order user message → tool card → answer survives a restart. A card
|
||||
whose anchor is gone (deleted message) falls to the end of the lane; an
|
||||
open card (process died before `tool.end`) is restored closed as
|
||||
interrupted.
|
||||
- `channel(chat_id PK, payload)` — the whole channel directory (channels +
|
||||
threads), so the drawer works offline.
|
||||
- `meta(key PK, value)` — small UI state (currently: `last_lane`, the
|
||||
@@ -308,5 +317,13 @@ Storage: `AndroidSqliteDriver` (app database dir) on Android,
|
||||
- First launch → **Connect**: server URL + token (or scan QR). "Test connection"
|
||||
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
|
||||
auth leg must be exercised). On success → save (secure storage) → main.
|
||||
- **Scan QR** (Android only, `docs/20`): a button below the token field opens
|
||||
`QrScanActivity` (CameraX + ML Kit, on-device, no Play services), requests the
|
||||
`CAMERA` permission, and pre-fills URL + token from the decoded
|
||||
`iris://pair…` payload via `PairLink.parse`. It never auto-connects — the
|
||||
user still taps "Test & Connect". A non-pairing QR sets an error and leaves
|
||||
the fields untouched. The same payload also arrives as an `iris://pair` deep
|
||||
link (system scanner / other phones) and pre-fills the screen the same way.
|
||||
Hidden on desktop (no camera).
|
||||
- States: connecting / connected / reconnecting / degraded / auth-failed — each
|
||||
with honest copy and a way out.
|
||||
@@ -1,13 +1,13 @@
|
||||
# 11 — Desktop App (Kotlin + Compose Multiplatform)
|
||||
|
||||
The desktop app is **the Android app, tweaked for a big screen**. It reuses the
|
||||
The desktop app is **the Iris app, tweaked for a big screen**. It reuses the
|
||||
entire `app/shared` module (protocol, network, repositories, state, most UI) and
|
||||
only adds desktop platform services + a wider default layout.
|
||||
|
||||
## 11.1 What's shared vs desktop-specific
|
||||
|
||||
| Layer | Shared (commonMain) | Desktop-specific (desktopMain) |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| Protocol / WS client | ✅ | — |
|
||||
| Repositories / state | ✅ | — |
|
||||
| Most Compose UI | ✅ | layout tweaks, keyboard shortcuts |
|
||||
@@ -62,9 +62,9 @@ only adds desktop platform services + a wider default layout.
|
||||
- The desktop app connects to the **same** gateway WS server as the phone (the
|
||||
user's home server / Tailscale). It does **not** spawn its own backend (unlike
|
||||
hermes's existing Electron desktop, which spawns `hermes serve`) — our
|
||||
desktop is a pure client of the messaging gateway, matching the Android app.
|
||||
desktop is a pure client of the messaging gateway, matching the Iris app.
|
||||
|
||||
## 11.5 Parity checklist (same functionality as Android)
|
||||
## 11.5 Parity checklist (same functionality as the Iris app)
|
||||
|
||||
- [ ] Streaming, reasoning block, tool cards (3-level verbosity), commentary.
|
||||
- [ ] Channels + threads + new channel + cron target.
|
||||
@@ -72,4 +72,4 @@ only adds desktop platform services + a wider default layout.
|
||||
- [ ] Media attach + live playback (via desktop player).
|
||||
- [ ] Slash command menu + autocomplete + interactive pickers.
|
||||
- [ ] Push (tray + OS notifications) + outbox/sync.
|
||||
- [ ] Pairing/Connect screen (URL + token, WSS cert pin).
|
||||
- [ ] Pairing/Connect screen (URL + token, WSS cert pin).
|
||||
+41
-29
@@ -9,6 +9,7 @@ First-time setup on a machine (verified baseline: CachyOS/Arch, `pacman`,
|
||||
pacman -S jdk17-openjdk
|
||||
java -version # expect 17.x
|
||||
```
|
||||
|
||||
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the
|
||||
Android toolchain; 21 also works but 17 is the safe floor.)
|
||||
|
||||
@@ -28,10 +29,12 @@ export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-
|
||||
sdkmanager --licenses
|
||||
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
|
||||
```
|
||||
|
||||
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide;
|
||||
`platform-tools` from the SDK is fine too (whichever is first on `PATH`).
|
||||
|
||||
Create `app/local.properties`:
|
||||
|
||||
```
|
||||
sdk.dir=/home/<you>/android-sdk
|
||||
```
|
||||
@@ -39,10 +42,12 @@ sdk.dir=/home/<you>/android-sdk
|
||||
## 12.3 Gradle
|
||||
|
||||
No system install — use the project wrapper:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew tasks # first run downloads the wrapper distribution
|
||||
```
|
||||
|
||||
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
|
||||
|
||||
## 12.4 hermes environment (for the plugin + running the gateway)
|
||||
@@ -53,15 +58,19 @@ uv sync # creates .venv with all core deps (websockets, httpx,
|
||||
source .venv/bin/activate
|
||||
hermes --version # sanity
|
||||
```
|
||||
|
||||
- Run the gateway with the plugin:
|
||||
|
||||
```bash
|
||||
# install the plugin (dev: symlink)
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/android
|
||||
hermes gateway status # should list "android"
|
||||
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/iris
|
||||
hermes gateway status # should list "iris"
|
||||
hermes gateway # run
|
||||
```
|
||||
|
||||
- Tests use hermes's hermetic runner (never bare `pytest`):
|
||||
|
||||
```bash
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
```
|
||||
@@ -73,43 +82,46 @@ hermes --version # sanity
|
||||
`dev.iris.app`). Download `google-services.json` → `app/androidApp/`.
|
||||
3. Create a **service account** (Project settings → Service accounts → Generate
|
||||
new private key) → download the JSON. Store its path in
|
||||
`ANDROID_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`).
|
||||
`IRIS_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`).
|
||||
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
|
||||
registers it via `hello` / `fcm.register`.
|
||||
|
||||
> Skip Firebase → set `ANDROID_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
|
||||
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
|
||||
> Skip Firebase → the default is already ntfy: leave `IRIS_PUSH_BACKEND` unset
|
||||
> (or set it to `ntfy`) and configure `NTFY_TOPIC` / `NTFY_SERVER_URL`
|
||||
> (self-host ntfy or use ntfy.sh). See `08-push.md`.
|
||||
|
||||
## 12.6 Environment variables (summary)
|
||||
|
||||
**Secrets (`~/.hermes/.env`):**
|
||||
|
||||
```
|
||||
ANDROID_TOKEN=<64-hex>
|
||||
ANDROID_PUSH_BACKEND=fcm # or ntfy
|
||||
ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
||||
# ANDROID_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
||||
IRIS_TOKEN=<64-hex>
|
||||
IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google)
|
||||
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
||||
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
||||
# NTFY_TOPIC=iris-push # when ntfy
|
||||
# NTFY_SERVER_URL=https://ntfy.sh
|
||||
# ANDROID_WS_CERT=/path/cert.pem # WSS
|
||||
# ANDROID_WS_KEY=/path/key.pem
|
||||
# IRIS_HTTP_CERT=/path/cert.pem # HTTPS
|
||||
# IRIS_HTTP_KEY=/path/key.pem
|
||||
```
|
||||
|
||||
**Behavioral (`~/.hermes/config.yaml`):**
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
android:
|
||||
iris:
|
||||
enabled: true
|
||||
extra:
|
||||
host: 127.0.0.1 # 0.0.0.0 for LAN
|
||||
port: 8790
|
||||
home_channel: android:default
|
||||
http_port: 8791
|
||||
home_channel: default
|
||||
push_backend: fcm
|
||||
outbox_retention_hours: 72
|
||||
max_upload_bytes: 104857600 # 100 MB
|
||||
display:
|
||||
platforms:
|
||||
android:
|
||||
iris:
|
||||
show_reasoning: true
|
||||
reasoning_style: code
|
||||
streaming: true
|
||||
@@ -120,19 +132,19 @@ display:
|
||||
|
||||
```bash
|
||||
# 1. gateway up with plugin
|
||||
hermes gateway status | grep -i android
|
||||
hermes gateway status | grep -i iris
|
||||
|
||||
# 2. a raw WS client can pair + echo
|
||||
python - <<'PY'
|
||||
import asyncio, json, websockets
|
||||
async def main():
|
||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
||||
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe",
|
||||
"caps":{"min_protocol":1}}}))
|
||||
print("recv:", await ws.recv())
|
||||
asyncio.run(main())
|
||||
PY
|
||||
# 2. the HTTP server answers (unauthenticated liveness)
|
||||
curl -s http://127.0.0.1:8791/v1/health
|
||||
# -> {"ok": true}
|
||||
|
||||
# 3. a frame round-trip with the pairing token
|
||||
curl -s -X POST http://127.0.0.1:8791/v1/frame \
|
||||
-H "Authorization: Bearer <IRIS_TOKEN>" -H "X-Iris-Device: probe" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"v":1,"type":"commands.catalog","id":1,"payload":{}}'
|
||||
```
|
||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
||||
wrong.
|
||||
|
||||
Expect `{"ok": true}` from the health probe and a `commands.catalog` reply
|
||||
frame from the POST. If you get `401`, the token/host/port is
|
||||
wrong.
|
||||
+20
-7
@@ -10,16 +10,18 @@ without the app (critical for verifying frame shapes early).
|
||||
into the hermes `tests/gateway/test_android.py` pattern when running under
|
||||
hermes's suite).
|
||||
- **Run with hermes's hermetic runner** (never bare `pytest`):
|
||||
|
||||
```bash
|
||||
cd hermes-agent
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
scripts/run_tests.sh # full suite (CI parity)
|
||||
```
|
||||
|
||||
- Coverage to write (behavioral, not change-detector — per hermes test policy):
|
||||
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
|
||||
parse_target_ref).
|
||||
- `check_requirements` / `validate_config` / `is_connected` truth table.
|
||||
- `_parse_target_ref`: `android:<chat>`, `android:<chat>:<thread>`, non-android
|
||||
- `_parse_target_ref`: `iris:<chat>`, `iris:<chat>:<thread>`, non-iris
|
||||
→ None.
|
||||
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
|
||||
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
|
||||
@@ -53,12 +55,13 @@ commentary classification and the reasoning prefix) before/while building the
|
||||
Kotlin client.
|
||||
|
||||
```bash
|
||||
hermes gateway & # with the android plugin
|
||||
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
|
||||
hermes gateway & # with the iris plugin
|
||||
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
|
||||
--send "list the files and summarize"
|
||||
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
|
||||
# commentary, message.stop {reasoning,…}, …
|
||||
```
|
||||
|
||||
Use it to lock `04-wire-protocol.md` against reality and to debug the adapter
|
||||
without waiting for the app.
|
||||
|
||||
@@ -102,6 +105,7 @@ adb logcat -d > /tmp/logcat.txt
|
||||
```
|
||||
|
||||
**E2E scenarios (script where possible):**
|
||||
|
||||
1. **Pair:** connect screen → enter URL+token → `hello.ack` → main. (Verify auth
|
||||
leg, not just TCP.)
|
||||
2. **Text round-trip:** send "hello" → streamed reply appears (message.start →
|
||||
@@ -112,7 +116,7 @@ adb logcat -d > /tmp/logcat.txt
|
||||
verbosity in Settings → rendering changes.
|
||||
5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed.
|
||||
6. **Channels:** create "Cron Reports" → appears in list; set as cron target.
|
||||
7. **Cron delivery:** create a cron job `deliver=android:android:chan_<n>` → it
|
||||
7. **Cron delivery:** create a cron job `deliver=iris:chan_<n>` → it
|
||||
fires → lands in that channel (not default).
|
||||
8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump.
|
||||
9. **Media (in):** attach a photo + a video → agent receives (vision) → reply.
|
||||
@@ -131,19 +135,28 @@ adb logcat -d > /tmp/logcat.txt
|
||||
entries drop out); tap a row → the command is sent and the drawer closes;
|
||||
type an unknown command → the drawer closes (the raw text can still be
|
||||
sent; hermes answers with its unknown-command reply).
|
||||
15. **HTTP fallback (docs/19):** with the WS port unreachable (e.g. the
|
||||
gateway bound WS to a dead port, or a firewall dropping 8790 but not
|
||||
8791), the app stays sendable: the status pill shows "connected · http"
|
||||
(green), a sent message echoes back within ~1 s and the agent reply
|
||||
streams in over SSE; the attach button is disabled (media needs the
|
||||
live WS). When the WS comes back the pill returns to "connected" and
|
||||
media works again. Automated: `e2e.py` scenario 13 (health +
|
||||
`POST /v1/frame` + SSE turn, user-echo < 1.5 s) and
|
||||
`ws_probe.py --http` (same assertion flags as the WS leg).
|
||||
|
||||
## 13.5 Debugging tips
|
||||
|
||||
- **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`).
|
||||
Our plugin logs under the `android` adapter name; secrets redacted.
|
||||
Our plugin logs under the `iris` adapter name; secrets redacted.
|
||||
- **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol
|
||||
from the app.
|
||||
- **Streaming jitter:** the consumer edits at intervals; if updates look chunky,
|
||||
check `display.platforms.android.streaming` and the consumer's edit interval.
|
||||
check `display.platforms.iris.streaming` and the consumer's edit interval.
|
||||
- **Media pull stalls:** check chunk size + backpressure; confirm the file is
|
||||
within hermes delivery roots (`validate_media_delivery_path`).
|
||||
- **FCM not arriving:** confirm the token registered (`devices.db`), the service
|
||||
account can mint a token, and the app's `onNewToken` re-registered after
|
||||
`pm clear`.
|
||||
- **Profile leaks:** if tokens look wrong under multiple profiles, verify the
|
||||
scope-aware secret read (`_get_scoped_secret`) is used.
|
||||
scope-aware secret read (`_get_scoped_secret`) is used.
|
||||
+59
-12
@@ -1,4 +1,4 @@
|
||||
# 14 — Milestones (M0–M7)
|
||||
# 14 — Milestones (M0–M8)
|
||||
|
||||
Phased delivery. Each milestone ends with a **demo** (on-device where noted) and
|
||||
has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
@@ -6,7 +6,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
---
|
||||
|
||||
## M0 — Toolchain & scaffolding
|
||||
|
||||
**Goal:** everything builds; the plugin is discoverable; the repo is safe.
|
||||
|
||||
- [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
|
||||
- [X] `cd hermes-agent && uv sync` (hermes venv works).
|
||||
- [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/`
|
||||
@@ -16,20 +18,22 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
- [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`,
|
||||
`./gradlew :desktopApp:run` (blank window).
|
||||
- [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
|
||||
no-op `AndroidAdapter` → `hermes gateway status` lists **android**.
|
||||
- **Demo:** `hermes gateway status` shows `android`; `./gradlew
|
||||
no-op `IrisAdapter` → `hermes gateway status` lists **iris**.
|
||||
- **Demo:** `hermes gateway status` shows `iris`; `./gradlew
|
||||
:androidApp:installDebug` installs a blank app on the MIX 2S.
|
||||
- **Accept:** blank app installs + launches on-device; plugin visible in
|
||||
`hermes gateway status`; `hermes-agent/` is git-ignored (verify with
|
||||
`git status --ignored`).
|
||||
|
||||
## M1 — Gateway core loop (text round-trip)
|
||||
|
||||
**Goal:** pair + send a text message + get a (non-streaming) reply.
|
||||
|
||||
- [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
|
||||
`hello.ack`, heartbeat, connection registry.
|
||||
- [X] `AndroidAdapter.send()` → `message` frame; inbound `message.send` →
|
||||
- [X] `IrisAdapter.send()` → `message` frame; inbound `message.send` →
|
||||
`MessageEvent` → `handle_message`.
|
||||
- [X] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`.
|
||||
- [X] Pairing store + `IRIS_TOKEN`; QR payload in `interactive_setup`.
|
||||
- [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient`
|
||||
(connect + reconnect), ChatScreen sends + renders `message`.
|
||||
- [X] `ws_probe.py` harness drives a real turn.
|
||||
@@ -38,9 +42,11 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
reconnect after gateway restart re-pairs.
|
||||
|
||||
## M2 — Streaming + reasoning + tools + commentary
|
||||
|
||||
**Goal:** the "agent transparency" features.
|
||||
|
||||
- [X] Map consumer `send`/`edit_message` → `message.start/update/stop`.
|
||||
- [X] Reasoning: set `show_reasoning` for android; adapter splits prefix →
|
||||
- [X] Reasoning: set `show_reasoning` for iris; adapter splits prefix →
|
||||
`reasoning` field. **Verify format with `ws_probe.py`.** (The model
|
||||
returns a separate `reasoning_content` field. In the *streaming* case the
|
||||
gateway drops it — the stream consumer only forwards `content` and the
|
||||
@@ -64,12 +70,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
rendering; reasoning copy button works; frame shapes match `04-wire-protocol`.
|
||||
|
||||
## M3 — Channels/threads + cron + search
|
||||
|
||||
**Goal:** organization + cron delegation + search.
|
||||
|
||||
- [X] Channel directory (SQLite): default channel ensured; `channel.create/
|
||||
rename/set_default/delete` + `channel.*` frames.
|
||||
- [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`.
|
||||
- [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron
|
||||
`deliver=android:<chat>[:<thread>]` works.
|
||||
`deliver=iris:<chat>[:<thread>]` works.
|
||||
- [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results.
|
||||
- [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new
|
||||
channel" + "set as cron target", SearchScreen with scope toggle + jump.
|
||||
@@ -79,7 +87,7 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
isolate context; search scoping correct; channel list reconciles on events.
|
||||
- **Status (complete):** channel directory + threads + search + outbox sync
|
||||
verified end-to-end via `ws_probe.py` (create/rename/set_default/delete,
|
||||
thread lanes, FTS5 search, sync); cron `deliver=android:<chat>[:<thread>]`
|
||||
thread lanes, FTS5 search, sync); cron `deliver=iris:<chat>[:<thread>]`
|
||||
target resolution verified via `resolve_send_target`. App on-device: channel
|
||||
drawer, thread toggle + topic switcher, new channel, search overlay with
|
||||
jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via
|
||||
@@ -89,7 +97,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
not e2e-tested (it shares the verified resolution path).
|
||||
|
||||
## M4 — Media
|
||||
|
||||
**Goal:** attach + receive + play media.
|
||||
|
||||
- [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`;
|
||||
size limit + sha256 + MIME re-sniff.
|
||||
- [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path
|
||||
@@ -117,12 +127,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
`04-wire-protocol.md` + `frames.schema.json`.
|
||||
|
||||
## M5 — Push + offline (FCM + ntfy)
|
||||
|
||||
**Goal:** reach the phone when backgrounded; catch up on reconnect.
|
||||
|
||||
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
|
||||
(row cap 5000 + prune banner, throttled 1/h).
|
||||
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
|
||||
PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
|
||||
`ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
|
||||
`IRIS_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
|
||||
- [x] Fire push on no-live-subscriber; data payload for silent sync.
|
||||
High-priority kinds (approval/clarify/cron) push even when live.
|
||||
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
|
||||
@@ -143,15 +155,17 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
loss/dup (verified); banners show for foreground events (implemented).
|
||||
|
||||
## M6 — Desktop app
|
||||
|
||||
**Goal:** the same app on a big screen.
|
||||
|
||||
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
|
||||
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
|
||||
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
|
||||
- [ ] Parity pass vs Android feature checklist (`11-desktop-app.md`).
|
||||
- [ ] Parity pass vs Iris app feature checklist (`11-desktop-app.md`).
|
||||
- [x] jpackage builds (Linux first; macOS/Windows as available).
|
||||
- **Demo:** desktop app pairs to the same gateway; full feature parity; tray
|
||||
notifications; media plays.
|
||||
- **Accept:** all Android features work on desktop; tray + shortcuts work;
|
||||
- **Accept:** all Iris app features work on desktop; tray + shortcuts work;
|
||||
native binary launches.
|
||||
- **Status (complete, 2026-08-19):** All `desktopMain` actuals implemented and
|
||||
verified on Linux (X11). `DesktopSecureStore`: non-secrets in
|
||||
@@ -178,7 +192,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
macOS/Windows packaging are deferred to M7.
|
||||
|
||||
## M7 — Polish + E2E + docs
|
||||
|
||||
**Goal:** ship-quality.
|
||||
|
||||
- [x] Telegram-style layout pass (per reference image): header, bubbles, date
|
||||
separators, ✓✓, model/token footer, banner, bottom bar.
|
||||
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
|
||||
@@ -222,12 +238,43 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||
|
||||
---
|
||||
|
||||
## M8 — QR pairing
|
||||
|
||||
**Goal:** QR-based pairing — a scannable QR at `hermes gateway setup` plus an
|
||||
in-app scanner and `iris://pair` deep link (closes gap #12, `docs/20`).
|
||||
|
||||
- [x] Pure-stdlib QR encoder + terminal renderer (`gateway-plugin/qr.py`):
|
||||
byte mode, EC M with L fallback, versions 1–10, ISO penalty masking,
|
||||
**zero new Python deps**.
|
||||
- [x] `interactive_setup` renders the QR after the pairing URL (text lines
|
||||
stay as the primary path).
|
||||
- [x] `PairLink` parser (`iris/util/PairLink.kt`) + jvmTest (valid/missing
|
||||
token/bad port/wrong scheme/wrong host/percent-encoded/secure/default
|
||||
port).
|
||||
- [x] CameraX + ML Kit scanner (`QrScanActivity`, on-device, no Play
|
||||
services) + Connect-screen **Scan QR** button (Android only; hidden on
|
||||
desktop) + `CAMERA` permission.
|
||||
- [x] `iris://pair` deep link (system-scanner / other-phone fallback) reusing
|
||||
the same parser.
|
||||
- **Accept:** `docs/20` §20.6; gap #12 in `09-pairing-security.md` closed.
|
||||
- **Status (2026-08-22):** Encoder cross-checked byte-for-byte against an
|
||||
independent reference and decoded by an independent decoder (zbarimg); fixed
|
||||
v1-M and v7-M matrix vectors lock the algorithm. `hermes gateway setup`
|
||||
prints a scannable QR (v7-M, 45 modules) for the 64-hex-token payload. App:
|
||||
Connect screen shows **Scan QR** (Android), which opens `QrScanActivity`
|
||||
(CameraX camera2 + ML Kit barcode), requests `CAMERA`, and pre-fills URL +
|
||||
token via `PairLink.parse` without auto-connecting; `iris://pair` deep link
|
||||
pre-fills the same way. Docs updated per `docs/20` Part C.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing notes
|
||||
|
||||
- **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early —
|
||||
build it in M1.
|
||||
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
|
||||
extend for push in M5.
|
||||
- **M6 (desktop) reuses M1–M5 shared code** — do it after the Android features
|
||||
- **M6 (desktop) reuses M1–M5 shared code** — do it after the Iris app features
|
||||
are stable so the shared module is settled.
|
||||
- Parallelizable: plugin (Python) and app (Kotlin) can be worked on
|
||||
concurrently once the protocol (`04-wire-protocol.md`) is agreed; the probe
|
||||
|
||||
@@ -5,7 +5,7 @@ integration point. Paths are relative to `hermes-agent/` (the read-only
|
||||
reference). This lets a coder jump straight to the right code instead of
|
||||
re-deriving the architecture.
|
||||
|
||||
> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/android`; we
|
||||
> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/iris`; we
|
||||
> never edit these files.
|
||||
|
||||
## Plugin / platform registration
|
||||
|
||||
+12
-10
@@ -3,24 +3,24 @@
|
||||
## Locked decisions (from planning, 2026-08-19)
|
||||
|
||||
| # | Decision | Choice | Rationale |
|
||||
|---|---|---|---|
|
||||
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. |
|
||||
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `ANDROID_PUSH_BACKEND`. |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Iris app, tweaked"; share protocol/state/UI. |
|
||||
| 2 | Push backend | **Both — ntfy default, FCM optional** (issue #10: FCM metadata — title, device token — is routed via Google's servers, contradicting the privacy claim; ntfy keeps it on your own infrastructure) | `IRIS_PUSH_BACKEND`. |
|
||||
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
|
||||
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
|
||||
|
||||
## Additional decisions made during planning
|
||||
|
||||
| Decision | Choice | Note |
|
||||
|---|---|---|
|
||||
| Connection point | **Messaging gateway** (platform plugin `android`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. |
|
||||
| --- | --- | --- |
|
||||
| Connection point | **Messaging gateway** (platform plugin `iris`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. |
|
||||
| Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. |
|
||||
| Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. |
|
||||
| Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. |
|
||||
| Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. |
|
||||
| Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. |
|
||||
| Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. |
|
||||
| Default chat id | `android:default` | Home channel + cron default. |
|
||||
| Default chat id | `default` | Home channel + cron default. |
|
||||
| WS port | `8790` (default) | Configurable. |
|
||||
| minSdk | 26 (test device API 29) | Broad coverage. |
|
||||
| Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. |
|
||||
@@ -29,6 +29,8 @@
|
||||
| Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. |
|
||||
| Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. |
|
||||
| Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. |
|
||||
| QR encoder | **Pure-stdlib** (`gateway-plugin/qr.py`) | No `qrcode`/`segno`/Pillow; byte mode, EC M→L, v1–10, ISO penalty masking. Keeps the plugin's zero-new-dep rule. |
|
||||
| QR scanner | **ML Kit barcode** (not zxing-android-embedded) | On-device, no Google Play services; better accuracy/latency, Google-maintained. Android-only (desktop has no camera). |
|
||||
|
||||
## Open questions (resolve during implementation)
|
||||
|
||||
@@ -42,10 +44,10 @@ will proceed with unless you say otherwise.
|
||||
live gateway. If a clean metadata marker exists, prefer it.
|
||||
2. **Reasoning prefix format stability (M2).** We split on the `code`-style
|
||||
`💭 **Reasoning:**\n```\n…\n```\n\n` prefix. *Default:* set
|
||||
`reasoning_style: code` for android and split on that; fallback = no
|
||||
`reasoning_style: code` for iris and split on that; fallback = no
|
||||
reasoning field (full text) if the prefix isn't found. Verify in M2.
|
||||
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
|
||||
`ANDROID_TOKEN` + optional `ANDROID_ALLOWED_USERS` device allowlist.
|
||||
`IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist.
|
||||
Per-device revocable tokens are a stretch.
|
||||
4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose
|
||||
surface, WebView fallback. Confirm `mpv` availability on target OSes during
|
||||
@@ -54,7 +56,7 @@ will proceed with unless you say otherwise.
|
||||
recommended remote path; WSS + reverse proxy as alternatives. No public bind
|
||||
by default.
|
||||
6. **Streaming cadence (M2).** If live updates look chunky, tune
|
||||
`display.platforms.android.streaming` / consumer edit interval. *Default:*
|
||||
`display.platforms.iris.streaming` / consumer edit interval. *Default:*
|
||||
follow global streaming config.
|
||||
7. **App package name / branding.** *Default:* applicationId `dev.iris.app`,
|
||||
app name "Iris". Confirm final product name + package + icon.
|
||||
@@ -72,4 +74,4 @@ will proceed with unless you say otherwise.
|
||||
- End-to-end encryption (transport WSS only).
|
||||
- Standalone-cron delivery while the gateway process is fully down (best-effort
|
||||
push only).
|
||||
- iOS.
|
||||
- iOS.
|
||||
@@ -153,7 +153,7 @@ Config + setup per provider — `web_server.py` `/api/memory/providers/*`.
|
||||
`app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`).
|
||||
2. **Security is the real gate.** The single-user model in
|
||||
`09-pairing-security.md` still holds, but control frames widen the blast
|
||||
radius of a leaked `ANDROID_TOKEN`. Sensitive operations (env/secrets,
|
||||
radius of a leaked `IRIS_TOKEN`. Sensitive operations (env/secrets,
|
||||
config writes, gateway restart, profile deletion) should get either an
|
||||
in-app confirmation step or a capability flag negotiated at pairing.
|
||||
3. **Read-heavy first.** Most of the value is in list/view frames (cheap,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 18 — Code Review & Lint/LSP Cleanup (alpha → stable)
|
||||
|
||||
Comprehensive review of all three components — **gateway plugin**, **Android
|
||||
app**, and **Desktop app** — performed to take the project from alpha to a
|
||||
Comprehensive review of all three components — **gateway plugin**, **Iris app
|
||||
(Android)**, and **Desktop app** — performed to take the project from alpha to a
|
||||
clean, stable baseline. Each section records what was found, what was fixed,
|
||||
how it was verified, and what was deliberately left (with rationale).
|
||||
|
||||
@@ -50,7 +50,7 @@ findings. Categories:
|
||||
`hermes_cli.config`, but those live in `hermes_cli.cli_output`; it also imported
|
||||
a `print_code` that does not exist in hermes at all. The whole import block
|
||||
raised `ImportError`, which the surrounding `try/except` swallowed, so
|
||||
`hermes gateway setup` for the android platform **always bailed out early** with
|
||||
`hermes gateway setup` for the iris platform **always bailed out early** with
|
||||
"setup helpers unavailable" and never generated a token or prompted for
|
||||
host/port. Fixed by importing the print helpers from `hermes_cli.cli_output`,
|
||||
the env helpers from `hermes_cli.config`, and dropping the non-existent
|
||||
@@ -127,9 +127,9 @@ rule set (`E W F I UP B SIM PL RET C4`) and `line-length = 100`. Changes:
|
||||
|
||||
---
|
||||
|
||||
## 18.2 Android app (`app/androidApp` + `app/shared`)
|
||||
## 18.2 Iris app — Android (`app/androidApp` + `app/shared`)
|
||||
|
||||
The Android and Desktop apps share the `:shared` KMP module (`commonMain` +
|
||||
The Iris Android and Desktop apps share the `:shared` KMP module (`commonMain` +
|
||||
`jvmMain`), so most Kotlin code is covered here and in 18.3.
|
||||
|
||||
### 18.2.1 Findings (before)
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# 19 — HTTP Fallback Transport (the "HTTP leg")
|
||||
|
||||
A second, **short-lived-connection** transport next to the WebSocket: the same
|
||||
JSON frames, the same outbox/cursor, the same token — served over plain HTTP
|
||||
by the gateway. When the WS is down (flaky network, NAT timeout, app just
|
||||
relaunched), the app **sends over `POST` and receives over SSE** instead of
|
||||
waiting 2–20 s for a WS redial.
|
||||
|
||||
Status: **implemented — and now the ONLY transport.** The WebSocket leg has
|
||||
been removed entirely from the codebase (gateway `ws_server.py` deleted;
|
||||
WS-only frames `hello`/`ping`/`pong`/`media.upload.*`/`media.pull*` dropped
|
||||
from `protocol.py` and `Protocol.kt`; `GatewayClient` is HTTP-only with no
|
||||
`HttpFallback` state — it *is* the connected state). HTTP is the primary and
|
||||
sole transport: v1 (JSON frames over POST/SSE/long-poll) and v2 (media over
|
||||
`POST /v1/media` + `GET /v1/media/{id}`, §19.15). Gateway leg:
|
||||
`gateway-plugin/http_server.py` (+ `dispatch.py` for frame dispatch);
|
||||
app leg: `app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt` +
|
||||
`GatewayClient.kt`. Legacy `ws(s)://` URLs entered by users are still
|
||||
accepted and rewritten to `http(s)://` (`HttpGateway.deriveHttpUrl`).
|
||||
Complements — does not replace — `04-wire-protocol.md` (frames),
|
||||
`08-push.md` (outbox/sync/push), and `09-pairing-security.md` (auth model).
|
||||
|
||||
> **Note:** the rest of this document describes the original design, in
|
||||
> which HTTP was a *fallback* next to a WS primary. That framing is
|
||||
> historical; where it says "WS (primary)" / "HTTP (fallback)", read
|
||||
> "HTTP (the only transport)".
|
||||
|
||||
## 19.1 Problem
|
||||
|
||||
Today the WS is the *only* transport, and the app hard-gates sending on a live
|
||||
socket (`ChatScreen.doSend()` no-ops unless `State.Connected`;
|
||||
`GatewayClient.sendMessage()` drops when `socket == null`). Consequences:
|
||||
|
||||
- **App killed → reopened:** full cold dial (TCP + TLS + `hello`/`hello.ack`,
|
||||
15 s dial timeout) before the user can send. On a flaky network the first
|
||||
dial often fails → backoff → second dial. Observed: 2–20 s of "can't send".
|
||||
- **Long-lived WS is the most fragile connection type on mobile:** idle
|
||||
sockets expire in router/CGNAT NAT tables, die on WiFi↔cellular handover,
|
||||
and are killed aggressively by OEM power management (MIUI on the test
|
||||
device). There is no foreground service holding the WS.
|
||||
- **Stale detection is slow:** 20 s ping interval, 60 s reap — a dead-but-
|
||||
unclosed socket can sit for up to a minute before redial.
|
||||
|
||||
## 19.2 Why HTTP (and why not the alternatives)
|
||||
|
||||
Short-lived HTTP requests are dramatically more resilient on mobile networks
|
||||
than a long-lived socket: no NAT table entry to expire, no proxy idle-kill,
|
||||
each request is a fresh connection (fast with TLS resumption), and they work
|
||||
through the restrictive proxies that mangle WebSockets. Sending a message
|
||||
becomes a single `POST` that completes in well under a second on a LAN —
|
||||
independent of whether the WS is up.
|
||||
|
||||
Alternatives considered and rejected (research, 2026-08):
|
||||
|
||||
| Option | Verdict |
|
||||
| --- | --- |
|
||||
| **MQTT broker** (QoS 1, persistent sessions) | Best protocol for flaky links, but new infra (broker process) + new Python dep (`paho-mqtt`, breaks the zero-new-deps rule) + new Kotlin dep + frame↔topic bridge. Overkill for a 1-user agent. |
|
||||
| **ntfy as the send path** (app publishes to a topic the gateway subscribes to) | Adds a third party to the critical send path; public ntfy.sh is already known-flaky. Not worth it. |
|
||||
| **WebTransport / QUIC** | The *real* fix for handover flakiness (connection migration), but no OkHttp support and `aioquic` is a new Python dep. Future option if this doc's approach is still not enough. |
|
||||
| **gRPC** | New deps both sides; no advantage over WS+SSE here. |
|
||||
| **Inverted connection** (app runs a local HTTP server, gateway pushes to the phone) | LAN-only, breaks on cellular/remote, security mess. Rejected. |
|
||||
| **Matrix / full chat server** | Massive overkill for a personal agent. |
|
||||
|
||||
**Zero new Python dependencies is preserved:** the HTTP leg is stdlib
|
||||
`http.server` (a `ThreadingHTTPServer` in a daemon thread) bridged into the
|
||||
gateway's asyncio loop. The app side uses the OkHttp it already depends on
|
||||
(hand-rolled SSE reader — the format is trivial; `okhttp-eventsource` is an
|
||||
acceptable alternative if preferred).
|
||||
|
||||
## 19.3 Shape
|
||||
|
||||
```
|
||||
┌──────────────────────── hermes gateway process ───────────────────────┐
|
||||
│ IrisAdapter │
|
||||
│ │ frames (same protocol.Frame objects) │
|
||||
│ ▼ │
|
||||
│ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ WsServer (asyncio, :8790) HttpServer (stdlib thread, :8791) │
|
||||
│ primary: full protocol fallback: POST /v1/frame, │
|
||||
│ incl. binary media GET /v1/events (SSE), /v1/poll │
|
||||
└───────────────┬──────────────────────────────┬────────────────────────┘
|
||||
│ WS (primary) │ HTTP (fallback)
|
||||
┌─────────┴──────────────────────────────┴────────┐
|
||||
│ APP: transport state machine │
|
||||
│ WS up → WS only (media works, lowest latency)│
|
||||
│ WS down → send via POST, receive via SSE/poll │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**v1 scope**
|
||||
|
||||
| Over HTTP | WS-only |
|
||||
| --- | --- |
|
||||
| All JSON request frames (`message.send`, `search`, `channel.*`, `commands.catalog`, `agent.stop`/`agent.steer`, …) via one generic endpoint | — |
|
||||
| All event/response frames via SSE (or long-poll) | — |
|
||||
| `sync` catch-up (same outbox, same cursor) | — |
|
||||
| Media upload + pull (`POST /v1/media`, `GET /v1/media/{id}`, v2 — §19.15) | — |
|
||||
|
||||
With v2 the HTTP leg is feature-complete: media no longer needs the WS
|
||||
(the composer's attach button is enabled in `HTTP_FALLBACK` too). The WS
|
||||
binary media frames remain accepted for WS clients, but the app routes media
|
||||
over HTTP whenever the WS is down.
|
||||
|
||||
## 19.4 Gateway: `gateway-plugin/http_server.py`
|
||||
|
||||
New module, started/stopped by `IrisAdapter.connect()`/`disconnect()` next
|
||||
to the WS server.
|
||||
|
||||
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
|
||||
run in a **daemon thread** (one thread per connection — fine at single-user
|
||||
scale). The handler thread never touches adapter state directly; it bridges
|
||||
into the gateway's asyncio loop with
|
||||
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
||||
start).
|
||||
- **Config:** `IRIS_HTTP_PORT` (default **8791**), bind host `IRIS_HTTP_HOST`.
|
||||
Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
||||
(`ssl.SSLContext` on the server): plaintext on a trusted LAN by default,
|
||||
TLS for remote/Tailscale setups.
|
||||
- **Bind failure is NON-fatal:** log a warning, disable the HTTP leg, show it
|
||||
in the inspector.
|
||||
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Endpoint | Auth | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/health` | none | Liveness probe → `200 {"ok": true}`. Leaks nothing (no token echo, no device info). The app races this against the WS dial at startup. |
|
||||
| `POST /v1/frame` | Bearer token | Accept **any** JSON frame the WS accepts (except binary media). Body = one frame envelope (`04-wire-protocol.md`). Dispatched through the *same* adapter handlers as WS (`on_message_send`, `on_search`, …). |
|
||||
| `GET /v1/events?cursor=N` | Bearer token | **SSE** stream: catch-up from the outbox, then live frames (§19.5). |
|
||||
| `GET /v1/poll?cursor=N` | Bearer token | **Long-poll** fallback where SSE is blocked (§19.6). |
|
||||
| `POST /v1/media` | Bearer token | **Media upload** (v2, §19.15): whole file as the body, metadata in `X-Iris-Media-*` headers. |
|
||||
| `GET /v1/media/{media_id}` | Bearer token | **Media pull** (v2, §19.15): streams an outbound offer as the response body. |
|
||||
|
||||
### Auth & limits
|
||||
|
||||
- `Authorization: Bearer <token>`; verified with the existing constant-time
|
||||
`verify_token()`; `401` on failure. Device identity via `X-Iris-Device`
|
||||
header (same `device_id` the app uses for `hello`; same allowlist check).
|
||||
- Request body cap **64 KiB**, `Content-Type: application/json` enforced
|
||||
(frames are small; media never travels here in v1).
|
||||
- Rate limit: token bucket per device, same parameters as the WS inbound
|
||||
limit (`INBOUND_RATE_PER_S` / `INBOUND_BURST`); `429` on exceed.
|
||||
- No CORS headers (app clients only); unknown paths → `404`.
|
||||
|
||||
## 19.5 SSE stream design (`GET /v1/events`)
|
||||
|
||||
Wire format (standard SSE, three fields):
|
||||
|
||||
```
|
||||
id: 1043
|
||||
event: frame
|
||||
data: {"v":1,"type":"message","chat_id":"default",...}
|
||||
|
||||
: hb ← comment heartbeat every 15 s (keeps proxies alive)
|
||||
```
|
||||
|
||||
- **`id` = outbox cursor.** This is what makes resume trivial: on reconnect
|
||||
the client sends `Last-Event-ID` (or `?cursor=`) and the server replays
|
||||
`outbox.replay(cursor)` — exactly the `sync` semantics, no new machinery.
|
||||
- **Stream open sequence:**
|
||||
1. Replay outbox rows with `cursor > N` (bounded by the existing
|
||||
`_REPLAY_LIMIT`), each as an `event: frame` with its `id`.
|
||||
2. One `event: hello` carrying the `hello.ack` payload
|
||||
(`server_caps`, `sync_cursor`, `last_pushed_cursor`, `channels`) — the
|
||||
HTTP equivalent of pairing-ack; the app treats it like `hello.ack`.
|
||||
3. Live frames as they are produced.
|
||||
- **Live fan-out hook:** in `adapter._broadcast_or_log`, after
|
||||
`outbox.append()` returns the cursor, push `(cursor, frame_json)` into every
|
||||
live HTTP subscriber's queue. The direct `status` broadcasts
|
||||
(`ws_server.broadcast(protocol.status(...))`) get a second fan-out call
|
||||
with `cursor = None` (SSE event without `id`).
|
||||
- **Thread model:** each SSE connection owns its handler thread, which blocks
|
||||
on a cross-thread `queue.get()` (via `run_coroutine_threadsafe`, 30 s
|
||||
timeout → write `: hb` and loop) and writes to `wfile` + `flush()`.
|
||||
- **Backpressure:** bounded queue (256). A subscriber that can't keep up is
|
||||
dropped; the client reconnects with `Last-Event-ID` and catches up from the
|
||||
outbox. Single-user scale makes this a non-event in practice.
|
||||
- **App-side reader:** hand-rolled over OkHttp's streaming `ResponseBody`
|
||||
(read lines; `id:` / `event:` / `data:`; blank line = dispatch). ~100 lines,
|
||||
no new dependency. Reconnect with exponential backoff + `Last-Event-ID`.
|
||||
|
||||
## 19.6 Long-poll fallback (`GET /v1/poll`)
|
||||
|
||||
For networks/proxies that buffer or kill SSE:
|
||||
|
||||
- `GET /v1/poll?cursor=N` → server holds the request (asyncio waiter on the
|
||||
subscriber queue) until a frame with `cursor > N` exists or **25 s** pass.
|
||||
- Response: `200 {"cursor": <new high-water>, "frames": [ ... ]}` (frames may
|
||||
be empty on timeout; the app immediately re-polls with the new cursor).
|
||||
- The app switches to long-poll automatically after **two consecutive SSE
|
||||
open failures**, and back to SSE on the next full (re)connect.
|
||||
|
||||
## 19.7 Request/response over HTTP
|
||||
|
||||
`POST /v1/frame` is accept-and-ack:
|
||||
|
||||
- `202 {"ok": true}` — frame accepted and dispatched.
|
||||
- `4xx` with an **error frame as the JSON body** for validation rejections
|
||||
(empty message, automation-channel read-only, rate limit → `429`, bad JSON
|
||||
→ `400`). These are the same `error` frames the WS path sends via
|
||||
`send_to`; over HTTP they double as the HTTP response.
|
||||
- **Async responses** (user echo, `search` results, `channel.list`, the agent
|
||||
reply, streaming updates) arrive on the **event stream** carrying the same
|
||||
`id` — the app's existing request-id correlation works unchanged.
|
||||
- Consequence: `send_to(device_id, …)` error replies for HTTP-originated
|
||||
requests are instead **broadcast** (single-user model; the SSE stream
|
||||
delivers them). The dispatch refactor must tag the origin so WS-originated
|
||||
requests keep point-to-point errors.
|
||||
|
||||
## 19.8 Delivery counting & push interaction (critical)
|
||||
|
||||
`_broadcast_or_log` fires push when `delivered == 0`. With the HTTP leg, a
|
||||
device reading SSE **is** a live subscriber:
|
||||
|
||||
```python
|
||||
delivered = await self._ws_server.broadcast(frame)
|
||||
delivered += await self._http_server.fanout(frame, cursor) # live SSE/poll subs
|
||||
...
|
||||
if delivered == 0: # → outbox + push (unchanged)
|
||||
```
|
||||
|
||||
If this is forgotten, every message would push *and* stream to a device that
|
||||
is already receiving it. Related bookkeeping:
|
||||
|
||||
- `has_devices()` / `status` must count HTTP subscribers as connected devices
|
||||
(mark the device's transport `ws` | `http` in the connection registry).
|
||||
- `last_pushed_cursor` / notification dedupe (`08-push.md` §8.8) is
|
||||
unchanged — SSE-replayed frames carry the same `cursor` envelope as
|
||||
`sync`-replayed ones, so the app's existing dedupe applies.
|
||||
|
||||
## 19.9 App side
|
||||
|
||||
New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
|
||||
`GatewayClient` (or a thin `Transport` wrapper around it):
|
||||
|
||||
- **API:** `health(timeoutMs)`, `postFrame(json): Result`,
|
||||
`events(cursor, onFrame, onHello): Job` (SSE reader), `poll(cursor): Result`.
|
||||
- **State machine:**
|
||||
|
||||
| State | Send path | Receive path |
|
||||
| --- | --- | --- |
|
||||
| `WS_CONNECTED` | WS frame | WS |
|
||||
| `HTTP_FALLBACK` | `POST /v1/frame` | SSE (or long-poll) |
|
||||
| `CONNECTING` / `RECONNECTING` | queued/dropped as today | — |
|
||||
|
||||
- **On WS loss:** switch to `HTTP_FALLBACK` **immediately** — open the SSE
|
||||
stream (catch-up from the local cursor is free) and route sends to POST.
|
||||
No backoff gate on the send path; the WS redial loop keeps running in the
|
||||
background.
|
||||
- **At startup (the key UX fix):** race the WS dial against
|
||||
`GET /v1/health` (2 s timeout). WS dial fails + health OK → straight into
|
||||
`HTTP_FALLBACK`: the user can send in **< 1 s** after opening the app,
|
||||
instead of waiting out dial timeouts and backoff.
|
||||
- **On WS reconnect:** close the SSE stream, resume WS-only (lowest latency,
|
||||
media available again).
|
||||
- **Send path:** `sendMessage()` builds the same `message.send` frame JSON and
|
||||
writes it to WS or POST depending on state. The `State.Connected` gate in
|
||||
`ChatScreen.doSend()` becomes `state is Connected || state is HttpFallback`.
|
||||
- **Media:** works in `HTTP_FALLBACK` too (v2, §19.15) — uploads go via
|
||||
`POST /v1/media`, pulls via `GET /v1/media/{id}`; the composer's attach
|
||||
button is enabled in both connected states.
|
||||
- **UI:** status pill shows "connected" (WS) or "connected · http" (fallback)
|
||||
— both green; the fallback is a healthy state, not an error.
|
||||
|
||||
## 19.10 Security
|
||||
|
||||
- Same token, constant-time verify, same bind host, same device allowlist as
|
||||
the WS (`09-pairing-security.md` threat model unchanged — the HTTP leg adds
|
||||
no new trust boundary, only a second door with the same lock).
|
||||
- `/v1/health` is unauthenticated by design (it answers "is the gateway
|
||||
alive?"); it must not reflect tokens, device ids, or version strings.
|
||||
- TLS: optional, same cert pattern as the WS; plaintext is a LAN-only
|
||||
default, identical to today's WS posture.
|
||||
- New attack-surface items to keep small: 64 KiB body cap, strict
|
||||
content-type, per-device rate limit, no directory listing, no CORS.
|
||||
|
||||
## 19.11 Failure modes
|
||||
|
||||
| Failure | Behavior |
|
||||
| --- | --- |
|
||||
| Gateway fully down | Both legs dead → app shows offline; sends queue (app-side outbox, follow-up work) or are dropped with a visible "not sent" state. Push is the wake path when the gateway comes back (`08-push.md`). |
|
||||
| WS down, HTTP up | Normal `HTTP_FALLBACK` operation — text chat fully functional, media paused. |
|
||||
| SSE blocked by a proxy | Two failures → long-poll loop (§19.6). |
|
||||
| HTTP port firewalled, WS up | WS-only operation (today's behavior); `health` fails at startup, no fallback attempted. |
|
||||
| Both flaky | Existing WS backoff + SSE/poll backoff run independently; outbox + cursor keep both paths idempotent. |
|
||||
| Slow SSE subscriber | Dropped at queue overflow; reconnects with `Last-Event-ID`, catches up from outbox. |
|
||||
|
||||
## 19.12 Testing
|
||||
|
||||
- **Python** (`hermes-agent/tests/gateway/test_android_http.py`, run via
|
||||
`scripts/run_tests.sh`):
|
||||
- auth: bad/missing token → 401; allowlist rejection; constant-time verify reused.
|
||||
- `POST /v1/frame`: valid `message.send` dispatches (agent turn fires);
|
||||
empty text → 400 error frame; automation channel → 409/400; rate limit → 429.
|
||||
- SSE: catch-up rows carry correct `id`s; `event: hello` present; a live
|
||||
frame appended after connect arrives on the stream; `Last-Event-ID`
|
||||
resume replays exactly the delta; heartbeat observed within 15 s.
|
||||
- long-poll: returns on new frame; empty 200 at timeout with advanced cursor.
|
||||
- **delivery counting:** frame with only an SSE subscriber → `delivered ≥ 1`
|
||||
→ **no push fired** (the critical regression test for §19.8).
|
||||
- **media (v2, §19.15):** `POST /v1/media` happy path (201 ack + cached
|
||||
entry), sha256 mismatch, oversize → 413, missing ref / bad kind → 400,
|
||||
auth → 401, magic-byte reclassification; `GET /v1/media/{id}` happy path
|
||||
(bytes + content-type), unknown id → 404, denied path → 404.
|
||||
- **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with
|
||||
assertion flags, per `gateway-plugin/tests/README.md`) + `--http-media FILE`
|
||||
(v2: upload round-trip via `POST /v1/media`, exit 23 on rejection).
|
||||
- **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments,
|
||||
`Last-Event-ID` bookkeeping); transport state machine transitions (fake
|
||||
clock: WS-loss → immediate fallback; startup race → fallback in < 1 s).
|
||||
- **E2E** (`e2e.py`, new scenario): point the app at a dead WS port with the
|
||||
HTTP leg live → send a message → assert user echo + agent reply arrive via
|
||||
SSE; timing assertion: send → user echo < 1 s on LAN. Live-verify on the
|
||||
device via ADB (screenshot of the "connected · http" pill).
|
||||
|
||||
## 19.13 Non-goals (v1) / future
|
||||
|
||||
- ~~**Media over HTTP** (v2)~~ — **done** (§19.15): `POST /v1/media`
|
||||
(whole-file body, sha256 contract per `07-media.md`) +
|
||||
`GET /v1/media/{id}` for pull/playback. Attachments work in fallback mode.
|
||||
- **App-side send outbox** (companion work, separate doc): queue sends locally
|
||||
when *both* legs are down; drains over whichever leg recovers. This doc
|
||||
removes the 2–20 s wait; the outbox removes the last "gateway was down for
|
||||
30 s" data-loss case.
|
||||
- **QUIC / WebTransport** if handover flakiness persists after this + the
|
||||
outbox (connection migration would make the fallback rare).
|
||||
- Per-device tokens (`16-open-questions.md` #3) apply to both legs identically
|
||||
when implemented.
|
||||
|
||||
## 19.15 Media over HTTP (v2)
|
||||
|
||||
The last WS-only feature, closed out so the HTTP leg is feature-complete.
|
||||
Same contracts as `07-media.md` — only the transport changes.
|
||||
|
||||
### Upload — `POST /v1/media`
|
||||
|
||||
One request per file (no chunked/resumable protocol — HTTP handles the
|
||||
body; single-user scale makes resume unnecessary):
|
||||
|
||||
```
|
||||
POST /v1/media
|
||||
Authorization: Bearer <token>
|
||||
X-Iris-Device: <device_id>
|
||||
X-Iris-Media-Ref: up_123456 # app-chosen ref (mu_*/up_*), ≤ 64 chars
|
||||
X-Iris-Media-Kind: image|audio|video|document|voice
|
||||
X-Iris-Media-Filename: photo.jpg
|
||||
X-Iris-Media-Sha256: <64 hex> # precomputed (headers precede the body)
|
||||
Content-Type: <media mime> # doubles as the declared MIME
|
||||
Content-Length: <size>
|
||||
|
||||
<raw file bytes>
|
||||
```
|
||||
|
||||
- **Response:** `201` with the `media.upload.ack` frame as the body
|
||||
(`{ok, media_ref}`); validation failures return the `error` frame as the
|
||||
4xx body with the same codes as the WS path (`media_too_large` → 413,
|
||||
`unsupported` → 400, `internal` → 500, `not_found` → 404).
|
||||
- **Server flow:** the body is streamed to a temp file in 256 KiB reads
|
||||
(bounded RAM, same `UploadSession` as the WS path), then
|
||||
`complete_upload` verifies size + sha256, re-sniffs the kind from magic
|
||||
bytes (the client's declared kind is not trusted), and caches via the
|
||||
hermes `cache_*_from_bytes` helpers. Runs entirely on the handler thread
|
||||
— no asyncio bridge (plain file IO).
|
||||
- **Limits:** `Content-Length` is checked against `max_upload_bytes`
|
||||
*before* reading the body (early 413); the 64 KiB `/v1/frame` body cap
|
||||
does not apply. Same per-device rate limit as the other endpoints.
|
||||
- **Abort:** a client that disconnects mid-body leaves a short read → the
|
||||
upload session (temp file) is discarded; nothing is cached.
|
||||
- The ref then travels in `message.send`'s `media_refs` exactly as on the WS
|
||||
path (single-use, resolved to `MessageEvent.media_urls`).
|
||||
|
||||
### Pull — `GET /v1/media/{media_id}`
|
||||
|
||||
- `media.offer` is a plain JSON event frame — it arrives on the SSE stream
|
||||
unchanged; only the byte transfer moves to HTTP.
|
||||
- **Response:** `200` with the file as the body,
|
||||
`Content-Type: <mime>`, `Content-Length: <size>`,
|
||||
`Content-Disposition: attachment; filename="<name>"`. Unknown id or a
|
||||
path that fails delivery validation → `404` with the `error` frame
|
||||
(`not_found`) — the delivery-path check is re-run at pull time, exactly
|
||||
as the WS `media.pull` handler does.
|
||||
- The app streams the body into its media cache (same
|
||||
`MediaCache.openWriter` path as the WS pull); playback is unchanged
|
||||
(`07-media.md` §7.4).
|
||||
|
||||
### What stays WS-only
|
||||
|
||||
Nothing feature-wise. The WS binary media frames (`media.upload.start/end`,
|
||||
`media.pull` + binary chunks) remain accepted for WS clients, and
|
||||
`POST /v1/frame` still rejects those frame types (they have HTTP endpoint
|
||||
equivalents now, not a WS dependency).
|
||||
|
||||
## 19.14 Effort & change list
|
||||
|
||||
| Slice | Files | Est. |
|
||||
| --- | --- | --- |
|
||||
| Gateway leg | new `gateway-plugin/http_server.py` (~450 lines); `adapter.py` hooks (start/stop, fan-out in `_broadcast_or_log` + status path, delivery counting, dispatch-origin tag); `protocol.py` unchanged | 2–3 d |
|
||||
| App leg | new `app/shared/.../net/HttpGateway.kt` (SSE reader + poll); `GatewayClient.kt` state machine + startup race; `ChatScreen.kt` gate + status pill; composer media-disable in fallback | 2–3 d |
|
||||
| Tests + e2e + docs | per §19.12; `frames.schema.json` unchanged (no new frame types); `09-pairing-security.md` + `13-testing.md` cross-references | 1–2 d |
|
||||
|
||||
Total: **~1 week**, each slice independently shippable (gateway leg is
|
||||
inert until the app uses it; app leg degrades to today's behavior if the
|
||||
HTTP port is closed).
|
||||
@@ -0,0 +1,322 @@
|
||||
# 20 — QR Pairing (terminal QR + in-app scanner)
|
||||
|
||||
**Status: implemented (M8, 2026-08-22).**
|
||||
|
||||
Closes gap #12 in `09-pairing-security.md` ("in-app QR scanner") and implements
|
||||
the QR branch of the §9.2 pairing flow, which the docs already promise but the
|
||||
code never delivered: today `interactive_setup` prints the pairing URL as
|
||||
plain text only, and the app has no `iris://pair` parser at all.
|
||||
|
||||
Two halves, independent and shippable separately:
|
||||
|
||||
- **A — Gateway:** `hermes gateway setup` renders a scannable QR in the
|
||||
terminal encoding `iris://pair?host=…&port=…&token=…`. **Zero new Python
|
||||
dependencies** (pure stdlib encoder).
|
||||
- **B — App:** a "Scan QR" button on the Android Connect screen (CameraX +
|
||||
ML Kit, on-device, no Play services) that pre-fills URL + token. Not added
|
||||
to desktop. Plus an `iris://pair` deep link so *any* scanner (system camera
|
||||
app, other phones) can route the QR into the app.
|
||||
|
||||
---
|
||||
|
||||
## 20.1 Current state (what exists today)
|
||||
|
||||
| Piece | State | Location |
|
||||
| ------- | ------- | ---------- |
|
||||
| QR payload format | ✅ implemented | `gateway-plugin/pairing.py` → `qr_payload(host, port, token, secure)` → `iris://pair?host=<lan-ip>&port=8791&secure=0&token=<64-hex>` |
|
||||
| Terminal QR rendering | ❌ missing | `gateway-plugin/adapter.py` → `interactive_setup()` prints the URL text only |
|
||||
| App `iris://pair` parser | ❌ missing | app has manual URL + token entry only (`ConnectScreen`) |
|
||||
| In-app camera scan | ❌ missing | no camera deps anywhere in `app/` |
|
||||
| `iris://` deep link | ⚠️ partial | manifest handles `iris://chat/<id>` only (`androidApp/.../AndroidManifest.xml`, `MainActivity.handleDeepLink`) |
|
||||
| QR libs in hermes venv | ❌ absent | `qrcode`/`segno` not installed; `Pillow` is a hermes core dep but only renders images — the QR *matrix* algorithm is still needed either way |
|
||||
|
||||
Payload size: `iris://pair?host=192.168.x.x&port=8791&secure=0&token=<64 hex>`
|
||||
≈ **118 bytes** → QR version **7 at EC level M** (capacity 122 bytes) or v6 at
|
||||
L (134). The encoder must therefore support at least versions 1–8; we target
|
||||
1–10.
|
||||
|
||||
---
|
||||
|
||||
## 20.2 Part A — terminal QR in `interactive_setup`
|
||||
|
||||
### A1. Pure-stdlib QR encoder — `gateway-plugin/qr.py` (new file)
|
||||
|
||||
A self-contained ISO/IEC 18004 encoder, **stdlib only** (no `qrcode`, no
|
||||
`segno`, no Pillow). Scope is deliberately minimal — we only ever encode
|
||||
ASCII pairing URLs:
|
||||
|
||||
- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths).
|
||||
- **Error correction:** level **M** (15 %); auto-fallback to **L** if the
|
||||
payload doesn't fit at M within the version cap.
|
||||
- **Versions:** 1–10, auto-selected (smallest version whose capacity fits).
|
||||
Payloads that don't fit v10-L raise `QrTooLongError` (caller falls back to
|
||||
text-only output — see A3).
|
||||
- **Components** (all well-known, spec-stable algorithms):
|
||||
1. Data encoding: mode indicator `0100`, 8-bit char count (8 bits for
|
||||
v1–9, 16 bits for v10), payload bytes, terminator, padding
|
||||
(`0xEC`/`0x11` alternation).
|
||||
2. Reed–Solomon error correction over GF(256), generator polynomial
|
||||
`0x11D`, per (version, EC level) block structure from the spec tables.
|
||||
3. Matrix placement: finder patterns + separators, timing patterns,
|
||||
alignment patterns (v2+), dark module, format info (BCH(15,5)),
|
||||
version info (v7+, BCH(18,6)), zig-zag data placement.
|
||||
4. Masking: all 8 masks, ISO penalty scoring (N1–N4), pick lowest.
|
||||
- **Public API:**
|
||||
|
||||
```python
|
||||
def qr_matrix(data: str) -> list[list[bool]]:
|
||||
"""Encode *data* (ASCII) into a module matrix (True = dark).
|
||||
Includes the 4-module quiet zone. Raises QrTooLongError."""
|
||||
```
|
||||
|
||||
~250–350 lines including the spec tables. No I/O, no globals, fully
|
||||
unit-testable.
|
||||
|
||||
### A2. Terminal renderer — `qr.py`
|
||||
|
||||
```python
|
||||
def render_qr(data: str) -> str:
|
||||
"""Render *data* as a terminal QR using Unicode half-blocks (▀).
|
||||
Returns '' (not an exception) when the payload is too long."""
|
||||
```
|
||||
|
||||
- Pair consecutive module rows into one character row: both dark → `█`,
|
||||
top dark → `▀`, bottom dark → `▄`, both light → space. (Matrix height
|
||||
including quiet zone is always even: `2·(17+4v)+8`.)
|
||||
- Output is a single string of `\n`-joined lines; the caller prints it.
|
||||
- No ANSI colors, no cursor tricks — must survive `less`, log files, and
|
||||
copy-paste.
|
||||
|
||||
### A3. Integration — `adapter.py:interactive_setup()`
|
||||
|
||||
After the existing "Pairing URL / Server URL" lines:
|
||||
|
||||
```python
|
||||
qr = render_qr(qr_payload(host, port, token))
|
||||
if qr:
|
||||
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
|
||||
print(qr)
|
||||
else:
|
||||
print_warning("QR too large to render; use the pairing URL above.")
|
||||
```
|
||||
|
||||
- The **URL text lines stay** — the QR is a convenience, not a replacement
|
||||
(terminals without UTF-8 still work, and the text is copy-pasteable).
|
||||
- Printed on every setup run (new *and* existing token), consistent with the
|
||||
URL lines which already print the token in cleartext.
|
||||
- **Security note:** no new exposure — the token is already printed in the
|
||||
pairing URL line today; the QR is the same bytes in a different encoding,
|
||||
on the same operator-only stdout. (Gap #5 in the §9.7 table already
|
||||
documents the stdout token print.)
|
||||
|
||||
### A4. Tests — `hermes-agent/tests/gateway/test_android.py`
|
||||
|
||||
The test file is a thin mirror importing the **live `gateway-plugin/`
|
||||
package**, so new tests land there:
|
||||
|
||||
1. **Fixed test vectors** (guard against silent algorithm drift): at least
|
||||
two known-good (data → matrix) pairs from public QR test vectors
|
||||
(e.g. the ISO 18004 annex examples / the classic `KARAT` v2-L vector).
|
||||
Assert the full matrix, not just dimensions.
|
||||
2. **Round-trip via payload:** `qr_matrix(qr_payload(h, p, t))` has the
|
||||
expected version/size for a 64-hex token (`17 + 4·7 = 45` modules at
|
||||
v7-M, +8 quiet zone).
|
||||
3. **Renderer shape:** every line equal length, height = half of matrix
|
||||
height, quiet zone renders as blank border, only the 4 block chars +
|
||||
space appear.
|
||||
4. **`QrTooLongError` / `render_qr` → `""`** for a payload beyond v10-L.
|
||||
5. **`interactive_setup` smoke:** with sandboxed HERMES_HOME (conftest
|
||||
already does this), capture stdout and assert the QR block appears after
|
||||
the pairing URL line.
|
||||
|
||||
Run: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`.
|
||||
|
||||
---
|
||||
|
||||
## 20.3 Part B — in-app scanner (Android only)
|
||||
|
||||
### B1. Dependencies — `app/shared/build.gradle.kts`, `androidMain.dependencies` only
|
||||
|
||||
| Dependency | Why |
|
||||
| ------------ | ----- |
|
||||
| `androidx.camera:camera-camera2` | Camera access (CameraX) |
|
||||
| `androidx.camera:camera-lifecycle` | Lifecycle-aware binding |
|
||||
| `androidx.camera:camera-view` | `PreviewView` for the scan surface |
|
||||
| `com.google.mlkit:barcode-scanning` | On-device QR decode; **no Google Play services required** (self-contained model) |
|
||||
|
||||
- Chosen over `zxing-android-embedded` (decided 2026-07-20): ML Kit has
|
||||
better accuracy/latency, is maintained by Google, and works fully
|
||||
on-device without GMS.
|
||||
- These go in **`androidMain`** only — the no-new-dep rule covers
|
||||
`gateway-plugin/`, and the app already carries OkHttp/SQLDelight/KCEF/etc.
|
||||
Desktop is untouched.
|
||||
- `minSdk 29` is fine for all four (ML Kit barcode needs 21+).
|
||||
- Versions go in the existing version catalog / `composeVersion`-style
|
||||
constants at the top of the build file (follow the current pattern).
|
||||
|
||||
### B2. Scanner activity — `shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt` (new)
|
||||
|
||||
A minimal `ComponentActivity` (not a Fragment, no nav graph):
|
||||
|
||||
- Layout: full-screen `PreviewView` + overlay hint text ("Point at the QR
|
||||
code") + close button.
|
||||
- `ImageAnalysis` (STRATEGY_LATEST, YUV_420_888) →
|
||||
`BarcodeScannerOptions(FORMAT_QR_CODE)` → first result →
|
||||
`setResult(RESULT_OK, Intent().putExtra("iris.qr.text", raw))` → finish.
|
||||
- **Runtime permission:** request `CAMERA` on launch; on denial show a
|
||||
message + close (the Connect screen still has manual entry).
|
||||
- Registered in `shared/src/androidMain/AndroidManifest.xml` (or the
|
||||
androidApp manifest — follow where `NtfyListenerService` is declared)
|
||||
with `android:exported="false"`, `android:theme` reusing the app theme.
|
||||
- Manifest additions (androidApp manifest):
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
```
|
||||
|
||||
`required="false"` so the app stays installable on camera-less devices
|
||||
(the button then just reports "no camera").
|
||||
|
||||
### B3. Platform hook — `expect`/`actual`
|
||||
|
||||
`shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt` (new):
|
||||
|
||||
```kotlin
|
||||
/** Launch the QR scanner. [onResult] gets the decoded text, or null when
|
||||
* the user cancelled / no camera / permission denied. Desktop: no-op. */
|
||||
expect fun scanQrCode(onResult: (String?) -> Unit)
|
||||
```
|
||||
|
||||
- **androidMain actual:** `ActivityResultLauncher` (from the Compose
|
||||
`LocalContext`) starting `QrScanActivity`; maps `RESULT_OK` → text,
|
||||
everything else → `null`.
|
||||
- **desktopMain actual:** `onResult(null)` immediately (the button is
|
||||
hidden on desktop anyway — see B4; the no-op keeps the `expect` total).
|
||||
|
||||
### B4. Connect screen button — `ConnectScreen.kt`
|
||||
|
||||
- New **"Scan QR"** `Button` below the token field, rendered only when
|
||||
`!isDesktop` (`iris.platform.isDesktop` already exists).
|
||||
- On tap: `scanQrCode { raw -> … }`; on non-null `raw`:
|
||||
- `PairLink.parse(raw)` (B5) → pre-fill `url` and `token` state, clear
|
||||
error, and **do not auto-connect** — the user still taps
|
||||
"Test & Connect" (pairing stays an explicit act, per §10.8).
|
||||
- Parse failure → set `error` to "Not a pairing QR code" (don't echo the
|
||||
raw payload — it may contain someone else's token).
|
||||
- On `null` (cancel/denied): no-op, no error.
|
||||
|
||||
### B5. Pair-link parser — `shared/src/commonMain/kotlin/iris/util/PairLink.kt` (new)
|
||||
|
||||
```kotlin
|
||||
data class PairLink(val url: String, val token: String)
|
||||
|
||||
object PairLink {
|
||||
/** Parse `iris://pair?host=…&port=…&secure=…&token=…` → PairLink.
|
||||
* Returns null on any malformation. */
|
||||
fun parse(raw: String): PairLink?
|
||||
}
|
||||
```
|
||||
|
||||
- Accepts exactly scheme `iris`, host `pair` (case-insensitive scheme).
|
||||
- Required: `host` (non-empty), `token` (non-empty). `port` defaults to
|
||||
`8791` (the HTTP default, `docs/19`); `secure` defaults to `0`.
|
||||
- Builds `url` as `http(s)://<host>:<port>`; validates port 1–65535.
|
||||
- URL-decodes `host`/`token` (the Python side `quote()`s them).
|
||||
- Pure function, no platform imports → **unit-tested in `jvmTest`**
|
||||
(`:shared:testAndroidHostTest` / `:shared:desktopTest` both run it):
|
||||
valid link, missing token, bad port, wrong scheme, wrong host,
|
||||
percent-encoded host, secure=1 → https, default port.
|
||||
|
||||
### B6. `iris://pair` deep link (system-scanner fallback)
|
||||
|
||||
So a QR scanned by *any* app (phone's built-in scanner, a friend's phone)
|
||||
lands in Iris:
|
||||
|
||||
- Manifest: extend the existing `VIEW` intent-filter block (or add a
|
||||
sibling) with `<data android:scheme="iris" android:host="pair" />`.
|
||||
- `MainActivity.handleDeepLink`: on `iris://pair` → `PairLink.parse(uri)` →
|
||||
stash into a `mutableStateOf<PairLink?>` passed into `IrisApp` →
|
||||
`ConnectScreen` receives it as `prefillUrl`/`prefillToken` (the params
|
||||
already exist). If the app is already connected, ignore (or surface in
|
||||
Settings later — out of scope).
|
||||
- This reuses B5's parser; add one test for the URI shape Android delivers.
|
||||
|
||||
---
|
||||
|
||||
## 20.4 Part C — doc updates (with the implementation)
|
||||
|
||||
| Doc | Change |
|
||||
| ----- | -------- |
|
||||
| `09-pairing-security.md` | Gap #12 → **implemented** (fix the stale `adapter.py:648-654` reference while at it); §9.2 QR branch no longer aspirational |
|
||||
| `10-android-app.md` §10.8 | "or scan QR" becomes real: scanner button + deep link, camera permission |
|
||||
| `14-milestones.md` | New **M8 — QR pairing** section (acceptance criteria below) |
|
||||
| `16-open-questions.md` | Record decision: ML Kit over zxing; pure-stdlib encoder over vendoring `segno` |
|
||||
| `README.md` | Reading-order table: add row 20 |
|
||||
|
||||
---
|
||||
|
||||
## 20.5 Work breakdown & sequencing
|
||||
|
||||
Ordered so each step is independently verifiable; A and B can be
|
||||
interleaved (different languages, no shared surface).
|
||||
|
||||
| # | Task | Verify |
|
||||
| --- | ------ | -------- |
|
||||
| 1 | `qr.py` encoder + renderer (A1/A2) | new unit tests green (A4.1–4.4) |
|
||||
| 2 | `interactive_setup` integration (A3) | A4.5 + manual: `hermes gateway setup` in a real terminal shows a scannable QR (scan with the phone's *system* camera app as the decoder oracle) |
|
||||
| 3 | `PairLink` parser + jvmTest (B5) | `./gradlew :shared:testAndroidHostTest` |
|
||||
| 4 | Deps + manifest + `QrScanActivity` (B1/B2) | `:androidApp:assembleDebug` |
|
||||
| 5 | `PlatformQr` expect/actual + Connect button (B3/B4) | `:androidApp:assembleDebug` + `:desktopApp:run` (button absent, no crash) |
|
||||
| 6 | `iris://pair` deep link (B6) | ADB: `adb shell am start -a android.intent.action.VIEW -d "iris://pair?host=…&port=…&token=…"` → Connect screen pre-filled |
|
||||
| 7 | Doc updates (Part C) | — |
|
||||
|
||||
**On-device E2E (final gate, per `13-testing.md` ADB workflow):**
|
||||
|
||||
1. `hermes gateway setup` on the gateway host → QR in terminal.
|
||||
2. Phone: `adb shell am start -n dev.iris.app/.MainActivity` → Connect →
|
||||
**Scan QR** → grant camera → point at the terminal (screenshot the QR
|
||||
onto a second screen if needed; the reference device is API 29 —
|
||||
verify CameraX works on the MIX 2S in step 4 before building the rest).
|
||||
3. Fields pre-filled → **Test & Connect** → chat screen.
|
||||
4. Repeat via deep link (step 6 command) with a *different* token.
|
||||
5. Negative: scan a non-pairing QR (e.g. a website) → "Not a pairing QR
|
||||
code", fields untouched.
|
||||
|
||||
---
|
||||
|
||||
## 20.6 Acceptance criteria (M8)
|
||||
|
||||
- [ ] `hermes gateway setup` prints a QR that a stock Android camera app
|
||||
decodes to exactly `qr_payload(host, port, token)`.
|
||||
- [ ] QR encoder: fixed test vectors + size/round-trip tests green;
|
||||
**zero** new entries in the plugin's import surface (stdlib only —
|
||||
verifiable by `ruff`/import scan).
|
||||
- [ ] Android: Connect screen shows **Scan QR** (hidden on desktop);
|
||||
scanning the setup QR pre-fills URL + token; "Test & Connect" pairs.
|
||||
- [ ] Camera permission denied → graceful message, manual entry still works.
|
||||
- [ ] `iris://pair` deep link pre-fills the Connect screen (ADB-verified).
|
||||
- [ ] `PairLink.parse` unit tests cover the matrix in B5.
|
||||
- [ ] Full Python suite green: `scripts/run_tests.sh` (no args).
|
||||
- [ ] Docs updated per Part C; gap #12 closed.
|
||||
|
||||
## 20.7 Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ------ | ------------ |
|
||||
| Hand-rolled QR encoder has a subtle bug | Fixed spec test vectors (A4.1) + the system-camera-app oracle in the E2E gate; scope locked to byte mode / v1–10 so the surface stays small |
|
||||
| Terminal without UTF-8 mangles the QR | URL text lines remain the primary path; QR is additive |
|
||||
| CameraX quirks on API 29 (MIX 2S) | Build the scanner activity first (task 4) and verify on-device before wiring the UI |
|
||||
| ML Kit model size (~4 MB) | Bundled in the APK, on-device, no runtime download — acceptable for this app's footprint |
|
||||
| Token in QR scanned by a bystander's phone | Same trust domain as the token already printed in the terminal; LAN pairing is operator-supervised by design (§9.2). Deep link only pre-fills — it never auto-connects |
|
||||
| `secure=1` (WSS) URLs | Parser already handles `secure` → `https://`; QR payload unchanged |
|
||||
|
||||
## 20.8 Explicit non-goals
|
||||
|
||||
- **QR display in the app** (showing a QR for other devices to scan) —
|
||||
single-device pairing today; revisit if multi-device lands.
|
||||
- **`hermes iris pair` stretch CLI** (re-issue token + new QR,
|
||||
`09-pairing-security.md` §9.2 line 48) — separate backlog item.
|
||||
- **WSS cert pinning** (gap #6) — orthogonal; QR carries `secure=1`
|
||||
already, pinning is app-side.
|
||||
- **iOS scanner** — no iOS target (per `00-overview.md`).
|
||||
+17
-7
@@ -8,6 +8,7 @@ This folder is the single source of truth for *what to build and why*. Read it
|
||||
top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
||||
|
||||
> ⚠️ **READ FIRST — two hard rules**
|
||||
>
|
||||
> 1. **`hermes-agent/` (sibling of this folder) is a read-only research
|
||||
> reference. It must NEVER be committed, pushed, or shipped.** It is
|
||||
> git-ignored at the repo root. We only *install* our plugin into a live
|
||||
@@ -17,10 +18,15 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
||||
|
||||
---
|
||||
|
||||
## User-facing guides
|
||||
|
||||
- [`install.md`](install.md) — **install the gateway + connect the app** (non-technical walkthrough, all options, TLS, push).
|
||||
- [`setup.md`](setup.md) — moved; pointer to `install.md`.
|
||||
|
||||
## Reading order
|
||||
|
||||
| # | File | When to read |
|
||||
|---|------|--------------|
|
||||
| --- | ------ | -------------- |
|
||||
| 0 | [`00-overview.md`](00-overview.md) | Always first. Vision, scope, disclaimers, locked decisions. |
|
||||
| 1 | [`01-architecture.md`](01-architecture.md) | Before touching code. System shape + rationale. |
|
||||
| 2 | [`02-monorepo.md`](02-monorepo.md) | When scaffolding the repo. |
|
||||
@@ -29,9 +35,9 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
||||
| 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. |
|
||||
| 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. |
|
||||
| 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. |
|
||||
| 8 | [`08-push.md`](08-push.md) | Push (FCM + ntfy), outbox, sync. |
|
||||
| 8 | [`08-push.md`](08-push.md) | Push (ntfy default + FCM optional), outbox, sync. |
|
||||
| 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. |
|
||||
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Android app. |
|
||||
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Iris app (Android). |
|
||||
| 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. |
|
||||
| 12 | [`12-toolchain.md`](12-toolchain.md) | First time on a machine (JDK/SDK/uv/Firebase). |
|
||||
| 13 | [`13-testing.md`](13-testing.md) | Writing tests + on-device ADB workflow. |
|
||||
@@ -39,24 +45,28 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
||||
| 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. |
|
||||
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
|
||||
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
|
||||
| 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. |
|
||||
| 20 | [`20-qr-pairing.md`](20-qr-pairing.md) | Terminal QR at `gateway setup` + in-app QR scanner (Android) + `iris://pair` deep link. |
|
||||
|
||||
Machine-readable / diagrams:
|
||||
|
||||
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
|
||||
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
|
||||
- [`playstore-listing.md`](playstore-listing.md) — Play Store listing text (incl. the FCM/ntfy privacy note).
|
||||
|
||||
---
|
||||
|
||||
## The three deliverables (one monorepo)
|
||||
|
||||
1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `android`.
|
||||
1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `iris`.
|
||||
Runs inside the `hermes gateway` process. Opens a WebSocket server the apps
|
||||
connect to. Implements the full `BasePlatformAdapter` contract. **Zero new
|
||||
Python dependencies, zero hermes-core changes.**
|
||||
2. **`app/androidApp`** — native Kotlin + Jetpack Compose client.
|
||||
3. **`app/desktopApp`** — Kotlin + Compose Multiplatform client that *shares*
|
||||
the Android app's code and is "tweaked" for a big screen.
|
||||
the Iris app's code and is "tweaked" for a big screen.
|
||||
|
||||
The Android and Desktop clients live in **one Compose Multiplatform Gradle
|
||||
The Iris Android and Desktop clients live in **one Compose Multiplatform Gradle
|
||||
project** (`app/`) with a shared KMP module (`app/shared`).
|
||||
|
||||
---
|
||||
@@ -65,4 +75,4 @@ project** (`app/`) with a shared KMP module (`app/shared`).
|
||||
|
||||
- **Phase:** M0–M6 complete; M7 (polish + E2E + docs) in progress.
|
||||
- **Owner decisions locked:** see [`16-open-questions.md`](16-open-questions.md).
|
||||
- **Last updated:** 2026-08-19.
|
||||
- **Last updated:** 2026-08-19.
|
||||
@@ -6,8 +6,8 @@ flowchart TB
|
||||
AGENT["Agent core<br/>(run_agent.py)"]
|
||||
SESS["Sessions<br/>(SQLite + FTS5)"]
|
||||
CRON["Cron scheduler"]
|
||||
subgraph PLUGIN["android PLATFORM PLUGIN"]
|
||||
ADAPTER["AndroidAdapter<br/>(BasePlatformAdapter)"]
|
||||
subgraph PLUGIN["IRIS PLATFORM PLUGIN"]
|
||||
ADAPTER["IrisAdapter<br/>(BasePlatformAdapter)"]
|
||||
OUTBOX["Outbox (SQLite)<br/>+ sync cursor"]
|
||||
PUSH["push.py<br/>FcmBackend / NtfyBackend"]
|
||||
MEDIA["media.py<br/>cache + chunk stream"]
|
||||
@@ -17,7 +17,7 @@ flowchart TB
|
||||
WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"]
|
||||
end
|
||||
AGENT -->|legacy stream callbacks| ADAPTER
|
||||
CRON -->|deliver=android:chat:thread| ADAPTER
|
||||
CRON -->|deliver=iris:chat:thread| ADAPTER
|
||||
ADAPTER <--> WSS
|
||||
ADAPTER <--> OUTBOX
|
||||
ADAPTER <--> PUSH
|
||||
@@ -33,7 +33,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph DEVICES["Clients"]
|
||||
PHONE["ANDROID APP<br/>(Kotlin / Compose)<br/>WS client + ExoPlayer + FCM"]
|
||||
PHONE["IRIS APP (ANDROID)<br/>(Kotlin / Compose)<br/>WS client + ExoPlayer + FCM"]
|
||||
DESKTOP["DESKTOP APP<br/>(Compose Multiplatform)<br/>WS client + tray + desktop player"]
|
||||
end
|
||||
|
||||
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
# Install — Gateway & App
|
||||
|
||||
A step-by-step guide for getting **Iris** (the Android / Desktop app) talking
|
||||
to your **hermes gateway**. Written for people who just want to *use* it, not
|
||||
build it. If you only want the short version, the [README](../README.md) has
|
||||
the three commands that matter.
|
||||
|
||||
The whole setup has two halves:
|
||||
|
||||
1. **The gateway** — a small plugin that runs *inside* your existing hermes
|
||||
install and opens a door for the app to connect through.
|
||||
2. **The app** — on your phone or desktop, where you enter the gateway's
|
||||
address and a pairing token.
|
||||
|
||||
> **Old guides?** Earlier versions of Iris used a WebSocket on port `8790`
|
||||
> (`ws://…/ws`). The transport is now plain HTTP on port **`8791`**
|
||||
> (see [`19-http-fallback-transport.md`](19-http-fallback-transport.md)).
|
||||
> The app still accepts old `ws://` URLs and converts them automatically, but
|
||||
> new setups should use the `http://` URL printed by `hermes gateway setup`.
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
| Where | What |
|
||||
| --- | --- |
|
||||
| Gateway host (any always-on computer: home server, Raspberry Pi, laptop) | [hermes-agent](https://github.com/NousResearch/hermes-agent) installed with its venv (`cd hermes-agent && uv sync`) |
|
||||
| Phone / desktop | Android 8+ or Linux / macOS / Windows |
|
||||
| Only if you build the app yourself | JDK 17 (+ Android SDK for Android) — see [`12-toolchain.md`](12-toolchain.md) |
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Install the gateway plugin (one-time)
|
||||
|
||||
Iris is a regular hermes **platform plugin**, so it installs with the normal
|
||||
plugin command. This repo is a *monorepo* (the plugin lives in the
|
||||
`gateway-plugin/` subfolder, next to the app), so you point the installer at
|
||||
that subfolder with a `#subfolder` suffix:
|
||||
|
||||
```bash
|
||||
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
|
||||
```
|
||||
|
||||
That's it. The installer clones the repo, copies just the `gateway-plugin/`
|
||||
folder into `~/.hermes/plugins/`, and asks whether to enable it now (say
|
||||
**yes**).
|
||||
|
||||
Notes:
|
||||
|
||||
- Any git URL works with the `#gateway-plugin` suffix — e.g.
|
||||
`https://gitea.zephyre.one/ARIA/iris_x_hermes.git#gateway-plugin` if you
|
||||
prefer HTTPS.
|
||||
- **Developing from a checkout?** Skip the install and symlink instead — the
|
||||
plugin then always tracks your working tree:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||
```
|
||||
|
||||
- Check it was picked up:
|
||||
|
||||
```bash
|
||||
hermes gateway status # the Iris platform should be listed
|
||||
```
|
||||
|
||||
## Part 2 — Gateway setup (one-time)
|
||||
|
||||
Run the interactive setup:
|
||||
|
||||
```bash
|
||||
hermes gateway setup
|
||||
```
|
||||
|
||||
It walks you through five things:
|
||||
|
||||
| Prompt | What it means | Default |
|
||||
| --- | --- | --- |
|
||||
| **Iris pairing token** | A long random secret the app must present to connect. Generated for you; stored in `~/.hermes/.env` as `IRIS_TOKEN`. **It is printed only once** — write it down. | auto-generated |
|
||||
| **HTTP host** | Which network address the gateway listens on. `127.0.0.1` = only this machine. For a phone on your home network, use the machine's **LAN IP** (e.g. `192.168.1.10`). | `127.0.0.1` |
|
||||
| **Port** | The port the app connects to. | `8791` |
|
||||
| **Push backend** | How offline notifications are delivered: `ntfy` (default, stays on your own infrastructure) or `fcm` (Google). See [Part 5](#part-5--push-notifications-optional). | `ntfy` |
|
||||
| **TLS** | Only asked when no certificate is configured yet: generates a **self-signed** certificate + key under `~/.hermes/iris/` and stores the paths in `.env` (`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`), so the gateway serves `https://`. The app asks you to confirm the printed SHA-256 fingerprint once (like an SSH host key). | No (Yes if you bound `0.0.0.0`) |
|
||||
|
||||
When it finishes it prints two things you need for the app:
|
||||
|
||||
- **Server URL** — e.g. `http://192.168.1.10:8791`
|
||||
- **Pairing QR + URL** — an `iris://pair?…` string with a scannable QR code
|
||||
|
||||
Then start the gateway:
|
||||
|
||||
```bash
|
||||
hermes gateway # (or: hermes gateway restart after changes)
|
||||
```
|
||||
|
||||
## Part 3 — Install the app
|
||||
|
||||
### Android
|
||||
|
||||
Build a debug APK on any machine with JDK 17 + the Android SDK:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :androidApp:assembleDebug
|
||||
# → app/androidApp/build/outputs/apk/debug/androidApp-debug.apk
|
||||
```
|
||||
|
||||
Copy the APK to the phone (cable, LAN share, or any file transfer) and open
|
||||
it — Android will ask to allow installs from unknown sources.
|
||||
|
||||
*Shortcut for developers with a USB-connected phone:*
|
||||
`./gradlew :androidApp:installDebug` installs it directly.
|
||||
|
||||
### Desktop
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :desktopApp:jpackage # → app/desktopApp/build/…/ (native app, JRE bundled)
|
||||
```
|
||||
|
||||
On Linux the launcher may print a `pure virtual method called` warning —
|
||||
it's a known, harmless jpackage bug (JDK-8348560); the app works fine.
|
||||
|
||||
## Part 4 — Connect the app
|
||||
|
||||
Open the app. The first screen is **Connect**. You need the **Server URL**
|
||||
and the **pairing token** from Part 2.
|
||||
|
||||
### Option A — Same home network (no encryption, simplest)
|
||||
|
||||
Works out of the box on a trusted home network:
|
||||
|
||||
1. **Server URL:** the one printed by `hermes gateway setup`,
|
||||
e.g. `http://192.168.1.10:8791`.
|
||||
- On a phone, use the gateway's **LAN IP** — not `127.0.0.1` (that only
|
||||
means "this device" and won't reach your server).
|
||||
- If you set the host to `127.0.0.1` during setup, re-run
|
||||
`hermes gateway setup` and enter the LAN IP instead.
|
||||
2. **Pairing token:** the long token from the setup output (or
|
||||
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host).
|
||||
3. **Test & Connect.**
|
||||
|
||||
> **Android shortcut:** the Connect screen has a **Scan QR** button — point
|
||||
> the camera at the QR printed by `hermes gateway setup` and the URL + token
|
||||
> fill themselves in. (Desktop has no camera, so it's manual entry.)
|
||||
|
||||
### Option B — Encrypted (TLS) — recommended for anything beyond your LAN
|
||||
|
||||
Plain `http://` is fine on a home network you trust, but for remote access
|
||||
you want the traffic encrypted. The gateway can serve `https://` itself:
|
||||
|
||||
1. Create a certificate + key. Three flavors:
|
||||
- **Generated by setup (easiest)**: `hermes gateway setup` offers to
|
||||
generate a **self-signed** certificate for you (see the TLS prompt in
|
||||
[Part 2](#part-2--gateway-setup-one-time)). It writes
|
||||
`~/.hermes/iris/iris.crt` + `iris.key`, stores the paths in `.env`, and
|
||||
prints the SHA-256 fingerprint the app will ask you to confirm.
|
||||
- **CA-signed** (Let's Encrypt, or your own CA): works out of the box.
|
||||
- **Self-signed manually** (e.g. `openssl req -x509 -newkey rsa:2048
|
||||
-nodes -keyout iris.key -out iris.crt -days 3650 -subj "/CN=iris"
|
||||
-addext "subjectAltName=DNS:iris.example.com,IP:192.168.1.10"`):
|
||||
the certificate **must** carry a SAN entry matching the host you'll
|
||||
type in the app.
|
||||
2. Put the paths in `~/.hermes/.env` on the gateway host:
|
||||
|
||||
```ini
|
||||
IRIS_HTTP_CERT=/path/to/iris.crt
|
||||
IRIS_HTTP_KEY=/path/to/iris.key
|
||||
```
|
||||
|
||||
3. `hermes gateway restart`.
|
||||
4. In the app, use the **`https://`** URL, e.g. `https://iris.example.com:8791`.
|
||||
|
||||
**Self-signed certificates:** the app won't trust them automatically (by
|
||||
design). On first connect it shows the certificate's SHA-256 fingerprint and
|
||||
asks you to confirm it — exactly like an SSH host key. Compare the
|
||||
fingerprint with the one on the gateway host
|
||||
(`openssl x509 -fingerprint -sha256 -noout -in iris.crt`), confirm, and it's
|
||||
pinned in the app's secure storage from then on. If the certificate ever
|
||||
changes, you'll be asked to confirm again. No system trust-store installs
|
||||
needed.
|
||||
|
||||
### Reaching the gateway from outside your home network
|
||||
|
||||
Pick one (in order of preference):
|
||||
|
||||
- **Tailscale / WireGuard (recommended).** Install Tailscale on the gateway
|
||||
host; the app connects to the stable tailnet IP, e.g.
|
||||
`http://100.x.y.z:8791`. No public exposure at all — and since the traffic
|
||||
travels inside the encrypted mesh, plain `http://` is acceptable here.
|
||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok). Terminate TLS
|
||||
at the edge and forward to `127.0.0.1:8791` on the gateway host.
|
||||
- **Public bind + TLS + strong token** (`IRIS_HTTP_HOST=0.0.0.0` + Option B).
|
||||
Last resort — the port is then reachable from the internet; the token and
|
||||
TLS are what protect it.
|
||||
|
||||
## Part 5 — Push notifications (optional)
|
||||
|
||||
Push wakes a backgrounded or offline phone so you see replies even when the
|
||||
app is closed. Nothing is lost either way — on reconnect the app syncs its
|
||||
outbox.
|
||||
|
||||
- **ntfy (default)** — the phone generates its own topic automatically; the
|
||||
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy**
|
||||
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata
|
||||
stays on your own infrastructure — this is the private option.
|
||||
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
|
||||
metadata (notification title, device token) is routed through **Google's
|
||||
servers**. Needs a Firebase project + `google-services.json` in the app
|
||||
build. Without it, FCM is inert and ntfy is the path.
|
||||
|
||||
Details: [`08-push.md`](08-push.md).
|
||||
|
||||
---
|
||||
|
||||
## Gateway options (reference)
|
||||
|
||||
Everything is configured in `~/.hermes/.env` on the gateway host (or via the
|
||||
prompts of `hermes gateway setup`). After changes: `hermes gateway restart`.
|
||||
|
||||
| Variable | What it does | Default |
|
||||
| --- | --- | --- |
|
||||
| `IRIS_TOKEN` | Pairing token the app must present. | — (required) |
|
||||
| `IRIS_HTTP_HOST` | Bind address. `127.0.0.1` = local only; LAN IP = same network; `0.0.0.0` = all interfaces. | `127.0.0.1` |
|
||||
| `IRIS_HTTP_PORT` | Port the app connects to. | `8791` |
|
||||
| `IRIS_HOME_CHANNEL` | Default chat for cron/notification delivery. | `default` |
|
||||
| `IRIS_ALLOWED_USERS` | Comma-separated device ids allowed to connect (empty = token-only auth). | empty |
|
||||
| `IRIS_ALLOW_ALL_USERS` | Allow any paired device (**dev only**). | `false` |
|
||||
| `IRIS_PUSH_BACKEND` | `ntfy` or `fcm`. | `ntfy` |
|
||||
| `IRIS_FCM_SERVICE_ACCOUNT` | Path to Firebase service-account JSON (FCM). | — |
|
||||
| `IRIS_FCM_SERVER_KEY` | Legacy FCM server key (fallback). | — |
|
||||
| `NTFY_SERVER_URL` | ntfy server. Self-hosting recommended. | `https://ntfy.sh` |
|
||||
| `NTFY_AUTH_TOKEN` | Auth token for a private ntfy topic (real trust boundary). | — |
|
||||
| `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY` | TLS cert/key paths → serves `https://` (see Part 4, Option B). | — |
|
||||
|
||||
Security model (tokens, device allowlist, transport):
|
||||
[`09-pairing-security.md`](09-pairing-security.md).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
| --- | --- |
|
||||
| `auth failed` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` (setup prints it only when it generates it). |
|
||||
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8791`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
||||
| Phone can't reach the gateway | Gateway bound to `127.0.0.1` — re-run `hermes gateway setup` and set the LAN IP; or the phone is on a different network/VLAN. |
|
||||
| TLS handshake fails | Cert has no SAN matching the URL host; or the pinned fingerprint is stale after a cert change (re-confirm in the app). |
|
||||
| Push not arriving | Backend not configured (check `~/.hermes/logs/gateway.log`); ntfy.sh flakiness — self-host ntfy. |
|
||||
| Start over on a phone | `adb shell pm clear dev.iris.app` wipes the app's pairing state. |
|
||||
|
||||
Logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Play Store Listing
|
||||
|
||||
Copy-paste text for the Play Console (and the F-Droid / sideload pages).
|
||||
Keep this file in sync with the README whenever the privacy story changes.
|
||||
|
||||
## Short description (≤ 80 chars)
|
||||
|
||||
Private chat for your personal AI agent — Android & desktop.
|
||||
|
||||
## Full description
|
||||
|
||||
Iris is a native chat app for your personal AI agent (hermes-agent):
|
||||
streaming replies, visible reasoning, structured tool activity, channels,
|
||||
threads, media, search — Telegram-quality, on your own infrastructure.
|
||||
|
||||
**Absolute Privacy!** — everything stays on your own infrastructure:
|
||||
|
||||
- Your gateway, your machine, your data. No cloud middleman for chat.
|
||||
- **Push notifications:** ntfy by default — push metadata stays on your own
|
||||
(self-hosted) ntfy server.
|
||||
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
|
||||
push metadata (notification title, device token) is routed through
|
||||
**Google's servers**. If you want truly private communication, use ntfy
|
||||
(self-hosted) instead — it is the default.
|
||||
|
||||
100 MB file uploads by default (configurable on your gateway). No
|
||||
4,096-character message limit like Telegram. Full markdown + HTML artifact
|
||||
preview. All settings live in the app.
|
||||
|
||||
Runs on Android and desktop (Linux, macOS, Windows) from one shared codebase.
|
||||
|
||||
## Privacy note (for the "Data safety" section / FAQ)
|
||||
|
||||
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
|
||||
that push metadata never leaves your infrastructure. If you explicitly enable
|
||||
FCM, push 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
|
||||
(self-hosted).
|
||||
@@ -10,7 +10,7 @@
|
||||
"v": { "type": "integer", "const": 1, "description": "Protocol version." },
|
||||
"id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." },
|
||||
"type": { "type": "string", "description": "Frame type (see frame_types)." },
|
||||
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. android:default, android:chan_7)." },
|
||||
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. default, chan_7)." },
|
||||
"thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." },
|
||||
"cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." },
|
||||
"payload": { "type": "object", "description": "Type-specific payload." }
|
||||
@@ -24,6 +24,7 @@
|
||||
"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"} } },
|
||||
"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)." },
|
||||
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
|
||||
}
|
||||
},
|
||||
@@ -47,9 +48,10 @@
|
||||
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "runtime": { "$ref": "#/definitions/runtime" }, "ts": { "type": "integer" } } },
|
||||
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
|
||||
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
||||
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
|
||||
"tool.start": { "description": "Cosmetic per-tool emoji (resolved server-side via hermes' get_tool_emoji: active-skin overrides, then the tool registry's per-tool emoji); omitted when the tool is unknown so the app falls back to its own default glyph.", "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" }, "emoji": { "type": "string" } } },
|
||||
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
|
||||
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
|
||||
"todo.update": { "description": "The agent's FULL current todo list for a chat/thread lane (last-write-wins). Emitted whenever the `todo` tool completes (the tool result is authoritative even for merge writes, whose args carry only the changed items) and, as a snapshot, right after hello when a device opens its event stream. Ephemeral: never outboxed, so a reconnecting device learns the list from the snapshot, not a replay. The app renders it as a compact scrollable strip above the composer.", "payload": { "todos": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "content": { "type": "string" }, "status": { "type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"] } } } } } },
|
||||
"typing": { "payload": { "on": { "type": "boolean" } } },
|
||||
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
|
||||
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
|
||||
@@ -59,14 +61,15 @@
|
||||
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
|
||||
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
|
||||
"read.receipt": { "description": "Agent received and started processing the user's message; app shows ✓✓ on user bubbles. Emitted to the originating connection when a message.send is accepted for processing.", "payload": { "message_id": { "type": "string" } } },
|
||||
"status": { "description": "Gateway health state; broadcast to all connected clients at startup (state=online).", "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] } } },
|
||||
"status": { "description": "Gateway health state; broadcast to all connected clients at startup (state=online) and to late joiners on hello.ack. state=restarting is broadcast on the gateway's shutdown path (restart/stop) before the sockets close; the app shows the 'Gateway restarting' chat notice only on that signal, not on a plain network drop.", "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] } } },
|
||||
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
|
||||
"pong": { "payload": { "ts": { "type": "integer" } } },
|
||||
"sync.done": { "payload": { "cursor": { "type": "integer" } } },
|
||||
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "runtime": {"$ref":"#/definitions/runtime"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } },
|
||||
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } },
|
||||
"media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } },
|
||||
"commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } }
|
||||
"commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } },
|
||||
"picker.choice": { "description": "Interactive choice picker (one tap -> one value) for finite-choice slash commands (/reasoning, /fast, ...). The app renders the title + choice buttons and answers with picker.select carrying the same picker_id. Outboxed, so a reconnecting device re-renders a still-pending picker; pending state is in-memory only (a gateway restart expires it).", "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object", "properties": { "value": { "type": "string" }, "label": { "type": "string" }, "is_current": { "type": "boolean" } } } } } }
|
||||
},
|
||||
"app_to_server": {
|
||||
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
||||
@@ -88,6 +91,7 @@
|
||||
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
|
||||
"message.delete": { "description": "Completely delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and from the hermes session store (so no search trace survives and they are not recoverable), then broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } },
|
||||
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
||||
"picker.select": { "description": "Answer an interactive picker (picker.choice). The server runs the command's selection callback and delivers its reply as a normal message in the picker's chat. Unknown/expired picker ids are a no-op.", "payload": { "picker_id": { "type": "string" }, "value": { "type": "string" } } },
|
||||
"ping": { "payload": { "ts": { "type": "integer" } } }
|
||||
}
|
||||
},
|
||||
@@ -99,11 +103,9 @@
|
||||
},
|
||||
"x-planned-frames": [
|
||||
{ "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." },
|
||||
{ "name": "picker.choice", "direction": "server_to_app", "note": "Generic choice picker prompt. Planned, not implemented." },
|
||||
{ "name": "picker.clarify", "direction": "server_to_app", "note": "Clarify picker prompt. Planned, not implemented (clarifies arrive as notification + message)." },
|
||||
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
|
||||
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
|
||||
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
|
||||
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented (the app fuzzy-matches the commands.catalog list client-side)." },
|
||||
{ "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." },
|
||||
{ "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." },
|
||||
|
||||
+8
-168
@@ -1,170 +1,10 @@
|
||||
# Setup — Pairing a Device
|
||||
|
||||
User-facing guide: get a phone or desktop talking to your hermes gateway in
|
||||
under 10 minutes. Design rationale lives in the numbered docs
|
||||
([`09-pairing-security.md`](09-pairing-security.md),
|
||||
[`08-push.md`](08-push.md), [`12-toolchain.md`](12-toolchain.md)); this page is
|
||||
just the steps.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Where | You need |
|
||||
|---|---|
|
||||
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) |
|
||||
| Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device |
|
||||
| Desktop build machine | JDK 17 only |
|
||||
|
||||
Gradle needs no system install — both apps use the project wrapper
|
||||
(`./gradlew`). First-time machine setup: [`12-toolchain.md`](12-toolchain.md).
|
||||
|
||||
## 1. Gateway setup (on the gateway host)
|
||||
|
||||
Install the plugin into the live hermes home (dev: a symlink from the monorepo
|
||||
root):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
|
||||
hermes gateway status # should list "android"
|
||||
```
|
||||
|
||||
Run the interactive setup:
|
||||
|
||||
```bash
|
||||
hermes gateway setup
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
- Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in
|
||||
`~/.hermes/.env` (it prints the token once, at generation).
|
||||
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
|
||||
and push backend (`fcm` or `ntfy`, default `fcm`).
|
||||
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
|
||||
string) and the server URL (`ws://<host>:8790/ws`).
|
||||
|
||||
Then start the gateway:
|
||||
|
||||
```bash
|
||||
hermes gateway # or: hermes gateway restart after config changes
|
||||
```
|
||||
|
||||
> **Note:** the default bind host `127.0.0.1` only accepts connections from the
|
||||
> gateway host itself (e.g. a desktop app on the same machine). For a phone on
|
||||
> the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set
|
||||
> `ANDROID_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
|
||||
|
||||
## 2. Android app
|
||||
|
||||
Build and install (ADB device connected):
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :androidApp:installDebug
|
||||
```
|
||||
|
||||
First run opens the **Connect** screen:
|
||||
|
||||
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (the URL printed by
|
||||
`hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone).
|
||||
2. **Pairing token** — from the `hermes gateway setup` output, or
|
||||
`grep ANDROID_TOKEN ~/.hermes/.env` on the gateway host.
|
||||
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
|
||||
pairing and connects.
|
||||
|
||||
> **Honest limitation:** QR scanning is **not** supported in the app yet. The
|
||||
> server prints a QR payload, but pairing is manual URL + token entry only.
|
||||
|
||||
## 3. Desktop app
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :desktopApp:run # dev run
|
||||
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
|
||||
```
|
||||
|
||||
Pairing is the same Connect screen (URL + token); the token is stored in the OS
|
||||
keyring (with an encrypted-file fallback). Desktop push is tray icon + OS
|
||||
notifications (no FCM).
|
||||
|
||||
> **Known issue:** on Linux with JDK 17 the jpackage launcher prints a
|
||||
> non-fatal `pure virtual method called` warning (JDK-8348560, a
|
||||
> jpackage/Linux launcher bug). The app runs and connects regardless.
|
||||
|
||||
## 4. Push notifications
|
||||
|
||||
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
||||
outbox, so nothing is lost. Push fires when the device is offline, plus for
|
||||
high-priority events (approvals, clarifies, cron) even when a device is live.
|
||||
|
||||
### FCM (default; needs a Firebase project)
|
||||
|
||||
1. Create a Firebase project (console.firebase.google.com) and add an Android
|
||||
app with the app's applicationId; download `google-services.json` into
|
||||
`app/androidApp/`.
|
||||
2. Create a service account (Project settings → Service accounts → Generate new
|
||||
private key) and store the JSON path in `~/.hermes/.env`:
|
||||
`ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
|
||||
3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default).
|
||||
|
||||
Without a Firebase project the FCM path is **inert** (the app's FCM service
|
||||
does nothing) — use ntfy below, or add Firebase later.
|
||||
|
||||
**What you see:** system notifications for new messages when the app is
|
||||
backgrounded; tapping one deep-links to the chat.
|
||||
|
||||
### ntfy (zero-config fallback)
|
||||
|
||||
```
|
||||
ANDROID_PUSH_BACKEND=ntfy
|
||||
```
|
||||
|
||||
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
|
||||
the server publishes to it.
|
||||
- `NTFY_SERVER_URL` defaults to `https://ntfy.sh`. **Self-hosted ntfy is
|
||||
recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its
|
||||
web UI instead of the stream), while a self-hosted instance gives reliable
|
||||
SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`.
|
||||
|
||||
**What you see:** a low-priority foreground "ntfy listener" notification while
|
||||
the app is off; incoming pushes trigger a silent sync.
|
||||
|
||||
## 5. Remote access
|
||||
|
||||
- **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP;
|
||||
the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
|
||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
|
||||
the edge, forward the WebSocket to `127.0.0.1:8790`.
|
||||
- **WSS:** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY` (paths, in
|
||||
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
|
||||
|
||||
> **Honest limitation:** the app has **no certificate pinning** yet, so
|
||||
> self-signed certs won't work — remote access requires **CA-signed** WSS for
|
||||
> now. Plain `ws://` on a trusted LAN (or inside Tailscale) stays the default.
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|---|---|
|
||||
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `ANDROID_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
|
||||
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
||||
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
|
||||
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
|
||||
|
||||
Smoke test without the app (from the gateway host):
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
import asyncio, json, websockets
|
||||
async def main():
|
||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
||||
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe",
|
||||
"caps":{"min_protocol":1}}}))
|
||||
print("recv:", await ws.recv())
|
||||
asyncio.run(main())
|
||||
PY
|
||||
```
|
||||
|
||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
||||
wrong.
|
||||
> **Moved.** The user-facing setup guide now lives in
|
||||
> [`install.md`](install.md) — gateway install, all options, app install,
|
||||
> and connecting (LAN / TLS / remote). This file is kept so old links keep
|
||||
> working.
|
||||
>
|
||||
> - Push details: [`08-push.md`](08-push.md)
|
||||
> - Security model: [`09-pairing-security.md`](09-pairing-security.md)
|
||||
> - Toolchain (first-time machine setup): [`12-toolchain.md`](12-toolchain.md)
|
||||
+319
-2519
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,358 @@
|
||||
"""M3: channel-directory frame handlers (``channel.*`` + directory queries).
|
||||
|
||||
Mixin for ``adapter.IrisAdapter``. Each request is answered by broadcasting
|
||||
the matching ``channel.*`` event carrying the request ``id``: the requester's
|
||||
pending request completes on the id, and every other device reconciles its
|
||||
local copy from the same frame (single broadcast serves as event + response).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
from . import protocol
|
||||
from . import purge as purge_bridge
|
||||
from .defaults import DEFAULT_HOME_CHANNEL
|
||||
from .mixin_base import IrisAdapterBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChannelFrameHandlers(IrisAdapterBase):
|
||||
"""Channel directory management (see module docstring)."""
|
||||
|
||||
async def on_channel_create(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
payload = frame.payload
|
||||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
kind = payload.get("kind")
|
||||
kind = kind if kind in ("channel", "thread") else "channel"
|
||||
parent_chat_id = payload.get("parent_chat_id")
|
||||
if not isinstance(parent_chat_id, str) or not parent_chat_id.strip():
|
||||
parent_chat_id = None
|
||||
if kind == "thread" and not parent_chat_id:
|
||||
parent_chat_id = frame.chat_id or self.home_channel
|
||||
try:
|
||||
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
|
||||
except ValueError as e:
|
||||
await self._reply(
|
||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_created(entry)
|
||||
resp.id = frame.id
|
||||
await self._http_server.fanout(resp)
|
||||
# M5: banner + push mirror (parked in the outbox when offline).
|
||||
await self._broadcast_or_log(
|
||||
entry["chat_id"],
|
||||
protocol.notification(
|
||||
entry["chat_id"],
|
||||
protocol.NOTIF_CHANNEL_CREATED,
|
||||
"Channels",
|
||||
f"New channel: {name}",
|
||||
),
|
||||
)
|
||||
|
||||
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
name = frame.payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
entry = self._channels.rename(chat_id, name)
|
||||
except ValueError as e:
|
||||
await self._reply(
|
||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||
)
|
||||
return
|
||||
if entry is None:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._http_server.fanout(resp)
|
||||
# M5: banner + push mirror (parked in the outbox when offline).
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.notification(
|
||||
chat_id,
|
||||
protocol.NOTIF_CHANNEL_RENAMED,
|
||||
"Channels",
|
||||
f"Renamed to {name}",
|
||||
),
|
||||
)
|
||||
|
||||
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
entry = self._channels.set_default(chat_id)
|
||||
if entry is None:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||
# new is_default flag) so every device reconciles the default change.
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._http_server.fanout(resp)
|
||||
|
||||
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
on = bool(frame.payload.get("on"))
|
||||
entry = self._channels.set_favorite(chat_id, on)
|
||||
if entry is None:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||
# new favorite flag) so every device reconciles the change.
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._http_server.fanout(resp)
|
||||
|
||||
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
payload = frame.payload
|
||||
icon = payload.get("icon")
|
||||
icon = icon if isinstance(icon, str) and icon else None
|
||||
color = payload.get("color")
|
||||
color = color if isinstance(color, str) and color else None
|
||||
# Guard against a runaway base64 blob (a channel icon is small).
|
||||
if icon is not None and len(icon) > 512 * 1024:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
|
||||
)
|
||||
return
|
||||
entry = self._channels.set_icon(chat_id, icon, color)
|
||||
if entry is None:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._http_server.fanout(resp)
|
||||
|
||||
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
on = bool(frame.payload.get("on"))
|
||||
entry = self._channels.set_automation(chat_id, on)
|
||||
if entry is None:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND,
|
||||
f"cannot set automation on {chat_id} (unknown or default)",
|
||||
id=frame.id,
|
||||
),
|
||||
)
|
||||
return
|
||||
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||
# new automation flag) so every device reconciles the change.
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._http_server.fanout(resp)
|
||||
|
||||
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
entry = self._channels.delete(chat_id)
|
||||
if entry is None:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_NOT_FOUND,
|
||||
f"cannot delete {chat_id} (unknown or default)",
|
||||
id=frame.id,
|
||||
),
|
||||
)
|
||||
return
|
||||
# Complete deletion: wipe the lane's history from the outbox (so
|
||||
# ``history`` / ``sync`` can't resurrect it) and from the hermes
|
||||
# session store (so no search trace survives). A channel delete takes
|
||||
# its threads with it (thread_id=None); a thread delete is scoped to
|
||||
# its parent channel + thread_id.
|
||||
if entry.get("kind") == "thread":
|
||||
lane_chat_id = entry.get("parent_chat_id") or chat_id
|
||||
thread_id = chat_id
|
||||
else:
|
||||
lane_chat_id = chat_id
|
||||
thread_id = None
|
||||
removed_frames = self._outbox.delete_lane(lane_chat_id, thread_id=thread_id)
|
||||
removed_msgs = purge_bridge.delete_lane(
|
||||
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
||||
)
|
||||
logger.info(
|
||||
"iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||
chat_id,
|
||||
entry.get("kind"),
|
||||
removed_frames,
|
||||
removed_msgs,
|
||||
)
|
||||
resp = protocol.channel_deleted(chat_id)
|
||||
resp.id = frame.id
|
||||
await self._http_server.fanout(resp)
|
||||
# M5: banner + push mirror (parked in the outbox when offline).
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.notification(
|
||||
chat_id,
|
||||
protocol.NOTIF_CHANNEL_DELETED,
|
||||
"Channels",
|
||||
f"{entry.get('name') or chat_id} deleted",
|
||||
),
|
||||
)
|
||||
|
||||
async def on_channel_list(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
channels = self._channels.list(include_archived=False)
|
||||
resp = protocol.channel_list(channels)
|
||||
resp.id = frame.id
|
||||
await self._reply(device_id, resp)
|
||||
|
||||
# ── Chat info ─────────────────────────────────────────────────────────
|
||||
|
||||
def _channel_name(self, chat_id: str) -> str:
|
||||
"""Channel display name (M3: from the channel directory)."""
|
||||
if not chat_id:
|
||||
return "chat"
|
||||
entry = self._channels.get(chat_id)
|
||||
if entry is not None:
|
||||
return entry["name"]
|
||||
if chat_id in (self.home_channel, DEFAULT_HOME_CHANNEL):
|
||||
return self.home_channel_name
|
||||
return chat_id
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
|
||||
"""Return ``{name, type, chat_id}`` for a chat (M3: directory-backed)."""
|
||||
entry = self._channels.get(chat_id)
|
||||
kind = entry["kind"] if entry else "channel"
|
||||
return {
|
||||
"name": self._channel_name(chat_id),
|
||||
"type": "dm" if kind == "default" else "channel",
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
|
||||
def channel_list(self) -> list[dict[str, Any]]:
|
||||
"""Channel directory for ``hello.ack`` (M3: full non-archived list)."""
|
||||
return self._channels.list(include_archived=False)
|
||||
|
||||
# ── M3: core channel-directory hook (cron / send_message name resolution)
|
||||
|
||||
async def list_channels(self) -> list[dict[str, Any]]:
|
||||
"""Expose the directory to the gateway's core channel directory.
|
||||
|
||||
``gateway/channel_directory.build_channel_directory`` calls this to
|
||||
populate ``channel_directory.json``, which ``resolve_channel_name``
|
||||
reads for friendly-name -> chat_id resolution (cron + send_message).
|
||||
Threads are addressed via the explicit ``iris:<chat>:<thread>``
|
||||
syntax (see ``_parse_target_ref``), so only channels are listed here.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry in self._channels.list(include_archived=False):
|
||||
if entry["kind"] == "thread":
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": entry["chat_id"],
|
||||
"name": entry["name"],
|
||||
"type": "dm" if entry["kind"] == "default" else "channel",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
# ── M3: thread handoff (gateway create_handoff_thread) ────────────────
|
||||
|
||||
async def create_handoff_thread(self, parent_chat_id: str, name: str) -> str | None:
|
||||
"""Mint a named thread under *parent_chat_id* (gateway handoff path).
|
||||
|
||||
Returns the new ``thread_id`` (``t_<n>``) so the handed-off session is
|
||||
isolated in its own lane, or ``None`` when the parent is unknown.
|
||||
"""
|
||||
parent = self._channels.get(parent_chat_id)
|
||||
if parent is None:
|
||||
# Unknown parent: still mint a thread under it so the handoff has a
|
||||
# lane (the directory row is created lazily on first use).
|
||||
parent_chat_id = parent_chat_id or self.home_channel
|
||||
try:
|
||||
entry = self._channels.create(
|
||||
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("iris: create_handoff_thread failed", exc_info=True)
|
||||
return None
|
||||
await self._broadcast_both(protocol.channel_created(entry))
|
||||
return entry["chat_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3,9 +3,9 @@
|
||||
Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives
|
||||
(docs/06-channels-cron-search.md §6.1):
|
||||
|
||||
* **default chat** -> the home channel (``ANDROID_HOME_CHANNEL``, default
|
||||
``android:default``), ``kind="default"``, ``is_default=1``.
|
||||
* **user channel** -> a minted ``chat_id = android:chan_<n>``, ``kind="channel"``.
|
||||
* **default chat** -> the home channel (``IRIS_HOME_CHANNEL``, default
|
||||
``default``), ``kind="default"``, ``is_default=1``.
|
||||
* **user channel** -> a minted ``chat_id = chan_<n>``, ``kind="channel"``.
|
||||
* **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``,
|
||||
``kind="thread"`` (stored with its ``parent_chat_id``).
|
||||
|
||||
@@ -15,7 +15,7 @@ directory (``gateway/channel_directory.py``) via the adapter's
|
||||
``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly
|
||||
name (e.g. "Cron Reports") to a chat_id.
|
||||
|
||||
Storage: ``get_hermes_home()/"android"/channels.db``.
|
||||
Storage: ``get_hermes_home()/"iris"/channels.db``.
|
||||
|
||||
Milestone M3.
|
||||
"""
|
||||
@@ -37,12 +37,12 @@ KIND_CHANNEL = "channel"
|
||||
KIND_THREAD = "thread"
|
||||
|
||||
# chat_id / thread_id minting prefixes.
|
||||
CHANNEL_PREFIX = "android:chan_"
|
||||
CHANNEL_PREFIX = "chan_"
|
||||
THREAD_PREFIX = "t_"
|
||||
|
||||
|
||||
class ChannelDirectory:
|
||||
"""Persistent channel directory under ``get_hermes_home()/"android"``.
|
||||
"""Persistent channel directory under ``get_hermes_home()/"iris"``.
|
||||
|
||||
Thread-safe (single connection + lock); all operations are small and fast
|
||||
enough to run inline on the gateway's asyncio loop. Mirrors the
|
||||
@@ -127,7 +127,7 @@ class ChannelDirectory:
|
||||
when it was still the auto default); if another row is marked default
|
||||
it is cleared so exactly one default exists.
|
||||
"""
|
||||
chat_id = (chat_id or "android:default").strip() or "android:default"
|
||||
chat_id = (chat_id or "default").strip() or "default"
|
||||
name = (name or "Default").strip() or "Default"
|
||||
with self._lock:
|
||||
existing = self._conn.execute(
|
||||
@@ -443,6 +443,6 @@ def get_directory() -> ChannelDirectory:
|
||||
# failure is not actionable.
|
||||
with contextlib.suppress(Exception):
|
||||
_directory.close()
|
||||
_directory = ChannelDirectory(home / "android" / "channels.db")
|
||||
_directory = ChannelDirectory(home / "iris" / "channels.db")
|
||||
_directory_home = home
|
||||
return _directory
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Outbound frame classification (M2): turn state + content heuristics.
|
||||
|
||||
The main gateway delivers through the legacy callback path: the stream
|
||||
consumer calls ``send()`` (first bubble of a segment) and ``edit_message()``
|
||||
(updates), tool progress flows through ``send()``/``edit_message()`` of an
|
||||
accumulated line buffer, and interim commentary arrives as a plain
|
||||
``send()``. The adapter classifies each outbound call into a structured frame
|
||||
using a per-chat turn state machine + the content markers below:
|
||||
|
||||
* ``metadata["expect_edits"] is True`` -> streaming segment start
|
||||
* ``metadata["notify"] is True`` -> final message (or fallback final)
|
||||
* tool-progress line format -> tool.start / tool.end
|
||||
* anything else -> commentary
|
||||
|
||||
Verified empirically against the live gateway with ``tests/ws_probe.py``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STREAMING_CURSOR = " ▉"
|
||||
|
||||
# Code-style reasoning prefix (gateway/run.py, reasoning_style="code"):
|
||||
# "💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>"
|
||||
_REASONING_PREFIX = "💭 **Reasoning:**\n```\n"
|
||||
_REASONING_CLOSE = "\n```\n\n"
|
||||
|
||||
# A gateway tool-progress line begins with a (non-ASCII) tool emoji.
|
||||
_TOOL_LINE_RE = re.compile(r"^(\S+)\s+(.+)$")
|
||||
_TOOL_NAME_PREVIEW_RE = re.compile(r'^(\S+):\s*"(.*)"\s*$')
|
||||
_TOOL_NAME_BARE_RE = re.compile(r"^(\S+)\.\.\.\s*$")
|
||||
_TOOL_NAME_ARGS_RE = re.compile(r"^(\S+)\(([^)]*)\)\s*$")
|
||||
# Terminal code block: "💻 terminal\n```\n<cmd>\n```"
|
||||
_TOOL_CODEBLOCK_HEAD_RE = re.compile(r"^(\S+)\s+(\S+)\s*$")
|
||||
|
||||
# Reverse map of the gateway's friendly tool verbs (agent/display.py
|
||||
# _TOOL_VERBS) so a verb-form line ("🔍 Searching the web for …") can be
|
||||
# recovered to a structured (tool_name, preview). Longest-first matching is
|
||||
# done at parse time. Verbs shared by several tools map to the most common.
|
||||
_VERB_TO_TOOL: dict[str, str] = {
|
||||
"Searching the web": "web_search",
|
||||
"Searching files": "search_files",
|
||||
"Searching past sessions": "session_search",
|
||||
"Running code": "execute_code",
|
||||
"Running": "terminal",
|
||||
"Reading skill": "skill_view",
|
||||
"Reading": "read_file",
|
||||
"Writing": "write_file",
|
||||
"Editing": "patch",
|
||||
"Browsing": "browser_navigate",
|
||||
"Clicking": "browser_click",
|
||||
"Typing": "browser_type",
|
||||
"Generating image": "image_generate",
|
||||
"Generating video": "video_generate",
|
||||
"Generating speech": "text_to_speech",
|
||||
"Looking at the image": "vision_analyze",
|
||||
"Listing skills": "skills_list",
|
||||
"Updating skill": "skill_manage",
|
||||
"Updating memory": "memory",
|
||||
"Updating tasks": "todo",
|
||||
"Delegating": "delegate_task",
|
||||
"Scheduling": "cronjob",
|
||||
"Asking": "clarify",
|
||||
}
|
||||
# Verbs that take a " for " connector before the preview.
|
||||
_VERB_FOR_CONNECTOR = {"web_search", "search_files"}
|
||||
|
||||
|
||||
def _mint_message_id() -> str:
|
||||
return f"m_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def _mint_picker_id() -> str:
|
||||
return f"pc_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
|
||||
if not metadata:
|
||||
return None
|
||||
tid = metadata.get("thread_id")
|
||||
if isinstance(tid, str) and tid:
|
||||
return tid
|
||||
return None
|
||||
|
||||
|
||||
def _derive_thread_name(text: str) -> str:
|
||||
"""Instant auto-thread name from the user's opening message (no model).
|
||||
|
||||
Reuses hermes' session-title derivation (``agent/title_generator.py``):
|
||||
a deterministic slice of the user's own words, so the thread is named the
|
||||
moment it is created. The LLM upgrade (``_schedule_thread_title_upgrade``)
|
||||
replaces it moments later — the same two-stage titling hermes uses for
|
||||
sessions (derived < llm < user).
|
||||
"""
|
||||
try:
|
||||
from agent.title_generator import derive_title
|
||||
|
||||
title = derive_title(text)
|
||||
except Exception:
|
||||
logger.debug("Thread name derivation failed", exc_info=True)
|
||||
title = None
|
||||
return (title or "").strip() or "New thread"
|
||||
|
||||
|
||||
def _strip_streaming_cursor(text: str) -> str:
|
||||
if text and text.endswith(_STREAMING_CURSOR):
|
||||
return text[: -len(_STREAMING_CURSOR)]
|
||||
return text
|
||||
|
||||
|
||||
# M5: coalesce back-to-back pushes for the same chat (a cron delivery parks
|
||||
# a notification frame AND a message frame; only the first should push).
|
||||
_PUSH_COALESCE_S = 5.0
|
||||
|
||||
|
||||
def _push_preview(text: Any, limit: int = 120) -> str:
|
||||
"""Short single-line preview for push bodies (lock-screen privacy: no
|
||||
secrets, no full bodies -- full content arrives via ``sync``)."""
|
||||
s = " ".join(str(text or "").split())
|
||||
if len(s) > limit:
|
||||
s = s[: limit - 1] + "…"
|
||||
return s
|
||||
|
||||
|
||||
# Cron delivery wrap (cron/scheduler.py ``_deliver_result``,
|
||||
# cron.wrap_response: true):
|
||||
# "Cronjob Response: <name>\n(job_id: <id>)\n-------------\n\n<content>\n\n
|
||||
# To stop or manage this job, send me a new message (e.g. ...)."
|
||||
_CRON_WRAP_RE = re.compile(r"^Cronjob Response: (.+?)\n\(job_id: [^)]*\)\n-+\n\n")
|
||||
_CRON_FOOTER = "\n\nTo stop or manage this job"
|
||||
|
||||
|
||||
def _cron_brief(content: str, job_id: str) -> tuple[str, str]:
|
||||
"""Parse a cron delivery into ``(job_name, inner_text)``.
|
||||
|
||||
Falls back to ``(job_id, content)`` when the wrap is disabled
|
||||
(``cron.wrap_response: false``) or unrecognised.
|
||||
"""
|
||||
m = _CRON_WRAP_RE.match(content or "")
|
||||
if not m:
|
||||
return str(job_id or "cron"), (content or "").strip()
|
||||
name = m.group(1).strip()
|
||||
body = content[m.end() :]
|
||||
idx = body.rfind(_CRON_FOOTER)
|
||||
if idx != -1:
|
||||
body = body[:idx]
|
||||
return name, body.strip()
|
||||
|
||||
|
||||
def _split_reasoning(text: str) -> tuple[str | None, str]:
|
||||
"""Split a code-style reasoning prefix off the front of *text*.
|
||||
|
||||
Returns ``(reasoning, body)``; ``reasoning`` is ``None`` when no prefix is
|
||||
present (reasoning off / no reasoning / non-code style). Best-effort parse
|
||||
of a stable, gateway-owned format: on any mismatch the fallback is
|
||||
``(None, full text)`` so the answer still renders.
|
||||
"""
|
||||
if not text or not text.startswith(_REASONING_PREFIX):
|
||||
return None, text
|
||||
close_idx = text.find(_REASONING_CLOSE, len(_REASONING_PREFIX))
|
||||
if close_idx == -1:
|
||||
return None, text
|
||||
reasoning = text[len(_REASONING_PREFIX) : close_idx]
|
||||
body = text[close_idx + len(_REASONING_CLOSE) :]
|
||||
return reasoning, body
|
||||
|
||||
|
||||
def _parse_tool_line(line: str) -> tuple[str, str | None] | None: # noqa: PLR0911
|
||||
"""Parse a single gateway tool-progress line into ``(name, preview)``.
|
||||
|
||||
Returns ``None`` when the line is not a tool line. The gateway formats
|
||||
tool lines as ``<emoji> <name>: "<preview>"``, ``<emoji> <name>...``,
|
||||
``<emoji> <name>(keys)``, or a friendly verb phrase (``<emoji> <verb> …``).
|
||||
The verb form is lossy (no tool name), so we surface the verb as the name.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
m = _TOOL_LINE_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
emoji, rest = m.group(1), m.group(2)
|
||||
if emoji.isascii():
|
||||
return None # a tool line always leads with a non-ASCII emoji
|
||||
mp = _TOOL_NAME_PREVIEW_RE.match(rest)
|
||||
if mp:
|
||||
return mp.group(1), mp.group(2)
|
||||
mb = _TOOL_NAME_BARE_RE.match(rest)
|
||||
if mb:
|
||||
return mb.group(1), None
|
||||
ma = _TOOL_NAME_ARGS_RE.match(rest)
|
||||
if ma:
|
||||
return ma.group(1), None
|
||||
# Friendly verb phrase: reverse-map to (tool_name, preview).
|
||||
verb_parsed = _parse_verb_phrase(rest)
|
||||
if verb_parsed is not None:
|
||||
return verb_parsed
|
||||
# Unrecognised: use the phrase as the label.
|
||||
return rest, None
|
||||
|
||||
|
||||
def _parse_verb_phrase(phrase: str) -> tuple[str, str | None] | None:
|
||||
"""Reverse-map a friendly verb phrase to ``(tool_name, preview)``.
|
||||
|
||||
Matches the longest verb first so "Running code" wins over "Running".
|
||||
Returns ``None`` when no known verb leads the phrase.
|
||||
"""
|
||||
for verb in sorted(_VERB_TO_TOOL, key=len, reverse=True):
|
||||
tool = _VERB_TO_TOOL[verb]
|
||||
if phrase == verb:
|
||||
return tool, None
|
||||
if tool in _VERB_FOR_CONNECTOR and phrase.startswith(verb + " for "):
|
||||
return tool, phrase[len(verb) + len(" for ") :].strip() or None
|
||||
if phrase.startswith(verb + " "):
|
||||
return tool, phrase[len(verb) + 1 :].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_code_block(content: str) -> str | None:
|
||||
"""Return the first fenced code block's body in *content*, else ``None``.
|
||||
|
||||
Used to recover the terminal command from a tool-progress code block
|
||||
(``<emoji> terminal`` head line + fenced command).
|
||||
"""
|
||||
m = re.search(r"```[^\n]*\n(.*?)\n```", content, re.DOTALL)
|
||||
if m:
|
||||
return m.group(1).strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_verbose_args(line: str, content: str) -> dict[str, Any] | None:
|
||||
"""Recover the full args dict from a verbose tool line, else ``None``.
|
||||
|
||||
In verbose mode the gateway renders ``<emoji> <name>(keys)`` on one line
|
||||
and the full args JSON on the line that follows. When *line* is such a
|
||||
header, return the parsed JSON object from the following line.
|
||||
"""
|
||||
parts = line.strip().split(None, 1)
|
||||
# 2 == "tool name" + "args JSON" on the header line.
|
||||
if len(parts) < 2 or not _TOOL_NAME_ARGS_RE.match(parts[1]): # noqa: PLR2004
|
||||
return None
|
||||
lines = content.splitlines()
|
||||
for i, ln in enumerate(lines):
|
||||
if ln.strip() != line.strip():
|
||||
continue
|
||||
for follow_line in lines[i + 1 :]:
|
||||
follow = follow_line.strip()
|
||||
if not follow:
|
||||
continue
|
||||
if follow.startswith("{"):
|
||||
try:
|
||||
obj = json.loads(follow)
|
||||
return obj if isinstance(obj, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None # next non-empty line is not the args JSON
|
||||
return None
|
||||
|
||||
|
||||
def _short_preview_from_args(args: dict[str, Any], cap: int = 60) -> str | None:
|
||||
"""Derive a short one-line preview from a verbose args dict.
|
||||
|
||||
The verbose line carries no explicit preview, so the Truncated display
|
||||
would otherwise lose its one-liner. Use the first non-empty string value
|
||||
(whitespace-collapsed, capped) as a stand-in.
|
||||
"""
|
||||
if not isinstance(args, dict):
|
||||
return None
|
||||
for value in args.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
s = " ".join(value.split())
|
||||
return s[: cap - 1] + "…" if len(s) > cap else s
|
||||
return None
|
||||
|
||||
|
||||
def _is_tool_progress(content: str) -> bool:
|
||||
"""Heuristic: does *content* look like gateway tool-progress line(s)?
|
||||
|
||||
Tool progress is delivered as one or more lines, each led by a tool emoji
|
||||
(or a terminal code block). Commentary is free-form prose. We classify on
|
||||
the first non-empty line; subsequent lines of the same bubble are tracked
|
||||
by message id, not re-classified.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
lines = [ln for ln in content.splitlines() if ln.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
first = lines[0].strip()
|
||||
# Terminal code block: "<emoji> terminal" then a fenced command.
|
||||
if len(lines) > 1 and lines[1].strip().startswith("```"):
|
||||
return _TOOL_CODEBLOCK_HEAD_RE.match(first) is not None
|
||||
return _parse_tool_line(first) is not None
|
||||
|
||||
|
||||
def _is_gateway_lifecycle_notice(content: str) -> bool:
|
||||
"""True for hermes gateway lifecycle notices (restart / shutdown / online).
|
||||
|
||||
These are system notices, not tool progress. Their leading ⚠️/♻️ emoji
|
||||
would otherwise trip the tool-line heuristic and render them as a
|
||||
never-completing tool card (an endless spinner, since no ``tool.end``
|
||||
ever arrives for a notice that is not a real tool).
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
c = content.strip()
|
||||
return any(
|
||||
marker in c for marker in ("Gateway restarting", "Gateway shutting down", "Gateway online")
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TurnState:
|
||||
"""Per-chat turn state for outbound frame classification (M2)."""
|
||||
|
||||
active: bool = False
|
||||
# message_id of the currently streaming segment (message.start open).
|
||||
stream_id: str | None = None
|
||||
# message_id of the current tool-progress bubble (editable line buffer).
|
||||
tool_msg_id: str | None = None
|
||||
# Monotonic per-turn tool counter (start -> end correlation).
|
||||
tool_index: int = 0
|
||||
# Index of the most recently started tool (awaiting tool.end).
|
||||
open_tool_index: int | None = None
|
||||
# Name of the most recently started tool (matches the post_tool_call
|
||||
# record when the tool completes, so tool.end can carry its output).
|
||||
open_tool_name: str | None = None
|
||||
# Tool lines already emitted as tool.start (dedup across edits).
|
||||
seen_tool_lines: set = field(default_factory=set)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Slash-command catalog for the app's "/" drawer.
|
||||
|
||||
Derived from hermes' central ``COMMAND_REGISTRY`` (``hermes_cli/commands.py``)
|
||||
— the same source the gateway help text and the Telegram command menu use —
|
||||
restricted to commands available on gateway surfaces, plus plugin-registered
|
||||
commands. Never raises: any import/attribute problem (code skew between the
|
||||
plugin and the hermes checkout) degrades to an empty catalog, so the app's
|
||||
drawer simply stays closed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
try:
|
||||
from hermes_cli import commands as hermes_commands
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"iris: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
def _entry(
|
||||
name: str, description: str, args_hint: str, category: str, aliases: list[str]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"name": f"/{name}",
|
||||
"description": description,
|
||||
"args_hint": args_hint or "",
|
||||
"category": category,
|
||||
"aliases": [f"/{a}" for a in aliases],
|
||||
}
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
try:
|
||||
overrides = hermes_commands._resolve_config_gates()
|
||||
for cmd in hermes_commands.COMMAND_REGISTRY:
|
||||
if not hermes_commands._is_gateway_available(cmd, overrides):
|
||||
continue
|
||||
entries.append(
|
||||
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
||||
)
|
||||
except Exception:
|
||||
# Code skew: the private helpers moved. Fall back to the plain
|
||||
# cli_only filter (config-gated commands are dropped, acceptable).
|
||||
logger.warning("iris: slash catalog fell back to cli_only filter", exc_info=True)
|
||||
entries = [
|
||||
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
||||
for cmd in hermes_commands.COMMAND_REGISTRY
|
||||
if not cmd.cli_only
|
||||
]
|
||||
try:
|
||||
for name, description, args_hint in hermes_commands._iter_plugin_command_entries():
|
||||
entries.append(_entry(name, description, args_hint, "Plugin", []))
|
||||
except Exception:
|
||||
# Best-effort: a broken plugin-command registry should not break the
|
||||
# built-in catalog, so the failure is intentionally swallowed.
|
||||
logger.debug("iris: plugin command enumeration failed", exc_info=True)
|
||||
return entries
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Platform defaults (config.yaml ``extra`` / env fallbacks)."""
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP is the only transport
|
||||
DEFAULT_HOME_CHANNEL = "default"
|
||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||
DEFAULT_PUSH_BACKEND = "ntfy"
|
||||
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
||||
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
|
||||
def _truthy(value: str | None) -> bool:
|
||||
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Shared inbound frame dispatch + inbound rate limit.
|
||||
|
||||
Extracted from the (now-removed) WS server so the HTTP transport has a
|
||||
single home for the transport-agnostic dispatch chain and the per-device
|
||||
token bucket. The HTTP leg (``http_server.py``) is the only transport;
|
||||
this module is transport-neutral.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from . import protocol
|
||||
|
||||
# Inbound JSON control-frame rate limit (per device, token bucket).
|
||||
# A legitimate app sends occasional user-initiated requests — far below
|
||||
# 20/s sustained. Media uploads are exempt (they travel via
|
||||
# ``POST /v1/media``, not the frame endpoint).
|
||||
INBOUND_RATE_PER_S = 20.0
|
||||
INBOUND_BURST = 40
|
||||
|
||||
# Max length of a client-supplied device_id.
|
||||
MAX_DEVICE_ID_LEN = 128
|
||||
|
||||
|
||||
async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None: # noqa: PLR0912
|
||||
"""Shared inbound frame dispatch (docs/19 §19.4). Unknown types are
|
||||
ignored (forward-compat)."""
|
||||
if frame.type == protocol.TYPE_MESSAGE_SEND:
|
||||
await adapter.on_message_send(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_CREATE:
|
||||
await adapter.on_channel_create(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_RENAME:
|
||||
await adapter.on_channel_rename(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
|
||||
await adapter.on_channel_set_default(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
|
||||
await adapter.on_channel_favorite(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
||||
await adapter.on_channel_icon(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
|
||||
await adapter.on_channel_set_automation(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
|
||||
await adapter.on_channel_delete(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||
await adapter.on_channel_list(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
|
||||
await adapter.on_commands_catalog(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_SEARCH:
|
||||
await adapter.on_search(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_SYNC:
|
||||
await adapter.on_sync(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_HISTORY:
|
||||
await adapter.on_history(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
|
||||
await adapter.on_message_delete(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
||||
await adapter.on_fcm_register(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_PICKER_SELECT:
|
||||
await adapter.on_picker_select(frame, device_id)
|
||||
# Unknown types are ignored (forward-compat).
|
||||
|
||||
|
||||
class _TokenBucket:
|
||||
"""Minimal token bucket (stdlib only). One instance per device."""
|
||||
|
||||
__slots__ = ("rate", "burst", "tokens", "updated_at")
|
||||
|
||||
def __init__(self, rate: float, burst: int):
|
||||
self.rate = rate
|
||||
self.burst = burst
|
||||
self.tokens = float(burst)
|
||||
self.updated_at = time.monotonic()
|
||||
|
||||
def consume(self) -> bool:
|
||||
"""Try to take one token. Refills at ``rate``/s up to ``burst``."""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.updated_at
|
||||
if elapsed > 0:
|
||||
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
||||
self.updated_at = now
|
||||
if self.tokens >= 1.0:
|
||||
self.tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Plugin hook capture: reasoning, tool results, runtime metadata.
|
||||
|
||||
The gateway's plugin hooks (``on_stream_delta``, ``post_tool_call``,
|
||||
``post_api_request``) fire on gateway worker threads; these module-level
|
||||
buffers accumulate the per-turn data the adapter attaches to outbound frames
|
||||
(reasoning on ``message.stop``, tool output on ``tool.end``, the ``runtime``
|
||||
footer on final messages). A personal iris gateway serves one active turn at
|
||||
a time, so global buffers suffice; each is reset at the turn boundary.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from . import protocol
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from .adapter import IrisAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M2 — reasoning capture (streaming)
|
||||
#
|
||||
# The gateway streams only ``content`` to the platform and suppresses the
|
||||
# final send (which would carry the prepended reasoning), so the model's
|
||||
# separate ``reasoning_content`` is otherwise lost in the streaming case.
|
||||
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
|
||||
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
|
||||
# We accumulate those deltas here and attach the result to the turn's
|
||||
# ``message.stop`` frame. Single-chat for now (the default home channel), so a
|
||||
# module-level buffer suffices; it is reset at each turn start.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_reasoning_parts: list[str] = []
|
||||
_reasoning_lock = threading.Lock()
|
||||
# Barrier: set by the hook worker once it has processed the first content
|
||||
# delta (kind="text"). The worker drains a FIFO queue and reasoning deltas are
|
||||
# enqueued before content deltas, so at that point every reasoning delta has
|
||||
# already been appended -- a reliable "reasoning flushed" signal that avoids
|
||||
# racing message.stop against the async hook thread.
|
||||
_reasoning_flushed = threading.Event()
|
||||
|
||||
|
||||
def _on_stream_delta(**kwargs: Any) -> None:
|
||||
"""Plugin hook: capture reasoning deltas (kind="reasoning")."""
|
||||
kind = kwargs.get("kind")
|
||||
if kind == "reasoning":
|
||||
delta = kwargs.get("delta") or ""
|
||||
if delta:
|
||||
with _reasoning_lock:
|
||||
_reasoning_parts.append(delta)
|
||||
elif kind == "text":
|
||||
_reasoning_flushed.set()
|
||||
|
||||
|
||||
async def _wait_for_reasoning_flushed(timeout: float = 0.3) -> None:
|
||||
"""Wait (without blocking the event loop) until the hook worker has
|
||||
processed all reasoning deltas, or *timeout* seconds elapse."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while loop.time() < deadline:
|
||||
if _reasoning_flushed.is_set():
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
def _take_reasoning() -> str:
|
||||
"""Drain and return the accumulated reasoning (empty string if none)."""
|
||||
with _reasoning_lock:
|
||||
parts = _reasoning_parts[:]
|
||||
_reasoning_parts.clear()
|
||||
_reasoning_flushed.clear()
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _reset_reasoning() -> None:
|
||||
with _reasoning_lock:
|
||||
_reasoning_parts.clear()
|
||||
_reasoning_flushed.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M2 — tool-result capture (post_tool_call hook)
|
||||
#
|
||||
# The gateway renders tool *progress* lines to the platform but never streams
|
||||
# the tool *output* (it is the agent's concern, persisted to history, not
|
||||
# presentation). To let the app show the full call + result on demand
|
||||
# (Settings → Tool detail), we capture each completed tool call via the
|
||||
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
|
||||
#
|
||||
# Global FIFO (like the reasoning buffer): a personal iris gateway serves
|
||||
# one active turn at a time, and records are matched to the open tool by name
|
||||
# in completion order. Bounded so a runaway turn can't grow it without limit.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_tool_results: "deque[dict[str, Any]]" = deque()
|
||||
_tool_results_lock = threading.Lock()
|
||||
_MAX_TOOL_RESULTS = 200
|
||||
_MAX_OUTPUT_PREVIEW = 8000
|
||||
|
||||
|
||||
def _on_post_tool_call(**kwargs: Any) -> None:
|
||||
"""Plugin hook: capture a completed tool call's result + timing."""
|
||||
result = kwargs.get("result")
|
||||
record = {
|
||||
"tool_name": kwargs.get("tool_name") or "",
|
||||
"result": (str(result) if result is not None else "")[:_MAX_OUTPUT_PREVIEW],
|
||||
"duration_ms": kwargs.get("duration_ms") or 0,
|
||||
"status": kwargs.get("status") or "ok",
|
||||
}
|
||||
with _tool_results_lock:
|
||||
_tool_results.append(record)
|
||||
while len(_tool_results) > _MAX_TOOL_RESULTS:
|
||||
_tool_results.popleft()
|
||||
# Live todo list: the todo tool's result is the authoritative full list,
|
||||
# so emit it the moment the call completes — the tool.end frame only
|
||||
# arrives when the NEXT tool starts or the turn ends, which would lag the
|
||||
# app's strip behind the agent's actual progress. Best-effort: a parse
|
||||
# failure (truncated preview) or a missing live adapter just skips it.
|
||||
if kwargs.get("tool_name") == "todo" and record["status"] == "ok":
|
||||
todos = _parse_todo_result(record["result"])
|
||||
adapter = _live_adapter
|
||||
if todos is not None and adapter is not None and adapter._loop is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(adapter._emit_todo_update(todos), adapter._loop)
|
||||
|
||||
|
||||
def _take_tool_result(tool_name: str) -> dict[str, Any] | None:
|
||||
"""Pop the first completed record matching *tool_name* (FIFO), else None."""
|
||||
if not tool_name:
|
||||
return None
|
||||
with _tool_results_lock:
|
||||
for i, rec in enumerate(_tool_results):
|
||||
if rec["tool_name"] == tool_name:
|
||||
del _tool_results[i]
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def _reset_tool_results() -> None:
|
||||
"""Clear captured records (turn boundary — drop anything unconsumed)."""
|
||||
with _tool_results_lock:
|
||||
_tool_results.clear()
|
||||
|
||||
|
||||
def _tool_end_fields(tool_name: str) -> dict[str, Any]:
|
||||
"""Build the ``tool.end`` enrichment (ok/duration/output_preview) from the
|
||||
captured hook record for *tool_name*; empty dict when none is available
|
||||
(e.g. tool_progress off, or the call came from another session)."""
|
||||
rec = _take_tool_result(tool_name)
|
||||
if rec is None:
|
||||
return {}
|
||||
fields: dict[str, Any] = {
|
||||
"ok": rec["status"] == "ok",
|
||||
"output_preview": rec["result"] or None,
|
||||
}
|
||||
if rec["duration_ms"]:
|
||||
fields["duration"] = round(rec["duration_ms"] / 1000.0, 3)
|
||||
return fields
|
||||
|
||||
|
||||
# The live adapter instance (module-level so the synchronous plugin hooks
|
||||
# below can reach it). A personal iris gateway runs exactly one adapter;
|
||||
# set on connect, cleared on disconnect.
|
||||
_live_adapter: "IrisAdapter | None" = None
|
||||
|
||||
# Valid todo item statuses (hermes tools/todo_tool.py VALID_STATUSES).
|
||||
_TODO_STATUSES = ("pending", "in_progress", "completed", "cancelled")
|
||||
|
||||
|
||||
def _parse_todo_result(result: Any) -> list[dict[str, str]] | None:
|
||||
"""Parse the ``todo`` tool's result into a clean item list.
|
||||
|
||||
The tool returns ``{"todos": [...], "summary": {...}}`` — the FULL current
|
||||
list, which is authoritative even for ``merge`` writes (whose args carry
|
||||
only the changed items) and read-only calls. Returns ``None`` when the
|
||||
result is not a parseable todo list (error string, truncated preview, …)
|
||||
so the caller skips the emission instead of broadcasting garbage.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(result)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
items = data.get("todos") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
todos: list[dict[str, str]] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
content = str(it.get("content") or "").strip()
|
||||
status = str(it.get("status") or "")
|
||||
if not content or status not in _TODO_STATUSES:
|
||||
continue
|
||||
todos.append({"id": str(it.get("id") or ""), "content": content, "status": status})
|
||||
return todos
|
||||
|
||||
|
||||
def _tool_emoji(tool_name: str) -> str | None:
|
||||
"""Cosmetic per-tool emoji for the ``tool.start`` frame.
|
||||
|
||||
Resolved via hermes' own display layer (``agent.display.get_tool_emoji``):
|
||||
active-skin ``tool_emojis`` overrides first, then the tool registry's
|
||||
per-tool ``emoji`` field — so icons track the user's hermes theme and
|
||||
new/plugin tools get their registered glyph for free. Returns ``None``
|
||||
when the tool is unknown (or the import fails) so the frame omits the
|
||||
field and the app falls back to its own default glyph.
|
||||
"""
|
||||
try:
|
||||
from agent.display import get_tool_emoji
|
||||
|
||||
return get_tool_emoji(tool_name, default="") or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime-metadata footer (post_api_request hook)
|
||||
#
|
||||
# The app renders a Telegram-style footer under final assistant messages
|
||||
# (model, context %, cwd, latency, cost). Display is controlled by the APP
|
||||
# (Settings → Runtime footer), not hermes config — so the gateway ALWAYS
|
||||
# sends the data. hermes core only appends its own *text* footer when
|
||||
# ``display.runtime_footer.enabled`` is set, and the adapter has no access to
|
||||
# the gateway's ``agent_result``, so we capture the same facts ourselves via
|
||||
# the ``post_api_request`` plugin hook (fires after every provider call with
|
||||
# model + usage):
|
||||
#
|
||||
# * model — the turn's latest model (failover-aware)
|
||||
# * prompt_tokens — the latest call's prompt size (context occupancy)
|
||||
# * turn start — the first API call of the turn (latency baseline)
|
||||
#
|
||||
# Global buffer (same pattern as the reasoning/tool buffers): a personal
|
||||
# iris gateway serves one active turn at a time. The hook fires for every
|
||||
# platform, so we only record when the turn's platform is iris.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_runtime_meta: dict[str, Any] = {}
|
||||
_runtime_meta_lock = threading.Lock()
|
||||
# Per-model context-window cache. Resolution may probe endpoints on first use
|
||||
# (slow); the cache is process-lifetime so each model resolves at most once.
|
||||
_context_length_cache: dict[str, int] = {}
|
||||
# Upper bound (seconds) on context-window resolution during a final send, so a
|
||||
# slow first-use probe never delays the reply. The worker thread keeps running
|
||||
# and populates the cache, so the next turn is fast.
|
||||
_CTX_RESOLVE_TIMEOUT_S = 3.0
|
||||
|
||||
|
||||
def _on_post_api_request(**kwargs: Any) -> None:
|
||||
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
|
||||
platform = kwargs.get("platform")
|
||||
if platform and platform != "iris":
|
||||
return
|
||||
model = kwargs.get("model") or ""
|
||||
usage = kwargs.get("usage") or {}
|
||||
prompt_tokens = usage.get("prompt_tokens") or 0
|
||||
with _runtime_meta_lock:
|
||||
if model:
|
||||
_runtime_meta["model"] = model
|
||||
if prompt_tokens:
|
||||
_runtime_meta["prompt_tokens"] = prompt_tokens
|
||||
if "turn_start" not in _runtime_meta:
|
||||
_runtime_meta["turn_start"] = time.monotonic()
|
||||
|
||||
|
||||
def _take_runtime_meta() -> dict[str, Any]:
|
||||
"""Drain the captured turn metadata (turn boundary)."""
|
||||
with _runtime_meta_lock:
|
||||
meta = dict(_runtime_meta)
|
||||
_runtime_meta.clear()
|
||||
return meta
|
||||
|
||||
|
||||
def _resolve_context_length(model: str) -> int | None:
|
||||
"""Best-effort context window for *model* (cached; None on failure).
|
||||
|
||||
Runs in a worker thread (may probe endpoints on first use). The cache is
|
||||
populated even if the caller's asyncio task times out, so subsequent
|
||||
turns resolve instantly.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
cached = _context_length_cache.get(model)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
ctx = get_model_context_length(model)
|
||||
if ctx and ctx > 0:
|
||||
_context_length_cache[model] = int(ctx)
|
||||
return int(ctx)
|
||||
except Exception:
|
||||
logger.debug("iris: context-length resolution failed for %s", model, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _home_relative_cwd(cwd: str) -> str:
|
||||
"""Collapse ``$HOME`` to ``~`` (matches hermes' runtime footer)."""
|
||||
if not cwd:
|
||||
return ""
|
||||
try:
|
||||
home = os.path.expanduser("~")
|
||||
p = os.path.abspath(cwd)
|
||||
if home and (p == home or p.startswith(home + os.sep)):
|
||||
return "~" + p[len(home) :]
|
||||
return p
|
||||
except Exception:
|
||||
return cwd
|
||||
|
||||
|
||||
async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the ``runtime`` footer object from captured turn metadata.
|
||||
|
||||
Called on every final send (the app decides what to show). Fields without
|
||||
data are omitted. ``meta`` is the drained turn buffer (model,
|
||||
prompt_tokens, turn_start).
|
||||
"""
|
||||
# Lazy import: the tests monkeypatch ``adapter._resolve_context_length``,
|
||||
# so resolve it through the adapter module at call time (avoids a
|
||||
# top-level circular import adapter -> hooks -> adapter).
|
||||
from .adapter import _resolve_context_length
|
||||
|
||||
model = (meta.get("model") or "").rsplit("/", 1)[-1]
|
||||
prompt_tokens = meta.get("prompt_tokens") or 0
|
||||
context_pct = None
|
||||
if prompt_tokens and model:
|
||||
try:
|
||||
ctx_len = await asyncio.wait_for(
|
||||
asyncio.to_thread(_resolve_context_length, model),
|
||||
timeout=_CTX_RESOLVE_TIMEOUT_S,
|
||||
)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
ctx_len = None
|
||||
if ctx_len:
|
||||
context_pct = round(prompt_tokens / ctx_len * 100)
|
||||
turn_start = meta.get("turn_start")
|
||||
latency = (time.monotonic() - turn_start) if turn_start else None
|
||||
cwd = _home_relative_cwd(os.environ.get("TERMINAL_CWD", ""))
|
||||
return protocol.runtime_footer(
|
||||
model=model or None,
|
||||
context_pct=context_pct,
|
||||
cwd=cwd or None,
|
||||
latency=latency,
|
||||
)
|
||||
@@ -0,0 +1,898 @@
|
||||
"""HTTP transport (docs/19): the gateway's device-facing server.
|
||||
|
||||
Short-lived-connection transport: the same JSON frames, the same
|
||||
outbox/cursor, the same token — served over plain HTTP by the gateway.
|
||||
The app sends over ``POST /v1/frame`` and receives over
|
||||
``GET /v1/events`` (SSE) or ``GET /v1/poll`` (long-poll); media travels
|
||||
via ``POST /v1/media`` / ``GET /v1/media/{id}`` (v2, docs/19 §19.15).
|
||||
|
||||
Zero new Python dependencies: stdlib ``http.server`` (a
|
||||
``ThreadingHTTPServer`` in a daemon thread) bridged into the gateway's
|
||||
asyncio loop with ``asyncio.run_coroutine_threadsafe``.
|
||||
|
||||
Endpoints (docs/19 §19.4):
|
||||
* ``GET /v1/health`` — unauthenticated liveness probe.
|
||||
* ``POST /v1/frame`` — accept-and-ack for any JSON frame the
|
||||
app sends (media uses the /v1/media
|
||||
endpoints; hello/ping are
|
||||
transport-specific).
|
||||
* ``GET /v1/events?cursor=N`` — SSE stream: outbox catch-up, then live
|
||||
frames (``id`` = outbox cursor, so resume
|
||||
is just ``Last-Event-ID``).
|
||||
* ``GET /v1/poll?cursor=N`` — long-poll fallback where SSE is blocked.
|
||||
* ``POST /v1/media`` — media upload (docs/19 §19.15, v2): the
|
||||
whole file as the request body; metadata
|
||||
in ``X-Iris-Media-*`` headers; sha256
|
||||
contract per docs/07 §7.2.
|
||||
* ``GET /v1/media/{media_id}`` — media pull (docs/19 §19.15, v2): streams
|
||||
an outbound offer (``media.offer`` id)
|
||||
as the response body.
|
||||
|
||||
Auth: ``Authorization: Bearer <token>`` (constant-time ``verify_token``) +
|
||||
``X-Iris-Device`` header (device id / allowlist). Device registration
|
||||
(name + push tokens) rides on the SSE open via ``X-Iris-Device-Name`` /
|
||||
``X-Iris-Fcm-Token`` / ``X-Iris-Ntfy-Topic`` headers (the HTTP equivalent
|
||||
of the old WS ``hello`` upsert).
|
||||
|
||||
HTTP is the ONLY transport: a bind failure is FATAL (the app has no other
|
||||
way to reach the gateway).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from . import dispatch, protocol
|
||||
from . import media as media_bridge
|
||||
from .pairing import verify_token
|
||||
|
||||
try: # main-repo import (same as adapter.py); absent in bare unit contexts
|
||||
from gateway.platforms.base import validate_media_delivery_path
|
||||
except ImportError: # pragma: no cover
|
||||
validate_media_delivery_path = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default port for the HTTP transport (the WS-era default was 8790).
|
||||
DEFAULT_HTTP_PORT = 8791
|
||||
|
||||
# Request body cap for POST /v1/frame. Frames are usually small, but a
|
||||
# ``channel.icon`` carries a base64 blob up to 512 KiB (docs/10), so the cap
|
||||
# must clear that with headroom. Media never travels here (it uses
|
||||
# POST /v1/media).
|
||||
MAX_BODY_BYTES = 1024 * 1024
|
||||
|
||||
# Per-subscriber live-frame queue. A subscriber that can't keep up is
|
||||
# dropped; it reconnects with Last-Event-ID and catches up from the outbox.
|
||||
SUB_QUEUE_MAX = 256
|
||||
|
||||
# SSE comment heartbeat cadence (keeps proxies from idling the stream).
|
||||
SSE_HEARTBEAT_S = 15.0
|
||||
|
||||
# Long-poll hold time (docs/19 §19.6).
|
||||
POLL_TIMEOUT_S = 25.0
|
||||
|
||||
# How long POST /v1/frame waits for a synchronous validation rejection
|
||||
# before acking 202 and letting the handler (e.g. the agent turn) run on.
|
||||
ACCEPT_ACK_TIMEOUT_S = 5.0
|
||||
|
||||
# Sentinel pushed into subscriber queues on shutdown.
|
||||
_STOP = object()
|
||||
|
||||
# Max length of a client-supplied media_ref (same as the WS path).
|
||||
MAX_MEDIA_REF_LEN = 64
|
||||
|
||||
# MediaError code -> HTTP status for the /v1/media endpoints.
|
||||
_MEDIA_STATUS = {
|
||||
protocol.ERR_MEDIA_TOO_LARGE: 413,
|
||||
protocol.ERR_NOT_FOUND: 404,
|
||||
protocol.ERR_UNSUPPORTED: 400,
|
||||
protocol.ERR_INTERNAL: 500,
|
||||
}
|
||||
|
||||
|
||||
def _with_cursor(frame: dict[str, Any], cursor: int) -> str:
|
||||
"""Re-serialize an outbox frame dict with its cursor in the envelope
|
||||
(same tagging ``sync`` replay uses, docs/08 §8.7)."""
|
||||
d = dict(frame)
|
||||
d["cursor"] = cursor
|
||||
return json.dumps(d, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _parse_cursor(*raws: Any) -> int:
|
||||
"""First parseable non-negative int wins (``?cursor=`` beats
|
||||
``Last-Event-ID``); 0 when nothing usable."""
|
||||
for raw in raws:
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
v = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v >= 0:
|
||||
return v
|
||||
return 0
|
||||
|
||||
|
||||
def _send_json(handler: BaseHTTPRequestHandler, status: int, obj: Any) -> None:
|
||||
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError):
|
||||
handler.wfile.write(body)
|
||||
handler.wfile.flush()
|
||||
|
||||
|
||||
def _send_frame_json(handler: BaseHTTPRequestHandler, status: int, frame_json: str) -> None:
|
||||
"""Send a protocol frame as the HTTP response body (docs/19 §19.7:
|
||||
error frames double as the HTTP response)."""
|
||||
body = frame_json.encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError):
|
||||
handler.wfile.write(body)
|
||||
handler.wfile.flush()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Subscriber:
|
||||
"""One live HTTP subscriber (SSE stream or long-poll request)."""
|
||||
|
||||
device_id: str
|
||||
kind: str # "sse" | "poll"
|
||||
q: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=SUB_QUEUE_MAX))
|
||||
closed: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
|
||||
class HttpServer:
|
||||
"""The plugin's HTTP server + live subscriber registry.
|
||||
|
||||
The handler threads never touch adapter state directly: inbound frames
|
||||
are bridged into the gateway's asyncio loop (captured at ``start()``)
|
||||
with ``asyncio.run_coroutine_threadsafe`` and dispatched through
|
||||
``dispatch_frame`` (``dispatch.py``).
|
||||
"""
|
||||
|
||||
def __init__(self, adapter: Any, devices: Any):
|
||||
self._adapter = adapter
|
||||
self._devices = devices
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._httpd: _ThreadingHTTPD | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._subs: dict[str, list[_Subscriber]] = {}
|
||||
self._subs_lock = threading.Lock()
|
||||
self._buckets: dict[str, dispatch._TokenBucket] = {}
|
||||
self._buckets_lock = threading.Lock()
|
||||
self._lock_key: str | None = None
|
||||
self.enabled = False
|
||||
self.bound_port = 0
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Bind and start serving. NEVER raises: a bind failure leaves
|
||||
``enabled`` False, which the adapter treats as a fatal error
|
||||
(HTTP is the only transport, docs/19 §19.4)."""
|
||||
if self.enabled:
|
||||
return
|
||||
self._loop = asyncio.get_running_loop()
|
||||
host = self._adapter.host
|
||||
port = self._adapter.http_port
|
||||
|
||||
# Port-conflict lock: same flock pattern the WS uses.
|
||||
try:
|
||||
from gateway.status import acquire_scoped_lock
|
||||
|
||||
lock_key = f"http:{host}:{port}"
|
||||
if not acquire_scoped_lock("iris", lock_key):
|
||||
logger.warning(
|
||||
"iris: HTTP port %s:%s in use by another profile; server disabled",
|
||||
host,
|
||||
port,
|
||||
)
|
||||
return
|
||||
self._lock_key = lock_key
|
||||
except ImportError:
|
||||
self._lock_key = None # status module not available (e.g. tests)
|
||||
|
||||
try:
|
||||
httpd = _ThreadingHTTPD((host, port), self)
|
||||
self.bound_port = int(httpd.server_address[1])
|
||||
if self._adapter.http_cert and self._adapter.http_key:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
|
||||
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||
except Exception as e:
|
||||
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
|
||||
self._release_lock()
|
||||
return
|
||||
|
||||
self._httpd = httpd
|
||||
self._thread = threading.Thread(target=httpd.serve_forever, name="iris-http", daemon=True)
|
||||
self._thread.start()
|
||||
self.enabled = True
|
||||
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
|
||||
logger.info("iris: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop serving and unblock all subscribers."""
|
||||
self.enabled = False
|
||||
with self._subs_lock:
|
||||
subs = [s for lst in self._subs.values() for s in lst]
|
||||
self._subs.clear()
|
||||
for s in subs:
|
||||
s.closed.set()
|
||||
with contextlib.suppress(Exception):
|
||||
s.q.put_nowait(_STOP)
|
||||
httpd = self._httpd
|
||||
self._httpd = None
|
||||
if httpd is not None:
|
||||
# shutdown() must be called from a thread other than the one
|
||||
# running serve_forever(); we are on the asyncio loop thread.
|
||||
with contextlib.suppress(Exception):
|
||||
httpd.shutdown()
|
||||
with contextlib.suppress(Exception):
|
||||
httpd.server_close()
|
||||
t = self._thread
|
||||
self._thread = None
|
||||
if t is not None and t is not threading.current_thread():
|
||||
t.join(timeout=5.0)
|
||||
self._release_lock()
|
||||
|
||||
def _release_lock(self) -> None:
|
||||
with contextlib.suppress(ImportError):
|
||||
from gateway.status import release_scoped_lock
|
||||
|
||||
if self._lock_key:
|
||||
release_scoped_lock("iris", self._lock_key)
|
||||
self._lock_key = None
|
||||
|
||||
# ── Subscriber registry ───────────────────────────────────────────────
|
||||
|
||||
def has_devices(self) -> bool:
|
||||
with self._subs_lock:
|
||||
return bool(self._subs)
|
||||
|
||||
def device_ids(self) -> list:
|
||||
with self._subs_lock:
|
||||
return list(self._subs.keys())
|
||||
|
||||
def _add_sub(self, sub: _Subscriber) -> None:
|
||||
with self._subs_lock:
|
||||
self._subs.setdefault(sub.device_id, []).append(sub)
|
||||
# M5: a live subscriber will sync the outbox -- tell the adapter to
|
||||
# drop any held-back (deferred) pushes so the turn-end flush doesn't
|
||||
# duplicate what the app already shows. getattr-guard: test doubles
|
||||
# may use a bare adapter stub.
|
||||
on_online = getattr(self._adapter, "on_device_online", None)
|
||||
if on_online is not None:
|
||||
on_online()
|
||||
|
||||
def _remove_sub(self, sub: _Subscriber) -> None:
|
||||
with self._subs_lock:
|
||||
lst = self._subs.get(sub.device_id)
|
||||
if lst:
|
||||
with contextlib.suppress(ValueError):
|
||||
lst.remove(sub)
|
||||
if not lst:
|
||||
del self._subs[sub.device_id]
|
||||
|
||||
# ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
async def fanout(self, frame: protocol.Frame, cursor: int | None = None) -> int:
|
||||
"""Push a frame to every live HTTP subscriber. Returns subscribers
|
||||
reached — the HTTP half of the delivery count (docs/19 §19.8): a
|
||||
device reading SSE is a live subscriber, so a frame fanned out here
|
||||
must not also fire a push."""
|
||||
if not self.enabled:
|
||||
return 0
|
||||
data = frame.to_json()
|
||||
with self._subs_lock:
|
||||
subs = [s for lst in self._subs.values() for s in lst]
|
||||
sent = 0
|
||||
for s in subs:
|
||||
try:
|
||||
s.q.put_nowait((cursor, data))
|
||||
sent += 1
|
||||
except queue.Full:
|
||||
# Slow subscriber: drop it. The client reconnects with
|
||||
# Last-Event-ID and catches up from the outbox.
|
||||
logger.info("iris: dropping slow HTTP subscriber %s", s.device_id)
|
||||
s.closed.set()
|
||||
self._remove_sub(s)
|
||||
return sent
|
||||
|
||||
# ── Auth / limits (handler threads) ───────────────────────────────────
|
||||
|
||||
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
|
||||
"""Verify Bearer token + device identity. Returns the device_id, or
|
||||
None after sending a 401.
|
||||
|
||||
Token model (docs/09 §9.3): a REVOKED device_id is rejected no matter
|
||||
which token it presents (per-device isolation). Otherwise the shared
|
||||
``IRIS_TOKEN`` (bootstrap / legacy) or the device's own per-device
|
||||
token (minted at pairing, returned in ``hello.ack.device_token``)
|
||||
both authenticate — each compared in constant time."""
|
||||
auth = handler.headers.get("Authorization") or ""
|
||||
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
|
||||
device_id = (handler.headers.get("X-Iris-Device") or "").strip()
|
||||
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
|
||||
_send_json(handler, 401, {"error": "X-Iris-Device header required"})
|
||||
return None
|
||||
with contextlib.suppress(Exception):
|
||||
if self._devices.is_revoked(device_id):
|
||||
logger.warning("iris: http rejected: device %s is revoked", device_id)
|
||||
_send_json(handler, 401, {"error": "device revoked"})
|
||||
return None
|
||||
if not verify_token(token, self._adapter.token):
|
||||
# Not the shared token: try the device's own per-device token.
|
||||
device_token = None
|
||||
with contextlib.suppress(Exception):
|
||||
device_token = self._devices.token_for(device_id)
|
||||
if not (device_token and verify_token(token, device_token)):
|
||||
_send_json(handler, 401, {"error": "unauthorized"})
|
||||
return None
|
||||
if (
|
||||
not self._adapter.allow_all
|
||||
and self._adapter.allowed_users
|
||||
and device_id not in self._adapter.allowed_users
|
||||
):
|
||||
logger.warning("iris: http rejected: device %s not allowlisted", device_id)
|
||||
_send_json(handler, 401, {"error": "device not allowed"})
|
||||
return None
|
||||
with contextlib.suppress(Exception):
|
||||
self._devices.touch(device_id)
|
||||
return device_id
|
||||
|
||||
def _rate_limited(self, device_id: str) -> bool:
|
||||
"""Per-device token bucket, same parameters as the frame limit."""
|
||||
with self._buckets_lock:
|
||||
b = self._buckets.get(device_id)
|
||||
if b is None:
|
||||
b = self._buckets[device_id] = dispatch._TokenBucket(
|
||||
dispatch.INBOUND_RATE_PER_S, dispatch.INBOUND_BURST
|
||||
)
|
||||
return not b.consume()
|
||||
|
||||
# ── POST /v1/frame ────────────────────────────────────────────────────
|
||||
|
||||
def _handle_frame(
|
||||
self, handler: BaseHTTPRequestHandler, device_id: str, frame: protocol.Frame
|
||||
) -> None:
|
||||
"""Accept-and-ack (docs/19 §19.7): 202 once the frame is dispatched;
|
||||
a synchronous validation rejection comes back as the 4xx body.
|
||||
Long-running handlers (the agent turn) keep running after the ack —
|
||||
their async output arrives on the event stream."""
|
||||
loop = self._loop
|
||||
if loop is None or not loop.is_running():
|
||||
_send_frame_json(
|
||||
handler,
|
||||
503,
|
||||
protocol.error(protocol.ERR_INTERNAL, "gateway loop not running").to_json(),
|
||||
)
|
||||
return
|
||||
# Reply sink: while this request is being dispatched, frames the
|
||||
# handler would send via send_to() are captured here instead (the
|
||||
# adapter's _reply routes them in). ``abandoned`` is set once the
|
||||
# HTTP response has been sent without consuming the sink (the
|
||||
# long-running-handler case); the dispatch's finally then delivers
|
||||
# any late replies via the event stream instead of losing them.
|
||||
sink: queue.Queue = queue.Queue()
|
||||
abandoned = threading.Event()
|
||||
self._adapter._http_register_sink(device_id, (sink, abandoned))
|
||||
try:
|
||||
task = asyncio.run_coroutine_threadsafe(
|
||||
self._dispatch_guarded(frame, device_id, sink, abandoned), loop
|
||||
)
|
||||
except Exception:
|
||||
self._adapter._http_pop_sink(device_id)
|
||||
_send_frame_json(
|
||||
handler, 500, protocol.error(protocol.ERR_INTERNAL, "dispatch failed").to_json()
|
||||
)
|
||||
return
|
||||
frames: list[protocol.Frame] = []
|
||||
deadline = time.monotonic() + ACCEPT_ACK_TIMEOUT_S
|
||||
while True:
|
||||
# If the handler is done, drain any replies and stop (no wait).
|
||||
# This keeps fast/ignored frames from incurring the sink timeout.
|
||||
if task.done():
|
||||
while True:
|
||||
try:
|
||||
frames.append(sink.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
break
|
||||
try:
|
||||
frames.append(sink.get(timeout=0.01))
|
||||
except queue.Empty:
|
||||
if time.monotonic() >= deadline:
|
||||
# Long-running handler (the agent turn): ack now; late
|
||||
# replies go to the event stream (the dispatch's finally
|
||||
# sees ``abandoned`` and delivers them there).
|
||||
abandoned.set()
|
||||
break
|
||||
continue
|
||||
# Got a frame; loop back to check task.done() (drain the rest if
|
||||
# the handler finished, e.g. a sync replay).
|
||||
if not frames:
|
||||
_send_json(handler, 202, {"ok": True})
|
||||
elif len(frames) == 1:
|
||||
f = frames[0]
|
||||
status = (
|
||||
429
|
||||
if f.payload.get("code") == protocol.ERR_RATE_LIMITED
|
||||
else (400 if f.type == protocol.TYPE_ERROR else 200)
|
||||
)
|
||||
_send_frame_json(handler, status, f.to_json())
|
||||
else:
|
||||
# Multi-frame reply (e.g. a sync replay): deliver it all on the
|
||||
# event stream; the ack stays plain.
|
||||
for f in frames:
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(self._deliver_via_stream(f), loop)
|
||||
_send_json(handler, 202, {"ok": True})
|
||||
|
||||
async def _dispatch_guarded(
|
||||
self,
|
||||
frame: protocol.Frame,
|
||||
device_id: str,
|
||||
sink: queue.Queue,
|
||||
abandoned: threading.Event,
|
||||
) -> None:
|
||||
try:
|
||||
await dispatch.dispatch_frame(self._adapter, frame, device_id)
|
||||
except Exception:
|
||||
logger.warning("iris: HTTP dispatch failed for %s", frame.type, exc_info=True)
|
||||
finally:
|
||||
# Pop our sink entry (a newer request from the same device may
|
||||
# have replaced it). If the HTTP response was already sent
|
||||
# (abandoned), any replies still in the sink are delivered via
|
||||
# the event stream instead of being lost. In the normal case the
|
||||
# handler thread has already drained the sink, so nothing is
|
||||
# left to deliver.
|
||||
popped = self._adapter._http_pop_sink_if(device_id, sink)
|
||||
if popped is not None and abandoned.is_set():
|
||||
while True:
|
||||
try:
|
||||
f = sink.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
with contextlib.suppress(Exception):
|
||||
await self._deliver_via_stream(f)
|
||||
|
||||
async def _deliver_via_stream(self, frame: protocol.Frame) -> None:
|
||||
await self.fanout(frame, cursor=None)
|
||||
|
||||
# ── GET /v1/events (SSE) ──────────────────────────────────────────────
|
||||
|
||||
def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None: # noqa: PLR0912,PLR0915
|
||||
qs = parse_qs(parsed.query)
|
||||
cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID"))
|
||||
# Device registration (the HTTP equivalent of the WS hello upsert):
|
||||
# the SSE open carries the device name + push tokens as optional
|
||||
# headers; upsert is idempotent and COALESCEs absent tokens, so a
|
||||
# re-open never clobbers a newer fcm.register value.
|
||||
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
|
||||
try:
|
||||
self._devices.upsert(
|
||||
device_id,
|
||||
device_name or device_id,
|
||||
None,
|
||||
fcm_token,
|
||||
ntfy_topic,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("iris: device registry upsert failed", exc_info=True)
|
||||
# Per-device token (docs/09 §9.3): minted once at pairing (idempotent
|
||||
# across (re)connects) and returned in the hello below; the app
|
||||
# stores it and presents it instead of the shared token from then on.
|
||||
device_token = ""
|
||||
try:
|
||||
device_token = self._devices.issue_token(device_id)
|
||||
except Exception:
|
||||
logger.warning("iris: device token issuance failed", exc_info=True)
|
||||
sub = _Subscriber(device_id=device_id, kind="sse")
|
||||
# Register BEFORE the replay so a frame appended in between is
|
||||
# fanned out to us (and de-duped by cursor below) instead of lost.
|
||||
self._add_sub(sub)
|
||||
reason = "eof"
|
||||
try:
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "text/event-stream")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("X-Accel-Buffering", "no")
|
||||
handler.end_headers()
|
||||
# INFO (not DEBUG like the per-request log): the stream
|
||||
# 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)
|
||||
# 1. Catch-up from the outbox (id = cursor; the envelope also
|
||||
# carries the cursor for the app's push dedupe).
|
||||
max_cursor = cursor
|
||||
for e in self._adapter._outbox.replay(cursor):
|
||||
c = int(e["cursor"])
|
||||
max_cursor = max(max_cursor, c)
|
||||
self._write_sse(handler, "frame", c, _with_cursor(e["frame"], c))
|
||||
# 2. hello (the HTTP equivalent of hello.ack) + current status.
|
||||
hello = protocol.hello_ack(
|
||||
server_caps=self._adapter.server_caps(),
|
||||
sync_cursor=self._adapter._outbox.latest_cursor(),
|
||||
channels=self._adapter.channel_list(),
|
||||
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
|
||||
device_token=device_token,
|
||||
)
|
||||
self._write_sse(handler, "hello", None, hello.to_json())
|
||||
self._write_sse(
|
||||
handler, "frame", None, protocol.status(self._adapter.gateway_status()).to_json()
|
||||
)
|
||||
# 2b. Live todo-list snapshot (ephemeral state, never outboxed):
|
||||
# a (re)connecting device re-learns the agent's current plan here.
|
||||
for snap in self._adapter.todo_snapshot_frames():
|
||||
self._write_sse(handler, "frame", None, snap.to_json())
|
||||
# 3. Live frames (cursor=None frames have no id).
|
||||
while True:
|
||||
if sub.closed.is_set():
|
||||
# stop() can land between the initial writes above and
|
||||
# this loop (the handler thread is descheduled under
|
||||
# load): drain the frames queued before the close — e.g.
|
||||
# the status{restarting} teardown broadcast — so the
|
||||
# client sees them before EOF instead of losing them to
|
||||
# the closed check.
|
||||
try:
|
||||
item = sub.q.get_nowait()
|
||||
except queue.Empty:
|
||||
reason = "stopped"
|
||||
break
|
||||
else:
|
||||
try:
|
||||
item = sub.q.get(timeout=SSE_HEARTBEAT_S)
|
||||
except queue.Empty:
|
||||
self._write_raw(handler, ": hb\n\n")
|
||||
continue
|
||||
if item is _STOP:
|
||||
reason = "stopped"
|
||||
break
|
||||
c, data = item
|
||||
if c is not None and c <= max_cursor:
|
||||
continue # already replayed above
|
||||
self._write_sse(handler, "frame", c, data)
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
# Client went away mid-stream: normal (the app reconnects with
|
||||
# Last-Event-ID and catches up from the outbox).
|
||||
reason = "client-gone"
|
||||
finally:
|
||||
logger.info("iris: SSE stream closed: %s (%s)", device_id, reason)
|
||||
self._remove_sub(sub)
|
||||
|
||||
@staticmethod
|
||||
def _write_sse(
|
||||
handler: BaseHTTPRequestHandler, event: str, cursor: int | None, data: str
|
||||
) -> None:
|
||||
lines = ""
|
||||
if cursor is not None:
|
||||
lines += f"id: {cursor}\n"
|
||||
lines += f"event: {event}\ndata: {data}\n\n"
|
||||
handler.wfile.write(lines.encode("utf-8"))
|
||||
handler.wfile.flush()
|
||||
|
||||
@staticmethod
|
||||
def _write_raw(handler: BaseHTTPRequestHandler, text: str) -> None:
|
||||
handler.wfile.write(text.encode("utf-8"))
|
||||
handler.wfile.flush()
|
||||
|
||||
# ── POST /v1/media (upload, docs/19 §19.15) ───────────────────────────
|
||||
|
||||
def _handle_media_upload(self, handler: BaseHTTPRequestHandler, device_id: str) -> None: # noqa: PLR0911
|
||||
"""Whole-file upload: metadata in headers, file bytes as the body.
|
||||
|
||||
Mirrors the WS ``media.upload`` contract (docs/07 §7.2) in one
|
||||
request: the body is streamed to a temp file (bounded RAM), then
|
||||
size + sha256 are verified and the file cached via the hermes
|
||||
``cache_*_from_bytes`` helpers. Runs entirely on the handler thread
|
||||
(plain file IO — no asyncio bridge needed)."""
|
||||
media_ref = (handler.headers.get("X-Iris-Media-Ref") or "").strip()
|
||||
kind = (handler.headers.get("X-Iris-Media-Kind") or "").strip()
|
||||
filename = (handler.headers.get("X-Iris-Media-Filename") or "upload")[:255]
|
||||
sha256 = (handler.headers.get("X-Iris-Media-Sha256") or "").strip().lower()
|
||||
mime = handler.headers.get("Content-Type") or "application/octet-stream"
|
||||
mime = mime.split(";")[0].strip()[:128]
|
||||
try:
|
||||
length = int(handler.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
|
||||
def reject(code: str, message: str) -> None:
|
||||
_send_frame_json(
|
||||
handler,
|
||||
_MEDIA_STATUS.get(code, 400),
|
||||
protocol.error(code, message).to_json(),
|
||||
)
|
||||
|
||||
# Same validation rules as the WS media.upload.start handler.
|
||||
if not media_ref or len(media_ref) > MAX_MEDIA_REF_LEN:
|
||||
reject(protocol.ERR_UNSUPPORTED, "X-Iris-Media-Ref header required")
|
||||
return
|
||||
if kind not in media_bridge.KINDS:
|
||||
reject(protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}")
|
||||
return
|
||||
if length <= 0:
|
||||
reject(protocol.ERR_UNSUPPORTED, "empty body")
|
||||
return
|
||||
if length > self._adapter.max_upload_bytes:
|
||||
reject(
|
||||
protocol.ERR_MEDIA_TOO_LARGE,
|
||||
f"upload of {length} bytes exceeds limit ({self._adapter.max_upload_bytes})",
|
||||
)
|
||||
return
|
||||
try:
|
||||
sess = self._adapter._media.create_upload(
|
||||
device_id,
|
||||
media_ref,
|
||||
kind,
|
||||
mime,
|
||||
filename,
|
||||
length,
|
||||
None,
|
||||
self._adapter.max_upload_bytes,
|
||||
)
|
||||
except media_bridge.MediaError as e:
|
||||
reject(e.code, e.message)
|
||||
return
|
||||
try:
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = handler.rfile.read(min(media_bridge.DEFAULT_CHUNK_BYTES, remaining))
|
||||
if not chunk:
|
||||
raise media_bridge.MediaError(
|
||||
protocol.ERR_INTERNAL, "client disconnected mid-upload"
|
||||
)
|
||||
sess.feed(chunk)
|
||||
remaining -= len(chunk)
|
||||
if sess.received != length:
|
||||
raise media_bridge.MediaError(
|
||||
protocol.ERR_INTERNAL,
|
||||
f"size mismatch (declared {length}, received {sess.received})",
|
||||
)
|
||||
entry = self._adapter._media.complete_upload(device_id, media_ref, sha256)
|
||||
except media_bridge.MediaError as e:
|
||||
# complete_upload already popped the session; discard is a no-op
|
||||
# in that case (feed/short-read failures leave it active).
|
||||
self._adapter._media.discard_upload(device_id, media_ref)
|
||||
reject(e.code, e.message)
|
||||
return
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
self._adapter._media.discard_upload(device_id, media_ref)
|
||||
return # client went away: nothing to answer
|
||||
_send_frame_json(handler, 201, protocol.media_upload_ack(True, entry.media_id).to_json())
|
||||
|
||||
# ── GET /v1/media/{id} (pull, docs/19 §19.15) ─────────────────────────
|
||||
|
||||
def _handle_media_pull(
|
||||
self, handler: BaseHTTPRequestHandler, device_id: str, media_id: str
|
||||
) -> None:
|
||||
"""Stream an outbound offer as the response body (docs/07 §7.3).
|
||||
|
||||
The delivery-path validation is re-checked at pull time, exactly as
|
||||
the WS ``media.pull`` handler does (the file may have moved since
|
||||
the offer)."""
|
||||
entry = self._adapter._media.get_outbound(media_id)
|
||||
if entry is None:
|
||||
_send_frame_json(
|
||||
handler,
|
||||
404,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}").to_json(),
|
||||
)
|
||||
return
|
||||
safe = validate_media_delivery_path(entry.path) if validate_media_delivery_path else None
|
||||
if safe is None:
|
||||
_send_frame_json(
|
||||
handler,
|
||||
404,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable").to_json(),
|
||||
)
|
||||
return
|
||||
filename = entry.filename.replace('"', "")
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", entry.mime)
|
||||
handler.send_header("Content-Length", str(entry.size))
|
||||
handler.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||||
handler.end_headers()
|
||||
try:
|
||||
with open(safe, "rb") as f: # pi-lens-ignore: python-path-traversal
|
||||
while True:
|
||||
chunk = f.read(media_bridge.DEFAULT_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
handler.wfile.write(chunk)
|
||||
handler.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass # client went away mid-pull, or the file vanished: normal
|
||||
|
||||
# ── GET /v1/poll (long-poll) ──────────────────────────────────────────
|
||||
|
||||
def _handle_poll(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
|
||||
qs = parse_qs(parsed.query)
|
||||
cursor = _parse_cursor(qs.get("cursor", [None])[0])
|
||||
sub = _Subscriber(device_id=device_id, kind="poll")
|
||||
self._add_sub(sub)
|
||||
try:
|
||||
frames: list[str] = []
|
||||
max_cursor = cursor
|
||||
for e in self._adapter._outbox.replay(cursor):
|
||||
c = int(e["cursor"])
|
||||
max_cursor = max(max_cursor, c)
|
||||
frames.append(_with_cursor(e["frame"], c))
|
||||
deadline = time.monotonic() + POLL_TIMEOUT_S
|
||||
while not frames and not sub.closed.is_set() and time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
try:
|
||||
item = sub.q.get(timeout=min(remaining, 5.0))
|
||||
except queue.Empty:
|
||||
continue
|
||||
if item is _STOP:
|
||||
break
|
||||
c, data = item
|
||||
if c is None or c <= max_cursor:
|
||||
continue
|
||||
max_cursor = c
|
||||
frames.append(data)
|
||||
hwm = max(max_cursor, self._adapter._outbox.latest_cursor())
|
||||
_send_json(handler, 200, {"cursor": hwm, "frames": frames})
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
# Client went away while we held the poll: normal.
|
||||
pass
|
||||
finally:
|
||||
self._remove_sub(sub)
|
||||
|
||||
|
||||
class _ThreadingHTTPD(ThreadingHTTPServer):
|
||||
"""One thread per connection (fine at single-user scale); daemon
|
||||
threads so a stuck handler can't block process exit."""
|
||||
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, addr: tuple[str, int], http_server: HttpServer):
|
||||
super().__init__(addr, _Handler)
|
||||
self.http_server = http_server
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
# HTTP/1.0 (default): the connection closes after each response. That
|
||||
# matches the transport's design (short-lived connections) and avoids
|
||||
# Content-Length bookkeeping on the streamed SSE response.
|
||||
server: _ThreadingHTTPD
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
||||
logger.debug("iris http: " + fmt, *args)
|
||||
|
||||
# ── Routing ───────────────────────────────────────────────────────────
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
hs = self.server.http_server
|
||||
if not hs.enabled:
|
||||
_send_json(self, 503, {"error": "http leg disabled"})
|
||||
return
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/v1/health":
|
||||
# Unauthenticated by design: it answers "is the gateway
|
||||
# alive?" and must not reflect tokens, device ids, or versions.
|
||||
_send_json(self, 200, {"ok": True})
|
||||
return
|
||||
if parsed.path == "/v1/events":
|
||||
device_id = hs._authenticate(self)
|
||||
if device_id is not None:
|
||||
hs._handle_sse(self, device_id, parsed)
|
||||
return
|
||||
if parsed.path == "/v1/poll":
|
||||
device_id = hs._authenticate(self)
|
||||
if device_id is not None:
|
||||
hs._handle_poll(self, device_id, parsed)
|
||||
return
|
||||
if parsed.path.startswith("/v1/media/"):
|
||||
media_id = parsed.path[len("/v1/media/") :]
|
||||
# The id is looked up in an exact-match dict; reject anything
|
||||
# path-shaped so a bad URL can't be mistaken for an id.
|
||||
if media_id and "/" not in media_id:
|
||||
device_id = hs._authenticate(self)
|
||||
if device_id is not None:
|
||||
hs._handle_media_pull(self, device_id, media_id)
|
||||
else:
|
||||
_send_json(self, 404, {"error": "not found"})
|
||||
return
|
||||
_send_json(self, 404, {"error": "not found"})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802, PLR0911, PLR0912
|
||||
hs = self.server.http_server
|
||||
if not hs.enabled:
|
||||
_send_json(self, 503, {"error": "http leg disabled"})
|
||||
return
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/v1/media":
|
||||
device_id = hs._authenticate(self)
|
||||
if device_id is None:
|
||||
return
|
||||
if hs._rate_limited(device_id):
|
||||
_send_frame_json(
|
||||
self,
|
||||
429,
|
||||
protocol.error(
|
||||
protocol.ERR_RATE_LIMITED, "http media rate limit exceeded"
|
||||
).to_json(),
|
||||
)
|
||||
return
|
||||
hs._handle_media_upload(self, device_id)
|
||||
return
|
||||
if parsed.path != "/v1/frame":
|
||||
_send_json(self, 404, {"error": "not found"})
|
||||
return
|
||||
device_id = hs._authenticate(self)
|
||||
if device_id is None:
|
||||
return
|
||||
if hs._rate_limited(device_id):
|
||||
_send_frame_json(
|
||||
self,
|
||||
429,
|
||||
protocol.error(
|
||||
protocol.ERR_RATE_LIMITED, "http frame rate limit exceeded"
|
||||
).to_json(),
|
||||
)
|
||||
return
|
||||
ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||||
if ctype != "application/json":
|
||||
_send_frame_json(
|
||||
self,
|
||||
400,
|
||||
protocol.error(
|
||||
protocol.ERR_INTERNAL, "Content-Type must be application/json"
|
||||
).to_json(),
|
||||
)
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length <= 0 or length > MAX_BODY_BYTES:
|
||||
# Drain the (oversize) body so the connection stays clean; cap the
|
||||
# drain at MAX_BODY_BYTES so a runaway body can't wedge the thread.
|
||||
if length > 0:
|
||||
to_drain = min(length, MAX_BODY_BYTES)
|
||||
while to_drain > 0:
|
||||
chunk = self.rfile.read(min(65536, to_drain))
|
||||
if not chunk:
|
||||
break
|
||||
to_drain -= len(chunk)
|
||||
_send_frame_json(
|
||||
self,
|
||||
413,
|
||||
protocol.error(
|
||||
protocol.ERR_INTERNAL, f"body must be 1..{MAX_BODY_BYTES} bytes"
|
||||
).to_json(),
|
||||
)
|
||||
return
|
||||
body = self.rfile.read(length)
|
||||
frame = protocol.Frame.from_json(body)
|
||||
if frame is None:
|
||||
_send_frame_json(
|
||||
self, 400, protocol.error(protocol.ERR_INTERNAL, "invalid frame").to_json()
|
||||
)
|
||||
return
|
||||
hs._handle_frame(self, device_id, frame)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Inbound ``message.send`` handling (app -> agent).
|
||||
|
||||
Mixin for ``adapter.IrisAdapter``. Echoes the user message to all devices
|
||||
(multi-device sync + ack), resolves ``media_refs`` to
|
||||
``MessageEvent.media_urls``/``media_types``, applies auto-threading, and
|
||||
hands the ``MessageEvent`` to ``handle_message()`` (the gateway's command
|
||||
pipeline + agent turn).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
|
||||
from . import protocol
|
||||
from .classify import _derive_thread_name
|
||||
from .mixin_base import IrisAdapterBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InboundHandlers(IrisAdapterBase):
|
||||
"""Inbound message.send (see module docstring)."""
|
||||
|
||||
async def on_message_send(self, frame: protocol.Frame, device_id: str) -> None: # noqa: PLR0912,PLR0915
|
||||
"""Handle an inbound ``message.send`` frame.
|
||||
|
||||
Echoes the user message to all devices (multi-device sync + ack),
|
||||
then builds a ``MessageEvent`` and hands it to ``handle_message()``
|
||||
(the gateway's command pipeline + agent turn).
|
||||
|
||||
M4: ``media_refs`` reference completed ``media.upload``s; they are
|
||||
resolved to ``MessageEvent.media_urls``/``media_types`` (local paths
|
||||
the agent's vision/audio tools can read) and echoed in the user
|
||||
message's ``media[]`` so every device renders the attachments.
|
||||
"""
|
||||
payload = frame.payload
|
||||
text = payload.get("text")
|
||||
text = text if isinstance(text, str) else ""
|
||||
|
||||
refs_raw = payload.get("media_refs")
|
||||
media_refs = (
|
||||
[r for r in refs_raw if isinstance(r, str) and r] if isinstance(refs_raw, list) else []
|
||||
)
|
||||
|
||||
if not text.strip() and not media_refs:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
chat_id = frame.chat_id or payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
chat_id = self.home_channel
|
||||
chat_id = chat_id.strip()
|
||||
|
||||
# Automation channels are read-only for the user: they only receive
|
||||
# gateway-originated output (cron jobs, webhooks). Reject direct sends
|
||||
# (the app hides the composer for them, this is the server-side
|
||||
# enforcement).
|
||||
target = self._channels.get(chat_id)
|
||||
if target is not None and target.get("automation"):
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED,
|
||||
f"{target.get('name') or chat_id} is an automation channel "
|
||||
"(read-only: cron/webhook output only)",
|
||||
id=frame.id,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
thread_id = frame.thread_id or payload.get("thread_id")
|
||||
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||
thread_id = None
|
||||
|
||||
reply_to = payload.get("reply_to")
|
||||
if not isinstance(reply_to, str) or not reply_to.strip():
|
||||
reply_to = None
|
||||
|
||||
# Auto-threading (the app's Threads setting, docs/06 §6.3): a message
|
||||
# in a channel's flat lane gets its own fresh thread, the way Telegram
|
||||
# topic mode mints a topic per new conversation. The thread is named
|
||||
# instantly from the user's opening message (derived title) and the
|
||||
# LLM upgrades the name in the background. The user echo, the agent
|
||||
# turn, and all streaming frames then carry the new thread_id.
|
||||
# Skipped for slash commands (session-scoped, not conversation
|
||||
# starters) and replies (they continue where the user is). Threading
|
||||
# is only active on the default channel; other channels stay flat.
|
||||
auto_thread = bool(payload.get("auto_thread"))
|
||||
default_entry = self._channels.default()
|
||||
if (
|
||||
auto_thread
|
||||
and thread_id is None
|
||||
and text.strip()
|
||||
and not text.lstrip().startswith("/")
|
||||
and reply_to is None
|
||||
and default_entry is not None
|
||||
and chat_id == default_entry["chat_id"]
|
||||
):
|
||||
entry = self._channels.create(
|
||||
name=_derive_thread_name(text),
|
||||
kind="thread",
|
||||
parent_chat_id=chat_id,
|
||||
)
|
||||
thread_id = entry["chat_id"]
|
||||
# Bare broadcast (like channel.create): the directory is
|
||||
# re-served on hello.ack, so no outbox entry is needed.
|
||||
await self._broadcast_both(protocol.channel_created(entry, auto=True))
|
||||
self._schedule_thread_title_upgrade(entry["chat_id"], text)
|
||||
|
||||
# M4: resolve media refs (single-use; unknown ref -> error).
|
||||
media_urls: list[str] = []
|
||||
media_types: list[str] = []
|
||||
media_wire: list[dict[str, Any]] = []
|
||||
for ref in media_refs:
|
||||
entry = self._media.get_inbound(ref)
|
||||
if entry is None:
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.error(
|
||||
protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id
|
||||
),
|
||||
)
|
||||
return
|
||||
media_urls.append(entry.path)
|
||||
media_types.append(entry.mime)
|
||||
media_wire.append(
|
||||
{
|
||||
"media_id": entry.media_id,
|
||||
"kind": entry.kind,
|
||||
"mime": entry.mime,
|
||||
"size": entry.size,
|
||||
"filename": entry.filename,
|
||||
}
|
||||
)
|
||||
|
||||
device = self._devices.get(device_id) or {}
|
||||
user_name = device.get("name") or device_id
|
||||
|
||||
# Echo to all devices: the sender confirms (server-assigned id),
|
||||
# other devices see the message too (single-user, multi-device).
|
||||
# Routed through _broadcast_or_log (not a bare broadcast) so the echo
|
||||
# is appended to the outbox: the app's ChatStore is in-memory only, so
|
||||
# after a process death / activity recreation the only way the user's
|
||||
# own message is restored is via the sync replay. Without this, user
|
||||
# messages vanish on reconnect while bot messages (already parked)
|
||||
# survive.
|
||||
message_id = f"m_{uuid.uuid4().hex[:16]}"
|
||||
echo = protocol.message(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
role=protocol.ROLE_USER,
|
||||
text=text,
|
||||
thread_id=thread_id,
|
||||
media=media_wire or None,
|
||||
reply_to=reply_to,
|
||||
ts=int(time.time() * 1000),
|
||||
)
|
||||
await self._broadcast_or_log(chat_id, echo)
|
||||
# Refs are consumed by this message (no replay).
|
||||
for ref in media_refs:
|
||||
self._media.pop_inbound(ref)
|
||||
|
||||
# M4: a new user turn starts -- stale offer association is dropped.
|
||||
self._last_message_id.pop(chat_id, None)
|
||||
|
||||
kind = media_wire[0]["kind"] if media_wire else None
|
||||
if kind == "image":
|
||||
message_type = MessageType.PHOTO
|
||||
elif kind == "video":
|
||||
message_type = MessageType.VIDEO
|
||||
elif kind == "audio":
|
||||
message_type = MessageType.AUDIO
|
||||
elif kind == "voice":
|
||||
message_type = MessageType.VOICE
|
||||
elif kind == "document":
|
||||
message_type = MessageType.DOCUMENT
|
||||
else:
|
||||
message_type = MessageType.TEXT
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=self._channel_name(chat_id),
|
||||
chat_type="dm",
|
||||
user_id=device_id,
|
||||
user_name=user_name,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=message_type,
|
||||
user_id=device_id,
|
||||
user_name=user_name,
|
||||
source=source,
|
||||
message_id=message_id,
|
||||
reply_to_message_id=reply_to,
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
# M5: acknowledge the user message to the originating device (the
|
||||
# app shows ✓✓) at the moment it is handed to the agent.
|
||||
await self._reply(
|
||||
device_id,
|
||||
protocol.read_receipt(chat_id, message_id),
|
||||
)
|
||||
|
||||
def _schedule_thread_title_upgrade(self, thread_id: str, text: str) -> None:
|
||||
"""Upgrade an auto-created thread's name with the model's title.
|
||||
|
||||
Stage 2 of hermes' two-stage session titling (``agent/title_generator
|
||||
.py``): the thread was created with an instant derived name; this
|
||||
background call on the ``title_generation`` auxiliary task replaces it
|
||||
with the model's title and broadcasts ``channel.renamed``. Best-effort
|
||||
— any failure (config, model, network) leaves the derived name in
|
||||
place, and a thread the user already renamed or archived is untouched.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _work() -> None:
|
||||
try:
|
||||
from agent.title_generator import generate_title
|
||||
|
||||
title = generate_title(text)
|
||||
except Exception:
|
||||
logger.debug("Thread title upgrade failed", exc_info=True)
|
||||
return
|
||||
if not title:
|
||||
return
|
||||
entry = self._channels.get(thread_id)
|
||||
if entry is None or entry.get("archived"):
|
||||
return
|
||||
if (entry.get("name") or "") == title:
|
||||
return
|
||||
renamed = self._channels.rename(thread_id, title)
|
||||
if renamed is None:
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._broadcast_both(protocol.channel_renamed(renamed)),
|
||||
loop,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Thread title rename broadcast failed", exc_info=True)
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="iris-thread-title").start()
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user