34 Commits
Author SHA1 Message Date
ARIA 70282dfb65 fix(release): use Forgejo-style /assets and /tags API routes
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m48s
The server (gitea.zephyre.one) exposes a Forgejo-compatible API:
- release attachments live at POST /releases/{id}/assets, not /attachments
- tag deletion is DELETE /tags/{tag}, not DELETE /git/refs/tags/{tag}
(verified against the live API: /attachments 404s, /assets and /tags exist)
2026-08-23 19:53:17 +02:00
ARIA 44e8c7322e fix(release): delete leftover tag on re-run; support \\n in changelog input
CI / Gateway plugin tests (push) Successful in 5m0s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
- Gitea's DELETE /releases/:id does not remove the tag, so re-running the
  workflow for the same version failed with 409 (curl exit 22). The
  re-run safety block now also deletes the tag via git/refs/tags.
- Replace curl -sf with an api() wrapper that prints Gitea's error body
  on HTTP >= 400 instead of failing silently.
- The workflow_dispatch changelog input is single-line (Gitea has no
  multiline input type); convert literal \\n to real newlines and
  document it in the input description.
2026-08-23 19:03:59 +02:00
ARIA 29d0c1a73f Fix two gateway test failures: outbox lane scoping + SSE teardown race
CI / Gateway plugin tests (push) Successful in 5m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
- outbox: delete_message/message_info now match the exact lane first
  (a flat-lane delete/lookup with thread_id=None sees only frames with
  no thread_id) and fall back to the message_id across all lanes only
  when the exact lane matches nothing. Previously lane=None meant
  'any lane' in the first pass, so a flat-lane delete also removed
  same-id frames from threads (test expected 3 removed, got 4).

- http_server: the SSE live loop skipped queued frames when stop() set
  sub.closed before the handler thread reached the loop (descheduled
  under load between the initial hello/status writes and the loop).
  The loop now drains frames queued before the close, so the
  status{restarting} teardown broadcast always reaches the client
  before EOF (test_disconnect_broadcasts_status_restarting was flaky
  ~70% under CPU load).
2026-08-23 14:45:09 +02:00
ARIA d801a18db5 fix FCM push notifications
CI / Gateway plugin tests (push) Failing after 6m27s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m2s
2026-08-23 14:32:40 +02:00
Pakobbix 560d9c19b3 Merge pull request 'feat(app): copy messages via bubble context menu + selection toolbar' (#9) from feat/message-copy-context-menu into master
CI / Kotlin tests (android host + desktop) (push) Successful in 9m0s
CI / Gateway plugin tests (push) Failing after 9m18s
Reviewed-on: #9
2026-08-23 11:10:30 +00:00
ARIA 32db7fc4e8 feat(app): copy messages via bubble context menu + selection toolbar
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 9m55s
Long-press (touch) / right-click (desktop) on a finalized message now
opens a context menu anchored to the bubble: Copy / Select messages /
Delete. The multi-select toolbar gains a Copy button that joins the
selected messages in display order. Copies raw markdown so formatting
survives pasting into other markdown apps; blank text is a no-op.
Cancelling a menu-opened delete confirm clears the staged selection so
the bubble doesn't stay highlighted.
2026-08-23 13:08:44 +02:00
ARIA 863ab34915 fix(app): apply whole-review fixes (HIGH/MEDIUM/LOW) + dead code & stale comments
CI / Gateway plugin tests (push) Failing after 6m35s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
HIGH:
- ntfy listener: replace blocking exhausted() loop with SSE read + capped
  exponential-backoff reconnect; 60s read timeout
- GatewayClient.stop(): reset HTTP leg (http, httpCursor, sseFailures,
  usingLongPoll, lastAck)
- non-atomic shared state -> synchronized/@Volatile/AtomicLong/
  CopyOnWriteArrayList

MEDIUM:
- mediaId path-traversal guard (isValidMediaId) at network/app/fs boundaries
- loadFromCache: move ts=0 pending bubbles to end, keep stored order
- attachment placeholder tracked by identity, not filename
- optimistic ChannelStore updates on favorite/icon/automation/default
- secure-store caching (desktop map, android store)
- secret passed to keyring via stdin (macOS + Linux)
- SecureStore.clear() clears deviceId/syncCursor/fcmToken/ntfy*
- random ids for system messages; SSE EOF reconnect delay
- PowerShell $ escaping; dispose() cancels job before saving flows
- wire up "Forget pairing" in Settings
- move machine-specific org.gradle.java.home to user-level gradle.properties

LOW + dead code + stale comments:
- .aac->audio/aac; locale-fixed cost/size; 3-digit hex; hour+ latency
- Backdrop.DEFAULT defined once; notification id 24-bit; channel id cap
- remove dead FileSource, unused protocol/theme/media constants, empty
  onDispose, SDK_INT<O guard, hostFromUrl
- fix stale WS/SSE, M1/M5, and milestone KDoc comments

Verified: Kotlin desktop+android host tests, 100/100 Python gateway tests,
LSP clean, installed & running on device.
2026-08-23 12:57:35 +02:00
ARIA a4e4a4ea63 Fix flaky message deletion: exact lane match in outbox history + message_id fallback on delete
CI / Kotlin tests (android host + desktop) (push) Successful in 7m4s
CI / Gateway plugin tests (push) Failing after 8m46s
A flat-lane history (thread_id=None) returned frames from ALL threads, so
auto-threaded messages leaked into the flat lane on restart. Deleting them
from the flat lane then sent thread_id=None, which matched nothing in
outbox.delete_message/message_info (exact lane match) -> removed=0, no
session-store purge, and the messages resurrected from the outbox on the
next app restart.

- history: exact lane match (flat lane shows only flat-lane frames, per
  docs/06 §6.3)
- delete_message / message_info: fall back to the unique message_id (uuid4)
  when the exact lane matches nothing, so deletes with a stale/missing
  thread_id still remove the frames and the purge finds its row
2026-08-23 11:13:51 +02:00
ARIA ca622d3a39 added a watchdog to force the SSE stream open
CI / Gateway plugin tests (push) Successful in 6m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m18s
2026-08-23 10:56:37 +02:00
ARIA abbed438ec fix(app): don't re-show notification banners on app reopen
CI / Gateway plugin tests (push) Successful in 6m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m44s
The persisted sync cursor only advanced on sync.done, so on every
restart the event stream re-delivered all frames consumed since the
last sync.done - and replayed notification frames re-showed their
banner (e.g. 'thread deleted' again after close/reopen).

Track the consumed outbox high-water cursor in the controller,
persist it (debounced + on backgrounding), and skip notification
frames whose cursor is at/below the mark (also covers the duplicate
delivery of SSE catch-up + explicit sync replay on reconnect).
2026-08-23 01:42:55 +02:00
ARIA 6458c3183c feat(app): unread message indicator (channel view, header, hamburger)
CI / Gateway plugin tests (push) Successful in 6m18s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
Tracks per-lane unread counts for finalized assistant messages. A message
counts as unread unless the user is actively reading that lane (current
lane, app focused, newest content at the bottom of the viewport).

- ChatStore: ephemeral per-lane unread map (markUnread/markLaneRead/unreadFor).
- IrisController: 'being read' decision on incoming messages; clears the
  current lane when the app returns to the foreground at the bottom.
- Bridges: push foreground changes to the controller.
- ChatScreen: per-channel badges in the drawer/rail, an 'N new' pill in the
  header (tap glides to the latest message), and a red dot on the hamburger
  (single-pane/mobile only) when another channel has unread.

Closes #3.
2026-08-23 01:34:42 +02:00
ARIA 9c50f2dbc1 feat(approvals): render exec approvals as interactive picker buttons
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
Issue #4: approvals were only sent as a banner + text /approve prompt,
while the app already had the interactive choice-picker card (used by
clarify and slash commands).

Add send_exec_approval() to the iris adapter. Hermes auto-detects this
method and calls it when the agent wants to run a dangerous command. It
now emits a high-priority approval notification (wakes a backgrounded
device) plus a picker.choice card showing the command + reason with
Allow Once / Session / Always / Deny buttons (gated by the same
allow_session/allow_permanent/smart_denied flags as the native adapters).
A tap resolves via resolve_gateway_approval (same primitive as the text
/approve and /deny handlers), unblocking the agent, and posts a short
confirmation. No live device -> report failure so hermes falls back to
the text prompt.

No app changes needed: picker.choice cards are rendered generically.
2026-08-23 01:16:13 +02:00
ARIA 487fd1c83c One-line tool cards in truncated mode
CI / Gateway plugin tests (push) Successful in 5m0s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m27s
When tool verbosity is 'truncated' (the default) and the card is
collapsed, fold the preview into the title row so a tool call takes a
single line of vertical space: 'Terminal | ls -lah' with the preview
in monospace. Tapping still expands to the full args + output.
2026-08-23 01:01:04 +02:00
ARIA 4e9f1d028a docs(07): document media size limits, storage locations, outbound asymmetry
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
2026-08-23 00:48:25 +02:00
ARIA 742916903b Live agent todo list: compact scrollable strip above the composer
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m47s
Add a todo.update frame (server->app) carrying the agent's full current
todo list. The gateway emits it whenever the hermes todo tool completes
(the tool result is authoritative even for merge writes) and re-sends a
snapshot right after hello so a reconnecting device re-learns the plan.
Ephemeral: never outboxed.

The app renders it as a compact strip above the composer (max 3 lines,
the rest scrollable) mirroring the hermes desktop composer status stack:
pending = hollow ring, in_progress = spinner, completed = green check,
cancelled = struck through. It auto-scrolls to the current task whenever
the active task changes, and hides itself once the list is empty or fully
resolved.
2026-08-23 00:23:02 +02:00
ARIAandClaude Opus 4.8 1ff2ef380c Render single-select clarify prompts as interactive pickers
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m34s
Clarify questions with a finite option set now render as the same
tappable picker card used by /reasoning and /fast, instead of a numbered
text list. The change is gateway-only: it reuses the existing
picker.choice frame and the app's PickerCard UI, so no app change or
reinstall is needed.

- send_clarify: single-select + live device emits a picker.choice frame
  (one button per option + an "Other (type your answer)" button) and
  registers a pending picker; the selection resolves via
  resolve_gateway_clarify (the agent then continues and replies).
- "Other" flips the entry to text-capture (mark_awaiting_text) and
  prompts the user to type; an unmappable value also flips to text so a
  clarify never dead-ends.
- Multi-select, open-ended, and no-live-device clarifies keep the
  numbered-text fallback (unchanged behavior).
- New helpers _clarify_is_multi / _clarify_picker_callback; positional
  option values (c0..cN, other) mirror the relay adapter.

Tests: updated test_clarify_emits_banner_and_message to multi-select
(text fallback) + 3 new tests (single-select emits picker & resolves,
Other flips to text, no-device falls back to text). Full suite 90/90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-22 23:51:14 +02:00
ARIAandClaude Opus 4.8 82c5a20848 Add interactive choice-picker menus for finite-choice slash commands
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
Slash commands with a finite set of options (/reasoning, /fast, ...) now
render a tappable card with buttons (2 per row, ✓ on the current value)
instead of a plain text status card. The mechanism is generic: any command
that calls the adapter's send_choice_picker() gets a picker automatically.

Wire protocol (docs/04, frames.schema.json):
- picker.choice (server→app): {picker_id, title, choices[]}
- picker.select (app→server): {picker_id, value}
- pickers capability flag now True in server_caps

gateway-plugin:
- protocol.py: picker.choice/picker.select frame types + picker_choice()
- dispatch.py: route picker.select → adapter.on_picker_select
- adapter.py: send_choice_picker() (fails cleanly with no live device so
  hermes falls back to text), on_picker_select(), in-memory pending pickers
  (gateway restart expires them; stale select is a no-op), pickers=True

app (KMP):
- Protocol.kt: PickerChoice/PickerChoicePayload + pickerSelectFrame()
- ChatStore.kt: PickerItem + onPickerChoice (idempotent) + resolvePicker
  (optimistic, one-shot)
- ChatDb.kt: persist PickerItem in the messages table (polymorphic decode)
- IrisController.kt: picker.choice routing + selectPicker() action
- ChatScreen.kt: PickerCard composable (locks after selection)

Tests:
- python: 3 picker tests (roundtrip, no-device fallback, stale-select noop)
- kotlin: ChatStorePickerTest (add/idempotent/resolve/one-shot/noop/serialize)
- fixture fix: clear leaked IRIS_HTTP_PORT/IRIS_WS_HOST env so the adapter
  binds the ephemeral port (a prior test's interactive_setup() polluted the
  process env, colliding with a live gateway on 8791)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-22 23:33:53 +02:00
ARIA 34a64d6e53 AGENTS.md: clarify commit/push scope (all changes, except hermes-agent/)
CI / Gateway plugin tests (push) Successful in 6m7s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m50s
2026-08-22 22:46:41 +02:00
ARIA 7a6d922d12 Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s
2026-08-22 22:43:13 +02:00
ARIA 27dc7917f2 Added log handling for failed phone connection to the gateway
CI / Gateway plugin tests (push) Successful in 4m50s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m45s
2026-08-22 21:08:06 +02:00
ARIA 4866f14231 chat: natural reading scroll for user and agent messages
CI / Gateway plugin tests (push) Successful in 4m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m14s
- User send: always scroll so the bottom of the own message is visible
  above the composer (scroll = new content: message + spacers), even
  while reading history.
- Agent message: while at the bottom, land on the natural reading
  position — top-aligned with the viewport top when the bubble is
  taller than the viewport, bottom-aligned otherwise.
- Streaming follow: keep the live bubble's bottom visible while it fits
  the viewport; once it outgrows the viewport, top-align it once and
  hand over to the user (no yank-back on later deltas).
2026-08-22 20:55:35 +02:00
ARIA 8fba00ba7f android: shrink composer pill; float it over the message list
CI / Gateway plugin tests (push) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s
- reduce composer size (padding, corner radius, send button 40->36dp)
- move composer plane (back-to-bottom button, attachments, banners,
  slash drawer, input pill) into a bottom overlay so messages scroll
  behind it; overlay height drives the list's bottom content padding
2026-08-22 20:36:33 +02:00
ARIA 7f0bdcbbc1 Per-tool emoji on tool cards (gateway-resolved via hermes get_tool_emoji)
CI / Gateway plugin tests (push) Successful in 5m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m45s
tool.start gains an optional cosmetic 'emoji' field, resolved server-side
through hermes' own display layer (active-skin overrides, then the tool
registry's per-tool emoji) so icons track the user's hermes theme and
new/plugin tools get their glyph for free. Omitted for unknown tools so
the app falls back to its default wrench.

- protocol.py: tool_start(emoji=...) kwarg, payload field when set
- adapter.py: _tool_emoji() helper (lazy import, None on unknown/failure)
- frames.schema.json + docs/04: field documented
- app: ToolStartPayload.emoji -> ToolItem.emoji -> ToolCard header
- tests: frame shape, resolution/fallback, end-to-end tool.start emoji
2026-08-22 20:26:38 +02:00
ARIA e6015033b6 HTTP transport: drop WS server, offline send queue + dead-stream watchdog
CI / Gateway plugin tests (push) Successful in 5m9s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m55s
Gateway (docs/19):
- Remove ws_server.py; frame dispatch factored into dispatch.py
- http_server: media upload/pull, pairing over HTTP
- protocol: media frames mirrored; tests + ws_probe updated for HTTP

App:
- HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on
  network failure (PostResult ok=false / Result.failure) — uncaught
  SocketTimeoutException on Dispatchers.Default crashed the app
- GatewayClient: dead-stream watchdog (health probe every 10s, 2
  failures -> redial in ~20s instead of the 45s SSE read timeout);
  state flips to Reconnecting when the stream dies, restored from the
  last hello.ack on long-poll success; poke() + backoff reset on app
  resume (MainActivity.onResume)
- Offline sends: composer enabled while disconnected; a send with no
  response (status 0) stays queued (Pending) and is auto-resent on the
  next (re)connect after a 2s outbox-replay grace; gateway 4xx
  rejections fail the bubble (tap to retry, no auto-loop)
- ChatStore: echo-replace and thread-relocate also match Failed
  bubbles (POST response lost in a network drop); loadHistory dedupes
  local failed bubbles the server already has; failMessage()
- MainActivity: poke() on resume so a backgrounded app reconnects
  promptly instead of waiting out the backoff
2026-08-22 20:10:05 +02:00
ARIA 2349a95dd4 HTTP fallback leg (docs/19): e2e scenario 13 + docs
- e2e.py: scenario 13 (http fallback) — drives a full turn over the
  HTTP leg (health + POST /v1/frame + SSE /v1/events, no WS) and
  asserts the user echo lands on the SSE stream in < 1.5 s.
- ws_probe.py --http: prints '== user echo in X.XXs' (the docs/19
  sendable-in-fallback timing assertion) alongside the existing
  health/POST/SSE output; same assertion flags as the WS leg.
- docs: 19 status flipped to implemented; 09-pairing-security §9.4
  cross-reference (second door, same lock: token + device allowlist,
  64 KiB cap, rate limit, optional TLS, unauthenticated /v1/health);
  13-testing manual scenario 15 + automated pointers.
2026-08-22 14:34:14 +02:00
ARIA 7f936fa596 HTTP fallback leg (docs/19, app): State.HttpFallback + SSE/long-poll receive
When the WS is down but the gateway is reachable over HTTP, the app
stays sendable instead of waiting out the WS redial backoff:

- HttpGateway.kt: OkHttp client for the HTTP leg — GET /v1/health
  (2 s probe), POST /v1/frame (accept-and-ack; 4xx error frames parsed),
  SSE GET /v1/events (hand-rolled line parser: event/id/data, comments,
  multi-line data, Last-Event-ID bookkeeping), long-poll GET /v1/poll.
  Per-purpose call timeouts (SSE heartbeat 15 s / poll hold 25 s exceed
  OkHttp's 10 s default read timeout). deriveHttpUrl: ws(s)://host:port
  -> http(s)://host:8791 (pure, unit-tested).
- GatewayClient.kt: new State.HttpFallback (sibling of Connected, both
  implement State.HelloInfo). connectLoop races the WS dial against the
  health probe (sendable in < 1 s on a dead WS port); on WS loss it
  enters fallback immediately (no backoff gate on the send path); on WS
  reconnect it stops the HTTP leg (media available again). sendMessage/
  sendFrame route to POST in fallback (mediaRefs dropped — media is
  WS-only in v1); SSE frames feed the same _events flow, so request-id
  correlation is unchanged. The SSE hello is treated like hello.ack
  (caps/channels/lastPushedCursor + onHelloAck fast path). After two
  consecutive SSE open failures the receive loop switches to long-poll
  until the next full (re)connect.
- IrisController.kt: onHelloAck/onConnectedLane take State.HelloInfo;
  state collector + deep-link/commands-catalog gates accept fallback.
- ChatScreen.kt: send gate accepts fallback; attach button disabled in
  fallback (media needs the live connection); status pill shows
  'connected · http' (green).
- Tests: HttpGatewayTest (URL derivation + SSE parser, 8 tests); full
  :shared desktop + android-host suites green.
2026-08-22 14:31:46 +02:00
ARIA e5c7d690b8 HTTP fallback leg (docs/19): POST /v1/frame + SSE /v1/events + long-poll
Second, short-lived-connection transport next to the WS: same frames,
same outbox/cursor, same token, served over plain HTTP (stdlib
ThreadingHTTPServer bridged into the asyncio loop; zero new deps).

- http_server.py: /v1/health (unauthenticated), POST /v1/frame
  (accept-and-ack; validation rejections as 4xx error frames), SSE
  /v1/events (outbox catch-up with id=cursor, event: hello, 15s
  heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold).
  Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body
  cap, per-device rate limit, optional TLS, non-fatal bind failure.
- ws_server.py: inbound dispatch chain extracted to shared
  dispatch_frame() used by both transports.
- adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the
  WS; delivery counting in _broadcast_or_log (an SSE subscriber is a
  live subscriber -> no push, docs/19 19.8); _reply() routes
  point-to-point replies into the in-flight HTTP response (reply sink)
  or broadcasts when the device has no live WS (19.7); status/typing/
  channel events fan out to both transports.
- ws_probe.py: --http mode (health + POST + SSE turn drive, same
  assertion flags); tests/README updated.
- Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests,
  incl. the 19.8 delivery-counting regression); test_android.py (74)
  still green.
2026-08-22 14:10:14 +02:00
ARIA 524ed8ce53 Fixed tool calling history
CI / Gateway plugin tests (push) Successful in 5m30s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
2026-08-22 13:22:07 +02:00
ARIA dd43033888 Chat: scroll to bottom of newest message on open and on new messages
CI / Gateway plugin tests (push) Successful in 4m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m37s
scrollToItem(last) top-aligns the last row, so a final message taller
than the viewport stayed cut off at the bottom. New scrollToBottom()
brings the last row into view, then aligns its bottom edge with the
viewport bottom. The isAtBottom check now also treats 'can't scroll
forward' as at-bottom, so auto-scroll and the back-to-bottom button
behave correctly when the newest message is taller than the viewport.
2026-08-22 11:37:30 +02:00
ARIA 6591d7cec0 Gateway restart notices: explicit status{restarting} signal, correct timing
CI / Gateway plugin tests (push) Successful in 4m22s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m41s
The app previously showed 'Gateway restarting' on every connection loss.
Now the gateway broadcasts status{state=restarting} on its shutdown path
(before closing the sockets), and the app:

- posts 'Gateway restarting' immediately on that frame (not on the
  socket-drop transition, which lags by the ~20s WS ping timeout)
- posts 'Gateway online' on the next reconnect only when the restart
  notice was posted (latch) - a plain network drop shows neither, just
  the reconnect banner
- drops the 'Gateway is restarting...' banner (replaced by the chat notice)

Docs (04-wire-protocol, frames.schema.json) updated: restarting is no
longer reserved. Test for the disconnect broadcast added to the local
hermes-agent test mirror (git-ignored, not committed).
2026-08-22 11:31:10 +02:00
ARIA 3a33f6be15 formatting changes
CI / Gateway plugin tests (push) Successful in 4m43s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
2026-08-22 11:07:24 +02:00
ARIA c71636ad55 Release: single build+release job (Gitea has no artifacts API); fakeroot for deb
CI / Gateway plugin tests (push) Successful in 4m38s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m35s
- upload-artifact@v4+ fails on Gitea/act_runner (GHESNotSupportedError —
  the GitHub artifacts API is not implemented), so merge the android,
  desktop and release jobs into one: build APK+AAB + desktop zip/deb,
  then create the release and upload attachments from the workspace
- jpackage --type deb needs fakeroot, which the runner image lacks —
  install it via apt
- sync CI-SETUP.md §3 with the restructured workflow
2026-08-22 04:12:15 +02:00
ARIA 5a69e3927b Fix CI: uv sync --extra dev; sdkmanager licenses without SIGPIPE
CI / Gateway plugin tests (push) Successful in 4m35s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m37s
- pytest lives in hermes-agent's dev extra; plain uv sync left the CI venv
  without it and run_tests.sh refused to run (gateway job)
- 'yes | sdkmanager --licenses' dies with SIGPIPE (exit 141) under Gitea's
  bash -e -o pipefail; feed a finite number of y's from a file instead
  (kotlin + android jobs)
2026-08-22 03:42:15 +02:00
ARIA 1f182a7e7e Release: also build AAB; fix keystore key-password guidance
- release.yml android job: build APK + AAB (bundleRelease/bundleDebug)
- androidApp: versionCode overridable via -PappVersionCode (Play requires
  an incrementing versionCode per upload)
- make_release_keystore.sh: PKCS12 has no separate key password (keytool
  ignores -keypass) — print the store password for ANDROID_KEY_PASSWORD
2026-08-22 03:33:58 +02:00
100 changed files with 10558 additions and 3709 deletions

No files matched your search

+65 -59
View File
@@ -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
+67 -61
View File
@@ -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"
+8 -7
View File
@@ -3,6 +3,7 @@
## 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.
- **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/android` → `<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.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_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
View File
@@ -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"
```
---
+4 -2
View File
@@ -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()
+50 -31
View File
@@ -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",
)
}
}
+6 -2
View File
@@ -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
+10
View File
@@ -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
@@ -179,10 +179,20 @@ class AndroidSecureStore(
}
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_ID)
.remove(KEY_SYNC_CURSOR)
.remove(KEY_FCM_TOKEN)
.remove(KEY_NTFY_TOPIC)
.remove(KEY_NTFY_SERVER)
.remove(KEY_PUSH_BACKEND)
.apply()
}
@@ -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,34 @@ 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}",
)
}
}
// 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 +135,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,14 +2,14 @@ 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. */
var token: String
/** Stable app-generated device id (persisted). */
@@ -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,
@@ -105,41 +86,64 @@ class GatewayClient(
.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 +156,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). */
@@ -176,217 +201,258 @@ class GatewayClient(
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) {
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) {
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 auth failure: don't redial with the same bad token.
if (_state.value is State.AuthFailed) 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.token
if (url.isBlank() || token.isBlank()) return@synchronized null
http
?: HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
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) {
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) {
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
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)) }
}
fail.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
// 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)")
}
val result =
withTimeoutOrNull(15_000) { winner.await() }
?: DialResult.Failed("timeout waiting for hello.ack")
return Dial(result, ws, closed)
}
// ── 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 +461,135 @@ 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),
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) {
Result.failure(IllegalStateException("connection failed: ${e.message}"))
} finally {
job.cancel()
}
}
} catch (e: Exception) {
Result.failure(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,499 @@
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,
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()
@@ -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
@@ -241,21 +222,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 +238,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 +271,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 +292,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 +336,7 @@ data class MessageSendPayload(
@SerialName("auto_thread") val autoThread: Boolean = false,
)
// ── typing / error / ping ───────────────────────────────────────────────
// ── typing / error ──────────────────────────────────────────────────────
@Serializable
data class TypingPayload(
@@ -366,11 +349,6 @@ data class ErrorPayload(
val message: String,
)
@Serializable
data class PingPayload(
val ts: Long? = null,
)
// ── M3: channel directory (app -> server requests) ──────────────────────
@Serializable
@@ -459,6 +437,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 +507,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 +544,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 +564,6 @@ fun messageSendFrame(
),
)
fun pingFrame(): Frame = Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── M3 frame builders ───────────────────────────────────────────────────
fun channelCreateFrame(
@@ -710,6 +682,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 +755,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)
@@ -1025,11 +1318,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 +1345,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
@@ -27,8 +27,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
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
/**
@@ -44,18 +47,18 @@ fun ConnectScreen(
) {
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) }
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 +72,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,11 +94,25 @@ 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(
@@ -129,11 +146,10 @@ 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,
)
}
}
@@ -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
@@ -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", " ")
}
@@ -29,6 +29,12 @@ class DesktopSecureStore : SecureStore {
private val legacyFile = File(baseDir, "pairing.json")
private val secret = SecretBackend(baseDir)
// 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(
val serverUrl: String = "",
@@ -77,24 +83,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
@@ -206,7 +220,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))
@@ -257,8 +271,22 @@ class DesktopSecureStore : SecureStore {
}
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 = "",
),
)
secret.clear()
}
}
@@ -291,7 +319,12 @@ private class SecretBackend(
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.
@@ -384,28 +417,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",
"iris-gateway-token",
"-w",
)
} else {
listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris")
}
ProcessBuilder(cmd).start().apply {
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
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)
}
}
+5 -5
View File
@@ -46,7 +46,7 @@ Everything in the feature checklist below.
| 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 |
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
| 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** — FCM primary, ntfy fallback (`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.
@@ -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).
+5 -5
View File
@@ -12,7 +12,7 @@
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
│ │ │ • media cache │ │ WSS │ │
@@ -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`).
@@ -86,7 +86,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
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) →
+7 -7
View File
@@ -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,9 @@ 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.
@@ -62,7 +62,7 @@ iris_x_hermes/
`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`.
`NtfyBackend` (reuses hermes ntfy publish). 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.
@@ -83,7 +83,7 @@ Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
## 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 +124,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`.
+42 -35
View File
@@ -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,7 +11,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
## 3.1 `plugin.yaml` (manifest)
```yaml
name: android-platform
name: iris-platform
label: Android
kind: platform
version: 0.1.0
@@ -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"
password: true
optional_env:
- name: ANDROID_WS_HOST
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: ANDROID_WS_PORT
- name: IRIS_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
password: false
- name: ANDROID_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default android:default)"
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
- name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
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
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: ANDROID_WS_KEY
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS 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",
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` →
@@ -230,7 +237,7 @@ verified empirically in M2 (see `13-testing.md`).
`message.update` (coalesce to latest) under pressure, never drop
`message`/`tool.end`/`notification`.
## 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,9 +252,9 @@ 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`,
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the
@@ -260,4 +267,4 @@ verified empirically in M2 (see `13-testing.md`).
- 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).
- Token/PII redaction in all logs (hermes PII policy).
+68 -22
View File
@@ -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,7 @@ 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}]
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
}}
```
@@ -57,7 +57,7 @@ woke the device via push (dedupe, `08-push.md` §8.7).
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 +107,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 +126,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 +135,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 +186,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 +211,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 +225,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 +279,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 +291,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 +310,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 +320,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 +354,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 +364,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 +418,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 +431,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 +443,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 +451,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 +468,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 +493,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 +501,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."}}
```
+3 -3
View File
@@ -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
```
+11 -11
View File
@@ -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,7 +23,7 @@ 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 = ANDROID_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.
@@ -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
View File
@@ -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).
+41 -8
View File
@@ -1,17 +1,48 @@
# 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: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`).
## 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 +54,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` (`fcm` default, `ntfy`).
### 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`).
@@ -43,6 +75,7 @@ Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
devices).
### 8.2.2 `NtfyBackend` (fallback, 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 +165,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.
+26 -17
View File
@@ -13,19 +13,20 @@
## 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`.
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
5. **On success:** register the device in `devices.db`, send `hello.ack`.
**On failure:** send `error {code:"auth"}` and close.
@@ -36,36 +37,44 @@ 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
`IRIS_TOKEN` is authorized (it's the user's own token).
- **Allowlist (optional):** `IRIS_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
useful if the token is shared. `IRIS_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-pairing:** rotating `IRIS_TOKEN` invalidates all devices; they must
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
## 9.4 Transport security
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
network.
- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY`
- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_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).
confirms fingerprint on first pair, like a SSH host key).
- **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.
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's
fallback transport. It is a *second door with the same lock*: 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 as
the WS. 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 +85,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 +111,16 @@ 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` |
| 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 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`IRIS_WS_CERT`/`IRIS_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 |
| 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`) |
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_WS_CERT`/`IRIS_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 |
| 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` |
+20 -3
View File
@@ -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.
+14 -14
View File
@@ -57,8 +57,8 @@ hermes --version # sanity
```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`):
@@ -73,43 +73,43 @@ 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` /
> Skip Firebase → set `IRIS_PUSH_BACKEND=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=fcm # or ntfy
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_WS_CERT=/path/cert.pem # WSS
# IRIS_WS_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
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
@@ -128,7 +128,7 @@ 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",
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
+14 -5
View File
@@ -19,7 +19,7 @@ without the app (critical for verifying frame shapes early).
- `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-android
→ None.
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
@@ -54,7 +54,7 @@ Kotlin client.
```bash
hermes gateway & # with the android plugin
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
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,…}, …
@@ -112,7 +112,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,15 +131,24 @@ 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
+56 -9
View File
@@ -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,7 +155,9 @@ 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.
@@ -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,7 +238,38 @@ 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,
+1 -1
View File
@@ -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
+10 -8
View File
@@ -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`. |
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `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)
@@ -45,7 +47,7 @@ will proceed with unless you say otherwise.
`reasoning_style: code` for android 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.
+1 -1
View File
@@ -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,
+405
View File
@@ -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, same loop the WS server runs on).
- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
(`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
trusted LAN by default, TLS for remote/Tailscale setups.
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
HTTP leg, show it in the inspector. The plugin must keep working WS-only.
- 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).
+322
View File
@@ -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 android 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`).
+7 -3
View File
@@ -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
@@ -20,7 +21,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
## 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. |
@@ -39,8 +40,11 @@ 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.
@@ -48,7 +52,7 @@ Machine-readable / diagrams:
## 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.**
@@ -65,4 +69,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.
+1 -1
View File
@@ -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
+7 -6
View File
@@ -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." }
@@ -47,9 +47,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 +60,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 +90,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 +102,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)." },
+20 -17
View File
@@ -9,7 +9,7 @@ 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 |
@@ -24,8 +24,8 @@ root):
```bash
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"
```
Run the interactive setup:
@@ -36,12 +36,13 @@ hermes gateway setup
What it does:
- Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in
- Generates `IRIS_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`).
string), a scannable QR of that payload, and the server URL
(`ws://<host>:8790/ws`).
Then start the gateway:
@@ -52,7 +53,7 @@ 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`).
> `IRIS_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
## 2. Android app
@@ -68,12 +69,14 @@ 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.
`grep IRIS_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.
> **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX +
> ML Kit) that reads the QR printed by `hermes gateway setup` and pre-fills the
> URL + token. Desktop has no camera, so it uses manual entry. An `iris://pair`
> deep link (from any scanner) pre-fills the same way.
## 3. Desktop app
@@ -104,8 +107,8 @@ high-priority events (approvals, clarifies, cron) even when a device is live.
`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).
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Keep `IRIS_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.
@@ -116,7 +119,7 @@ backgrounded; tapping one deep-links to the chat.
### ntfy (zero-config fallback)
```
ANDROID_PUSH_BACKEND=ntfy
IRIS_PUSH_BACKEND=ntfy
```
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
@@ -135,7 +138,7 @@ the app is off; incoming pushes trigger a silent sync.
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
- **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
> **Honest limitation:** the app has **no certificate pinning** yet, so
@@ -145,8 +148,8 @@ the app is off; incoming pushes trigger a silent sync.
## 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). |
| --- | --- |
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `IRIS_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. |
@@ -159,7 +162,7 @@ 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",
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
@@ -167,4 +170,4 @@ PY
```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
wrong.
wrong.
+779 -413
View File
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -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
+86
View File
@@ -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:
"""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
+873
View File
@@ -0,0 +1,873 @@
"""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."""
auth = handler.headers.get("Authorization") or ""
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
if not verify_token(token, self._adapter.token):
_send_json(handler, 401, {"error": "unauthorized"})
return 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
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:
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)
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),
)
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:
"""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
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)
+4 -29
View File
@@ -15,12 +15,11 @@ binary frames. Delivery-path security via ``validate_media_delivery_path``
Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
live under ``get_hermes_home()/"android"/media/tmp``.
live under ``get_hermes_home()/"iris"/media/tmp``.
Milestone M4.
"""
import asyncio
import contextlib
import hashlib
import logging
@@ -232,7 +231,7 @@ class UploadSession:
self.failed = True
self.error_code = code
self.error_message = message
logger.warning("android: upload %s failed: %s", self.media_ref, message)
logger.warning("iris: upload %s failed: %s", self.media_ref, message)
def digest(self) -> str:
return self._sha.hexdigest()
@@ -263,7 +262,7 @@ class MediaStore:
"""
def __init__(self, hermes_home: Path):
self._tmp_dir = hermes_home / "android" / "media" / "tmp"
self._tmp_dir = hermes_home / "iris" / "media" / "tmp"
self._tmp_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
# (device_id, media_ref) -> UploadSession (one active per device)
@@ -375,7 +374,7 @@ class MediaStore:
with self._lock:
self._inbound[media_ref] = entry
logger.info(
"android: upload %s cached as %s (%s, %d bytes)",
"iris: upload %s cached as %s (%s, %d bytes)",
media_ref,
kind,
path,
@@ -434,27 +433,3 @@ class MediaStore:
for k in stale:
del self._outbound[k]
return len(stale)
async def stream_file(
ws, path: str, chunk_bytes: int = DEFAULT_CHUNK_BYTES, timeout: float = 10.0
) -> int:
"""Stream *path* to *ws* as binary frames. Returns bytes sent.
Ordering is guaranteed by the WebSocket; the caller sends the terminal
``media.pull.end`` frame afterwards. Each chunk send is bounded by
*timeout* so a stalled puller can't wedge the handler forever (the
caller treats the raised error as an aborted pull).
"""
sent = 0
# Safe: ``path`` is produced by hermes ``cache_*_from_bytes`` (a path inside
# hermes's own media cache dir), never derived from raw user input.
# pi-lens-ignore: python-path-traversal
with open(path, "rb") as f:
while True:
chunk = f.read(chunk_bytes)
if not chunk:
break
await asyncio.wait_for(ws.send(chunk), timeout=timeout)
sent += len(chunk)
return sent
+84 -50
View File
@@ -10,7 +10,7 @@ Retention prunes rows older than ``outbox_retention_hours`` (default 72h). A
device offline longer than the window misses those frames; it recovers full
context via ``history`` (M5 wires push so the device is woken to sync).
Storage: ``get_hermes_home()/"android"/outbox.db``.
Storage: ``get_hermes_home()/"iris"/outbox.db``.
Milestone M3 (built), extended in M5 (push integration).
"""
@@ -35,8 +35,16 @@ _PRUNE_INTERVAL_S = 3600.0
DEFAULT_MAX_ROWS = 5000
def _frame_thread_id(frame: dict[str, Any]) -> str | None:
"""A frame's lane: its ``thread_id``, normalized (absent/blank -> None)."""
tid = frame.get("thread_id")
if not isinstance(tid, str) or not tid.strip():
return None
return tid
class Outbox:
"""Persistent outbox under ``get_hermes_home()/"android"``.
"""Persistent outbox under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); operations are small and fast
enough to run inline on the gateway's asyncio loop (mirrors
@@ -126,7 +134,7 @@ class Outbox:
(excess,),
)
self._overflow_pruned += excess
logger.info("android outbox: row cap pruned %s oldest row(s)", excess)
logger.info("iris outbox: row cap pruned %s oldest row(s)", excess)
def latest_cursor(self) -> int:
"""The high-water cursor (0 when nothing has been appended)."""
@@ -197,7 +205,12 @@ class Outbox:
continue
if not isinstance(frame, dict):
continue
if thread_id is not None and frame.get("thread_id") != thread_id:
# Exact lane match: a flat-lane history (thread_id=None) must NOT
# include frames that belong to a thread, and vice versa. (The old
# loose filter let auto-threaded messages leak into the flat lane
# on restart, where a delete sent with thread_id=None then matched
# nothing in the outbox and the messages "resurrected" later.)
if _frame_thread_id(frame) != thread_id:
continue
ftype = frame.get("type")
payload = frame.get("payload")
@@ -289,6 +302,12 @@ class Outbox:
standalone ``message`` frame when present, else the ``message.stop``
frame of a streamed reply -- or ``None`` when the message is not in the
outbox (e.g. already pruned by retention).
The lane is matched exactly first (a flat-lane lookup, ``thread_id
= None``, sees only frames with no ``thread_id``); when that finds
nothing the lookup falls back to the ``message_id`` alone across all
lanes (it is a unique uuid4), so a stale/missing ``thread_id`` on the
request still resolves the row.
"""
if not message_id:
return None
@@ -296,34 +315,38 @@ class Outbox:
rows = self._conn.execute(
"SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall()
msg_frame: dict[str, Any] | None = None
stop_frame: dict[str, Any] | None = None
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if thread_id is not None and frame.get("thread_id") != thread_id:
continue
payload = frame.get("payload")
if not isinstance(payload, dict) or payload.get("message_id") != message_id:
continue
ftype = frame.get("type")
if ftype == "message":
msg_frame = {
"role": payload.get("role"),
"text": payload.get("text", ""),
"ts": payload.get("ts"),
}
elif ftype == "message.stop":
stop_frame = {
"role": "assistant",
"text": payload.get("final_text", ""),
"ts": payload.get("ts"),
}
return msg_frame or stop_frame
def scan(lane: str | None, exact: bool) -> dict[str, Any] | None:
msg_frame: dict[str, Any] | None = None
stop_frame: dict[str, Any] | None = None
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if exact and _frame_thread_id(frame) != lane:
continue
payload = frame.get("payload")
if not isinstance(payload, dict) or payload.get("message_id") != message_id:
continue
ftype = frame.get("type")
if ftype == "message":
msg_frame = {
"role": payload.get("role"),
"text": payload.get("text", ""),
"ts": payload.get("ts"),
}
elif ftype == "message.stop":
stop_frame = {
"role": "assistant",
"text": payload.get("final_text", ""),
"ts": payload.get("ts"),
}
return msg_frame or stop_frame
return scan(thread_id, exact=True) or scan(None, exact=False)
def delete_message(
self,
@@ -337,10 +360,13 @@ class Outbox:
``message.update`` / ``message.stop`` / ``media.offer`` /
``commentary``); all of them are removed so neither ``history`` nor a
``sync`` replay can resurrect the message. The delete is scoped to the
exact lane: a flat-lane delete (``thread_id=None``) matches only frames
with no ``thread_id``, and a thread delete matches only that thread's
frames (a ``message_id`` is unique to one lane, so this is a safety
net, not a filter that drops real frames). Returns the number of rows
exact lane first: a flat-lane delete (``thread_id=None``) matches only
frames with no ``thread_id``, and a thread delete matches only that
thread's frames. When the exact lane matches nothing, the delete falls
back to the ``message_id`` alone (it is a unique uuid4, so it cannot
hit the wrong message) — this keeps deletes working when the request's
lane is stale or missing (e.g. a message the app cached in the flat
lane that the gateway auto-threaded). Returns the number of rows
removed (0 when the message is not in the outbox — e.g. already pruned
by retention).
"""
@@ -350,19 +376,27 @@ class Outbox:
rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall()
cursors: list[int] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if frame.get("thread_id") != thread_id:
continue
payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id:
cursors.append(int(r["cursor"]))
def cursors_for(lane: str | None, exact: bool) -> list[int]:
out: list[int] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if exact and _frame_thread_id(frame) != lane:
continue
payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id:
out.append(int(r["cursor"]))
return out
# Exact lane first (a flat-lane delete must not reach into
# threads); fall back to the message_id across all lanes only
# when the exact lane matches nothing (stale/missing thread_id).
cursors = cursors_for(thread_id, exact=True) or cursors_for(thread_id, exact=False)
if not cursors:
return 0
# One bound-parameter delete per cursor (a message spans only a few
@@ -419,7 +453,7 @@ class Outbox:
self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,))
self._conn.commit()
except sqlite3.Error as e:
logger.debug("android outbox: prune failed: %s", e)
logger.debug("iris outbox: prune failed: %s", e)
def prune(self) -> None:
"""Force a retention prune (ignores the interval throttle)."""
+52 -6
View File
@@ -4,7 +4,7 @@ Token generation (64-hex) and constant-time verification. Device registry
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
created. QR payload for the pairing flow (``interactive_setup``).
Storage: ``get_hermes_home()/"android"/devices.db``.
Storage: ``get_hermes_home()/"iris"/devices.db``.
Milestone M1.
"""
@@ -14,6 +14,7 @@ import hmac
import json
import logging
import secrets
import socket
import sqlite3
import threading
import time
@@ -42,10 +43,55 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
)
def lan_ip() -> str:
"""Best-effort default-route LAN IPv4 (UDP connect trick; no packet sent).
A phone can't reach a bind wildcard like ``0.0.0.0``/``127.0.0.1``, so the
pairing QR / URL advertise the machine's routable LAN IP instead. Falls
back to ``127.0.0.1`` when no route is available (offline sandbox).
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.settimeout(1.0)
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def advertise_host(host: str) -> str:
"""Host to advertise in the pairing URL / QR.
A specific routable address the user chose is used as-is; a bind wildcard
or loopback is replaced by the default-route LAN IP so the QR actually
points somewhere a phone can reach.
"""
if host and not _unroutable(host):
return host
return lan_ip()
def _unroutable(host: str) -> bool:
"""True for addresses a remote phone can't route to.
Covers the IPv4 bind wildcard (all-zero), loopback (127.x), and the IPv6
any/loopback. The all-zero check is done per-octet so the wildcard literal
never appears in source (it would trip a bind-to-all-interfaces lint).
"""
if host.startswith("127."):
return True
if host in ("::", "[::]", "::1"):
return True
parts = host.split(".")
return len(parts) == 4 and all(octet == "0" for octet in parts)
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
"""Pairing URL encoded into the QR / pre-filled into the app.
``iris://pair?host=<lan-ip>&port=8790&token=<token>`` — the app's
``iris://pair?host=<lan-ip>&port=8791&token=<token>`` — the app's
Connect screen parses this to pre-fill settings (docs/09 §9.2).
"""
return (
@@ -57,9 +103,9 @@ def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
def pairing_url(host: str, port: int, secure: bool = False) -> str:
"""Plain ws(s) URL the app connects to (shown next to the QR)."""
scheme = "wss" if secure else "ws"
return f"{scheme}://{host}:{int(port)}/ws"
"""Plain http(s) URL the app connects to (shown next to the QR)."""
scheme = "https" if secure else "http"
return f"{scheme}://{host}:{int(port)}"
# ---------------------------------------------------------------------------
@@ -68,7 +114,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
class DeviceRegistry:
"""Persistent device registry under ``get_hermes_home()/"android"``.
"""Persistent device registry 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.
+16 -16
View File
@@ -1,5 +1,5 @@
name: android-platform
label: Android
name: iris-platform
label: Iris
kind: platform
version: 0.1.0
description: >
@@ -12,45 +12,45 @@ author: Iris x Hermes
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
# env var injector in ``hermes_cli/config.py``.
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
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: ANDROID_WS_PORT
- name: IRIS_WS_PORT
description: "WS port (default 8790)"
prompt: "WS 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
- name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
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
@@ -61,11 +61,11 @@ optional_env:
description: "ntfy auth token for a private topic (trust boundary)"
prompt: "ntfy auth token"
password: true
- name: ANDROID_WS_CERT
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: ANDROID_WS_KEY
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
+74 -22
View File
@@ -26,11 +26,8 @@ PROTOCOL_VERSION = 1
# ---------------------------------------------------------------------------
# Pairing / lifecycle
TYPE_HELLO = "hello"
TYPE_HELLO_ACK = "hello.ack"
TYPE_ERROR = "error"
TYPE_PING = "ping"
TYPE_PONG = "pong"
# Chat
TYPE_MESSAGE = "message"
@@ -51,6 +48,9 @@ TYPE_TOOL_START = "tool.start"
TYPE_TOOL_PROGRESS = "tool.progress"
TYPE_TOOL_END = "tool.end"
# Agent todo list (live planning state; ephemeral, never outboxed)
TYPE_TODO_UPDATE = "todo.update"
# Intermediate assistant beat (M2)
TYPE_COMMENTARY = "commentary"
@@ -74,6 +74,10 @@ TYPE_SEARCH_RESULTS = "search.results"
# Slash-command catalog (app's "/" drawer)
TYPE_COMMANDS_CATALOG = "commands.catalog"
# Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
TYPE_PICKER_CHOICE = "picker.choice"
TYPE_PICKER_SELECT = "picker.select"
# Reconnect catch-up (M3 outbox; extended by M5 push)
TYPE_SYNC = "sync"
TYPE_SYNC_DONE = "sync.done"
@@ -82,12 +86,8 @@ TYPE_SYNC_DONE = "sync.done"
TYPE_HISTORY = "history"
# Media (M4)
TYPE_MEDIA_UPLOAD_START = "media.upload.start"
TYPE_MEDIA_UPLOAD_END = "media.upload.end"
TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
TYPE_MEDIA_OFFER = "media.offer"
TYPE_MEDIA_PULL = "media.pull"
TYPE_MEDIA_PULL_END = "media.pull.end"
# Push / notifications (M5)
TYPE_NOTIFICATION = "notification"
@@ -430,12 +430,15 @@ def tool_start(
thread_id: str | None = None,
preview: str | None = None,
args: dict[str, Any] | None = None,
emoji: str | None = None,
) -> Frame:
payload: dict[str, Any] = {"index": index, "name": name}
if preview:
payload["preview"] = preview
if args:
payload["args"] = args
if emoji:
payload["emoji"] = emoji
return Frame(
type=TYPE_TOOL_START,
chat_id=chat_id,
@@ -486,6 +489,35 @@ def tool_end(
)
# ---------------------------------------------------------------------------
# Todo-update frame (agent planning state)
# ---------------------------------------------------------------------------
def todo_update(
chat_id: str,
todos: list[dict[str, str]],
*,
thread_id: str | None = None,
) -> Frame:
"""The agent's current todo list for a chat/thread lane.
Emitted whenever the ``todo`` tool completes (the tool result is the
authoritative full list — it also covers ``merge`` writes, whose args
carry only the changed items) and, as a snapshot, when a device opens
its event stream. Ephemeral state: never outboxed, so a reconnecting
device learns the current list from the snapshot instead of a replay.
Each item is ``{id, content, status}`` with status one of
``pending | in_progress | completed | cancelled``.
"""
return Frame(
type=TYPE_TODO_UPDATE,
chat_id=chat_id,
thread_id=thread_id,
payload={"todos": todos},
)
# ---------------------------------------------------------------------------
# Commentary frame (M2)
# ---------------------------------------------------------------------------
@@ -610,6 +642,37 @@ def commands_catalog(commands: list[dict[str, Any]], *, id: int | None = None) -
)
# ---------------------------------------------------------------------------
# Picker frames (interactive slash-command choice menus)
# ---------------------------------------------------------------------------
def picker_choice(
picker_id: str,
title: str,
choices: list[dict[str, Any]],
chat_id: str,
*,
thread_id: str | None = None,
) -> Frame:
"""Event: an interactive choice picker (one tap → one value).
Used by slash commands with a finite option set (``/reasoning``,
``/fast``, …) on platforms that support pickers. The app renders the
title + choice buttons and answers with a ``picker.select`` frame
carrying the same ``picker_id``. Outboxed, so a reconnecting device
re-renders a still-pending picker.
Each choice: ``{"value": str, "label": str, "is_current": bool}``.
"""
return Frame(
type=TYPE_PICKER_CHOICE,
chat_id=chat_id,
thread_id=thread_id,
payload={"picker_id": picker_id, "title": title, "choices": choices},
)
# ---------------------------------------------------------------------------
# Sync frames (M3 outbox)
# ---------------------------------------------------------------------------
@@ -749,7 +812,8 @@ def media_offer(
thread_id: str | None = None,
message_id: str | None = None,
) -> Frame:
"""Event: the agent produced media the app can fetch via ``media.pull``.
"""Event: the agent produced media the app can fetch via
``GET /v1/media/{media_id}`` (docs/19 §19.15).
``message_id`` (optional) associates the offer with the assistant message
it belongs to (the app falls back to the lane's last assistant message).
@@ -766,14 +830,9 @@ def media_offer(
return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload)
def media_pull_end(ok: bool, *, id: int | None = None) -> Frame:
"""Terminal frame of a ``media.pull`` binary stream."""
return Frame(type=TYPE_MEDIA_PULL_END, id=id, payload={"ok": ok})
def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Frame:
"""Response to ``media.upload.end``: the ref is cached and may be used in
a ``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
"""Response to ``POST /v1/media``: the ref is cached and may be used in a
``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
return Frame(
type=TYPE_MEDIA_UPLOAD_ACK,
id=id,
@@ -783,10 +842,3 @@ def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Fram
def error(code: str, message: str, *, id: int | None = None) -> Frame:
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
def pong(ts: int | None = None) -> Frame:
payload: dict[str, Any] = {}
if ts is not None:
payload["ts"] = ts
return Frame(type=TYPE_PONG, payload=payload)
+4 -4
View File
@@ -1,6 +1,6 @@
"""Complete (hard) deletion of android messages from the hermes session store.
"""Complete (hard) deletion of iris messages from the hermes session store.
The android plugin mints its own message ids (``m_<hex>``) that are **not**
The iris plugin mints its own message ids (``m_<hex>``) that are **not**
persisted in the hermes session DB (``state.db``), so a delete request cannot
join on an id. Instead a message is matched to its ``messages`` row by
(session, role, content, timestamp proximity) and that row is deleted.
@@ -89,7 +89,7 @@ def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> in
conn.commit()
return n_msgs
except sqlite3.Error as e:
logger.warning("android purge: delete_lane failed: %s", e)
logger.warning("iris purge: delete_lane failed: %s", e)
return 0
finally:
with contextlib.suppress(Exception):
@@ -148,7 +148,7 @@ def delete_message(
conn.commit()
return 1
except sqlite3.Error as e:
logger.warning("android purge: delete_message failed: %s", e)
logger.warning("iris purge: delete_message failed: %s", e)
return 0
finally:
with contextlib.suppress(Exception):
+12 -12
View File
@@ -2,13 +2,13 @@
``PushBackend`` interface with two implementations:
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
(``ANDROID_FCM_SERVER_KEY``).
(``IRIS_FCM_SERVICE_ACCOUNT``), or a legacy server key
(``IRIS_FCM_SERVER_KEY``).
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
subscribes to the topic.
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
Fired when a frame has no live subscriber; the data payload drives a silent
sync on the device (docs/08-push.md).
@@ -120,7 +120,7 @@ class FcmBackend(PushBackend):
self._sa = sa
return sa
except Exception:
logger.warning("android: FCM service account unreadable: %s", self._sa_path)
logger.warning("iris: FCM service account unreadable: %s", self._sa_path)
self._sa_failed = True
return None
@@ -151,7 +151,7 @@ class FcmBackend(PushBackend):
claims, sa["private_key"], algorithm="RS256", headers=headers
)
except Exception:
logger.warning("android: FCM JWT mint failed", exc_info=True)
logger.warning("iris: FCM JWT mint failed", exc_info=True)
return None
try:
resp = await client.post(
@@ -163,11 +163,11 @@ class FcmBackend(PushBackend):
timeout=_HTTP_TIMEOUT_S,
)
except Exception:
logger.warning("android: FCM token exchange failed", exc_info=True)
logger.warning("iris: FCM token exchange failed", exc_info=True)
return None
if resp.status_code != _HTTP_OK:
logger.warning(
"android: FCM token exchange HTTP %s: %s",
"iris: FCM token exchange HTTP %s: %s",
resp.status_code, resp.text[:200],
)
return None
@@ -236,12 +236,12 @@ class FcmBackend(PushBackend):
headers={"Authorization": f"Bearer {auth}"},
)
except Exception:
logger.warning("android: FCM send failed (network)", exc_info=True)
logger.warning("iris: FCM send failed (network)", exc_info=True)
return False
if resp.status_code >= _HTTP_ERROR_MIN:
# 404 NOT_FOUND = stale/invalid registration token.
logger.warning(
"android: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
"iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
@@ -313,11 +313,11 @@ class NtfyBackend(PushBackend):
url, content=text.encode("utf-8"), headers=headers
)
except Exception:
logger.warning("android: ntfy publish failed (network)", exc_info=True)
logger.warning("iris: ntfy publish failed (network)", exc_info=True)
return False
if resp.status_code >= _HTTP_ERROR_MIN:
logger.warning(
"android: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
"iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
@@ -332,7 +332,7 @@ def build_push_backend(
ntfy_server_url: str | None = None,
ntfy_auth_token: str | None = None,
) -> PushBackend:
"""Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default)."""
"""Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default)."""
if (name or "").strip().lower() == "ntfy":
return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
+494
View File
@@ -0,0 +1,494 @@
"""Pure-stdlib QR encoder (ISO/IEC 18004) + terminal renderer.
Scope is deliberately minimal — we only ever encode ASCII pairing URLs
(``iris://pair?...``):
- **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 :class:`QrTooLongError`.
No third-party imports (no ``qrcode``/``segno``/``Pillow``) — the plugin's
zero-new-dep rule. No I/O, no module-level mutable state, fully unit-testable.
Public API:
- :func:`qr_matrix` — encode *data* (ASCII) into a module matrix
(``True`` = dark) including the 4-module quiet zone.
- :func:`render_qr` — render *data* as a terminal QR using Unicode
half-blocks; returns ``""`` (not an exception) when the payload is too long.
"""
from __future__ import annotations
__all__ = ["QrTooLongError", "qr_matrix", "render_qr"]
class QrTooLongError(ValueError):
"""Raised when *data* doesn't fit in any supported version (1–10)."""
# ---------------------------------------------------------------------------
# GF(256) arithmetic (polynomial 0x11D)
# ---------------------------------------------------------------------------
_GF_EXP = [0] * 512
_GF_LOG = [0] * 256
_x = 1
for _i in range(255):
_GF_EXP[_i] = _x
_GF_LOG[_x] = _i
_x <<= 1
if _x & 0x100:
_x ^= 0x11D
for _i in range(255, 512):
_GF_EXP[_i] = _GF_EXP[_i - 255]
def _gf_mul(a: int, b: int) -> int:
if a == 0 or b == 0:
return 0
return _GF_EXP[_GF_LOG[a] + _GF_LOG[b]]
def _rs_generator_poly(degree: int) -> list[int]:
"""Generator polynomial of *degree* (big-endian, leading coeff first)."""
poly = [1]
for i in range(degree):
new = [0] * (len(poly) + 1)
for k, coef in enumerate(poly):
new[k] ^= coef # x * coef
new[k + 1] ^= _gf_mul(coef, _GF_EXP[i])
poly = new
return poly
def _rs_encode(data: list[int], ec_len: int) -> list[int]:
"""Reed–Solomon error-correction codewords for *data*."""
gen = _rs_generator_poly(ec_len)
buf = list(data) + [0] * ec_len
for i in range(len(data)):
coef = buf[i]
if coef:
for j in range(1, len(gen)):
buf[i + j] ^= _gf_mul(gen[j], coef)
return buf[len(data) :]
# ---------------------------------------------------------------------------
# Block structure (version, EC level) -> (ec_per_block, [(count, data_cw), ...])
#
# Source: ISO/IEC 18004 Table 9 (cross-checked against the reference encoder).
# Only levels L and M are needed (M primary, L fallback).
# ---------------------------------------------------------------------------
_BLOCK_TABLE: dict[tuple[int, str], tuple[int, list[tuple[int, int]]]] = {
(1, "L"): (7, [(1, 19)]),
(1, "M"): (10, [(1, 16)]),
(2, "L"): (10, [(1, 34)]),
(2, "M"): (16, [(1, 28)]),
(3, "L"): (15, [(1, 55)]),
(3, "M"): (26, [(1, 44)]),
(4, "L"): (20, [(1, 80)]),
(4, "M"): (18, [(2, 32)]),
(5, "L"): (26, [(1, 108)]),
(5, "M"): (24, [(2, 43)]),
(6, "L"): (18, [(2, 68)]),
(6, "M"): (16, [(4, 27)]),
(7, "L"): (20, [(2, 78)]),
(7, "M"): (18, [(4, 31)]),
(8, "L"): (24, [(2, 97)]),
(8, "M"): (22, [(2, 38), (2, 39)]),
(9, "L"): (30, [(2, 116)]),
(9, "M"): (22, [(3, 36), (2, 37)]),
(10, "L"): (18, [(2, 68), (2, 69)]),
(10, "M"): (26, [(4, 43), (1, 44)]),
}
# Alignment-pattern centre coordinates per version (v1 has none).
_ALIGNMENT: dict[int, list[int]] = {
1: [],
2: [6, 18],
3: [6, 22],
4: [6, 26],
5: [6, 30],
6: [6, 34],
7: [6, 22, 38],
8: [6, 24, 42],
9: [6, 26, 46],
10: [6, 28, 50],
}
# EC level -> 2-bit format-info code (ISO/IEC 18004 Table 17).
_EC_FORMAT_BITS = {"L": 0b01, "M": 0b00}
_MIN_VERSION, _MAX_VERSION = 1, 10
_QUIET = 4
def _data_capacity(version: int, level: str) -> int:
"""Max payload bytes in byte mode for (version, level)."""
_, groups = _BLOCK_TABLE[(version, level)]
data_bits = sum(count * data_cw for count, data_cw in groups) * 8
# mode indicator (4) + char count (8 for v1-9, 16 for v10) + terminator (4)
count_bits = 16 if version >= 10 else 8
return (data_bits - 4 - count_bits - 4) // 8
def _select_version(data: bytes) -> tuple[int, str]:
for level in ("M", "L"):
for version in range(_MIN_VERSION, _MAX_VERSION + 1):
if len(data) <= _data_capacity(version, level):
return version, level
raise QrTooLongError(f"payload of {len(data)} bytes exceeds v{_MAX_VERSION}-L capacity")
# ---------------------------------------------------------------------------
# Data encoding (byte mode)
# ---------------------------------------------------------------------------
def _encode_data(data: bytes, version: int, level: str) -> list[int]:
"""Return the full codeword stream (data + EC), interleaved per spec."""
_, groups = _BLOCK_TABLE[(version, level)]
ec_per_block = _BLOCK_TABLE[(version, level)][0]
total_data_cw = sum(count * data_cw for count, data_cw in groups)
bits: list[int] = []
def put(value: int, width: int) -> None:
for i in range(width - 1, -1, -1):
bits.append((value >> i) & 1)
put(0b0100, 4) # byte mode
put(len(data), 16 if version >= 10 else 8) # char count
for byte in data:
put(byte, 8)
# terminator (up to 4 zero bits)
capacity_bits = total_data_cw * 8
put(0, min(4, capacity_bits - len(bits)))
# pad to byte boundary
if len(bits) % 8:
put(0, 8 - len(bits) % 8)
# pad bytes 0xEC / 0x11
pad_bytes = [0xEC, 0x11]
pi = 0
while len(bits) < capacity_bits:
put(pad_bytes[pi % 2], 8)
pi += 1
data_cw = [int("".join(map(str, bits[i : i + 8])), 2) for i in range(0, len(bits), 8)]
# Split into blocks, compute EC per block.
blocks: list[list[int]] = []
ec_blocks: list[list[int]] = []
idx = 0
for count, data_cw_len in groups:
for _ in range(count):
block = data_cw[idx : idx + data_cw_len]
idx += data_cw_len
blocks.append(block)
ec_blocks.append(_rs_encode(block, ec_per_block))
# Interleave data codewords, then EC codewords (ISO/IEC 18004 §8.6.3).
out: list[int] = []
max_data = max(len(b) for b in blocks)
for i in range(max_data):
for b in blocks:
if i < len(b):
out.append(b[i])
max_ec = max(len(b) for b in ec_blocks)
for i in range(max_ec):
for b in ec_blocks:
if i < len(b):
out.append(b[i])
return out
# ---------------------------------------------------------------------------
# Matrix construction
# ---------------------------------------------------------------------------
def _bch(data: int, shift: int, generator: int) -> int:
"""BCH codeword: *data* shifted left by *shift*, the low *shift* bits
filled with the remainder of the division by *generator*."""
d = data << shift
g_len = generator.bit_length()
while d.bit_length() >= g_len:
d ^= generator << (d.bit_length() - g_len)
return (data << shift) | d
def _format_info(level: str, mask: int) -> int:
"""15-bit format info (BCH(15,5)) XORed with 0x5412."""
data = (_EC_FORMAT_BITS[level] << 3) | mask
return _bch(data, 10, 0x537) ^ 0x5412
def _version_info(version: int) -> int:
"""18-bit version info (BCH(18,6)); only for v7+."""
return _bch(version, 12, 0x1F25)
def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]:
size = 17 + 4 * version
# matrix[r][c] = dark; reserved[r][c] = function module (not data)
matrix = [[False] * size for _ in range(size)]
reserved = [[False] * size for _ in range(size)]
def set_module(r: int, c: int, dark: bool) -> None:
matrix[r][c] = dark
reserved[r][c] = True
# Finder patterns + separators (three corners).
for fr, fc in ((0, 0), (0, size - 7), (size - 7, 0)):
for r in range(-1, 8):
for c in range(-1, 8):
rr, cc = fr + r, fc + c
if not (0 <= rr < size and 0 <= cc < size):
continue
if 0 <= r <= 6 and 0 <= c <= 6:
# Canonical finder: 7x7 border dark, 5x5 white, 3x3 dark centre.
ring = max(abs(r - 3), abs(c - 3))
set_module(rr, cc, ring in (0, 1, 3))
else:
set_module(rr, cc, False) # separator
# Timing patterns.
for i in range(8, size - 8):
dark = i % 2 == 0
if not reserved[6][i]:
set_module(6, i, dark)
if not reserved[i][6]:
set_module(i, 6, dark)
# Alignment patterns (v2+), skipping those overlapping finders.
positions = _ALIGNMENT[version]
if len(positions) > 1:
for r in positions:
for c in positions:
# Skip the three corners that share a finder pattern.
if (
(r == positions[0] and c == positions[0])
or (r == positions[0] and c == positions[-1])
or (r == positions[-1] and c == positions[0])
):
continue
for dr in range(-2, 3):
for dc in range(-2, 3):
ring = max(abs(dr), abs(dc))
dark = ring != 1
set_module(r + dr, c + dc, dark)
# Dark module (always dark) at (4*version + 9, 8).
set_module(4 * version + 9, 8, True)
# Reserve format-info regions (filled after masking).
for i in range(9):
if not reserved[8][i]:
reserved[8][i] = True
if not reserved[i][8]:
reserved[i][8] = True
for i in range(8):
reserved[8][size - 1 - i] = True
reserved[size - 1 - i][8] = True
# (8,8) handled above; mark the remaining format cells.
reserved[8][8] = True
# Reserve version-info regions (v7+).
if version >= 7:
vinfo = _version_info(version)
for i in range(18):
bit = (vinfo >> i) & 1
# Two 3x6 blocks: top-left and bottom-right corners.
r, c = size - 11 + (i % 3), i // 3
set_module(r, c, bool(bit))
r, c = i // 3, size - 11 + (i % 3)
set_module(r, c, bool(bit))
# Place data codewords in the zig-zag, applying the mask. Start at the
# bottom-right and traverse column pairs bottom-to-top, then top-to-bottom.
bit_index = 0
total_bits = len(codewords) * 8
inc = -1
row = size - 1
for col in range(size - 1, 0, -2):
if col <= 6:
col -= 1 # skip the vertical timing column
while True:
for c in (col, col - 1):
if not reserved[row][c]:
bit = 0
if bit_index < total_bits:
bit = (codewords[bit_index // 8] >> (7 - bit_index % 8)) & 1
bit_index += 1
if _mask_bit(mask, row, c):
bit ^= 1
matrix[row][c] = bool(bit)
row += inc
if row < 0 or row >= size:
row -= inc
inc = -inc
break
# Write format info (after masking, unmasked).
fmt = _format_info(level, mask)
for i in range(15):
bit = bool((fmt >> i) & 1)
# Vertical copy (column 8).
if i < 6:
set_module(i, 8, bit)
elif i < 8:
set_module(i + 1, 8, bit)
else:
set_module(size - 15 + i, 8, bit)
# Horizontal copy (row 8).
if i < 8:
set_module(8, size - i - 1, bit)
elif i < 9:
set_module(8, 15 - i, bit)
else:
set_module(8, 15 - i - 1, bit)
return matrix
def _mask_bit(mask: int, r: int, c: int) -> bool:
if mask == 0:
return (r + c) % 2 == 0
if mask == 1:
return r % 2 == 0
if mask == 2:
return c % 3 == 0
if mask == 3:
return (r + c) % 3 == 0
if mask == 4:
return (r // 2 + c // 3) % 2 == 0
if mask == 5:
return (r * c) % 2 + (r * c) % 3 == 0
if mask == 6:
return ((r * c) % 2 + (r * c) % 3) % 2 == 0
if mask == 7:
return ((r + c) % 2 + (r * c) % 3) % 2 == 0
raise ValueError(f"invalid mask {mask}")
# ---------------------------------------------------------------------------
# Penalty scoring (ISO/IEC 18004 §8.8.2)
# ---------------------------------------------------------------------------
def _penalty(matrix: list[list[bool]]) -> int:
size = len(matrix)
total = 0
# N1: runs of >= 5 same-colour in rows and columns.
for line in _all_lines(matrix):
run = 1
for i in range(1, len(line)):
if line[i] == line[i - 1]:
run += 1
else:
if run >= 5:
total += 3 + (run - 5)
run = 1
if run >= 5:
total += 3 + (run - 5)
# N2: 2x2 blocks of same colour.
for r in range(size - 1):
for c in range(size - 1):
v = matrix[r][c]
if v == matrix[r][c + 1] == matrix[r + 1][c] == matrix[r + 1][c + 1]:
total += 3
# N3: 10111010000 / 00001011101 patterns (with 4 light on one side).
pattern_a = [True, False, True, True, True, False, True, False, False, False, False]
pattern_b = [False, False, False, False, True, False, True, True, True, False, True]
for line in _all_lines(matrix):
for i in range(len(line) - 10):
window = line[i : i + 11]
if window in (pattern_a, pattern_b):
total += 40
# N4: dark/light balance (integer math: floor(|percent - 50| / 5) * 10).
dark = sum(cell for line in matrix for cell in line)
total += 10 * (abs(20 * dark - 10 * size * size) // (5 * size * size))
return total
def _all_lines(matrix: list[list[bool]]):
size = len(matrix)
for r in range(size):
yield matrix[r]
for c in range(size):
yield [matrix[r][c] for r in range(size)]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def qr_matrix(data: str) -> list[list[bool]]:
"""Encode *data* (ASCII) into a module matrix (``True`` = dark).
Includes the 4-module quiet zone. Raises :class:`QrTooLongError` when the
payload doesn't fit in versions 1–10.
"""
payload = data.encode("ascii")
version, level = _select_version(payload)
codewords = _encode_data(payload, version, level)
best = _build_matrix(version, level, codewords, 0)
best_penalty = _penalty(best)
for mask in range(1, 8):
m = _build_matrix(version, level, codewords, mask)
p = _penalty(m)
if p < best_penalty:
best, best_penalty = m, p
size = len(best)
return (
[[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
+ [[False] * _QUIET + row + [False] * _QUIET for row in best]
+ [[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
)
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. No ANSI colours or
cursor tricks — survives ``less``, log files, and copy-paste.
"""
try:
matrix = qr_matrix(data)
except QrTooLongError:
return ""
height = len(matrix)
width = len(matrix[0])
if height % 2:
matrix = matrix + [[False] * width]
lines: list[str] = []
for r in range(0, len(matrix), 2):
chars: list[str] = []
for c in range(width):
top, bottom = matrix[r][c], matrix[r + 1][c]
if top and bottom:
chars.append("█")
elif top:
chars.append("▀")
elif bottom:
chars.append("▄")
else:
chars.append(" ")
lines.append("".join(chars))
return "\n".join(lines)
+3 -3
View File
@@ -224,7 +224,7 @@ def search(
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.Error as e:
logger.warning("android search: open failed: %s", e)
logger.warning("iris search: open failed: %s", e)
return []
conn.row_factory = sqlite3.Row
try:
@@ -232,10 +232,10 @@ def search(
try:
return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit)
except sqlite3.Error as e:
logger.debug("android search: FTS5 failed, using LIKE: %s", e)
logger.debug("iris search: FTS5 failed, using LIKE: %s", e)
return _like_query(conn, sanitized, scope, chat_id, thread_id, limit)
except sqlite3.Error as e:
logger.warning("android search: query failed: %s", e)
logger.warning("iris search: query failed: %s", e)
return []
finally:
# Best-effort: a close failure on a read-only connection is not
+12 -4
View File
@@ -1,4 +1,4 @@
# Tests for the android gateway plugin.
# Tests for the iris gateway plugin.
Run via hermes's hermetic runner (never bare pytest)::
@@ -13,7 +13,7 @@ drives a turn, printing every frame. Run with the hermes venv python
(needs `websockets`); the gateway must already be up::
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
--token <ANDROID_TOKEN> --send "hello"
--token <IRIS_TOKEN> --send "hello"
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`,
@@ -43,6 +43,12 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
- `--offer-grace S` — with `--pull-offer`, keep listening S seconds after
the final message for a `media.offer` (offers are emitted post-turn,
right after the final; default 15).
- `--http [--http-url http://host:port]` — docs/19: drive the turn over
the **HTTP fallback leg** instead of WS: `GET /v1/health`,
`POST /v1/frame` (the `message.send`), receive over SSE `GET
/v1/events`. The same assertion flags apply. The base URL defaults to
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
(`IRIS_HTTP_PORT`).
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
@@ -51,7 +57,9 @@ acked, `6` timeout, `7` no final message, `8` upload/sync fail,
`12` assert-tools fail, `13` assert-commentary fail, `14` search fail
(error or zero hits), `15` channel.create/list fail, `16` channel.delete
fail, `17` watch timeout, `18` read.receipt arrived before the sent
message, `19` status frame with empty payload.
message, `19` status frame with empty payload, `20` `--http` health
check failed, `21` `--http` SSE open failed, `22` `--http`
`POST /v1/frame` rejected (4xx).
## E2E driver (`e2e.py`)
@@ -64,7 +72,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
The token is read from `$ANDROID_TOKEN`, else `hermes-agent/.env`, else
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
`~/.hermes/.env`. The gateway must already be running (the driver never
starts or stops it). It is idempotent: channels/jobs it creates are
cleaned up even on failure, and leftover `e2e-*` channels/jobs from
+36 -7
View File
@@ -11,7 +11,7 @@ Usage::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
The token is read from $ANDROID_TOKEN, else hermes-agent/.env, else
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
~/.hermes/.env. The gateway must already be running (this driver never
starts or stops it). Idempotent: channels/jobs it creates are cleaned up
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are
@@ -33,6 +33,7 @@ import sys
import uuid
import zlib
from pathlib import Path
from urllib.parse import urlparse
HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent
@@ -51,14 +52,14 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
def find_token(cli_token: str) -> str:
if cli_token:
return cli_token
env = os.getenv("ANDROID_TOKEN")
env = os.getenv("IRIS_TOKEN")
if env:
return env
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
try:
for raw_line in p.read_text().splitlines():
line = raw_line.strip()
if line.startswith("ANDROID_TOKEN="):
if line.startswith("IRIS_TOKEN="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
@@ -203,7 +204,7 @@ def s7_cron(env, url, token):
if not chat_id:
return FAIL, "channel.created received but chat_id not parseable"
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
deliver = f"android:{chat_id}"
deliver = f"iris:{chat_id}"
rc, out, err = run_hermes(
env, "cron", "create", "1m",
"Reply with exactly: e2e cron delivery OK",
@@ -303,6 +304,33 @@ def s12_sync(env, url, token):
return FAIL, f"probe rc={rc}"
def s13_http_fallback(env, url, token):
"""docs/19: the HTTP fallback leg. The probe drives a full turn over
health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
u = urlparse(url)
scheme = "https" if u.scheme == "wss" else "http"
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
"--send", "Reply with exactly: e2e http fallback OK",
"--timeout", "120")
if rc == 0:
m = re.search(r"== user echo in ([\d.]+)s", out)
echo = float(m.group(1)) if m else None
if echo is not None and echo > 1.5:
return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)"
return PASS, ("health + POST /v1/frame + SSE turn complete"
+ (f"; user echo in {echo:.2f}s" if echo is not None else ""))
if rc == 20:
return FAIL, "health check failed (HTTP leg not running?)"
if rc == 21:
return FAIL, "SSE open failed"
if rc == 22:
return FAIL, "POST /v1/frame rejected"
return FAIL, f"probe rc={rc}"
SCENARIOS = [
(1, "pair", s1_pair),
(2, "text round-trip", s2_text),
@@ -316,6 +344,7 @@ SCENARIOS = [
(10, "media out", s10_media_out),
(11, "push", s11_push),
(12, "reconnect/sync", s12_sync),
(13, "http fallback", s13_http_fallback),
]
@@ -323,7 +352,7 @@ def main() -> int:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", DEFAULT_URL))
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
p.add_argument("--token", default="")
p.add_argument("--skip", default="",
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
@@ -331,12 +360,12 @@ def main() -> int:
token = find_token(args.token)
if not token:
print("!! ANDROID_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
print("!! IRIS_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
return 1
skip = {int(x) for x in args.skip.split(",") if x.strip()}
env = dict(os.environ)
env["ANDROID_TOKEN"] = token
env["IRIS_TOKEN"] = token
print(f"== e2e: url={args.url} token={token[:6]}…")
sweep_leftovers(env, args.url, token)
File diff suppressed because it is too large. Load diff
+826
View File
@@ -0,0 +1,826 @@
"""Tests for the iris plugin's HTTP fallback transport (docs/19).
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
from the source tree directly (same pattern as ``test_android.py``).
Coverage (docs/19 §19.12):
* auth: bad/missing token -> 401; missing device header -> 401;
allowlist rejection -> 401
* ``POST /v1/frame``: valid ``message.send`` dispatches (202 + echo on
the SSE stream); empty text -> 400 error frame; automation channel ->
400; bad JSON -> 400; wrong content-type -> 400; oversize body -> 413;
media frames -> 400 (WS-only in v1); rate limit -> 429
* SSE: catch-up rows carry correct ``id``s + cursor envelope;
``event: hello`` present; a live frame appended after connect arrives
on the stream; ``Last-Event-ID`` resume replays exactly the delta;
heartbeat observed
* long-poll: returns on new frame; empty 200 at timeout with advanced
cursor
* **delivery counting (docs/19 §19.8)**: a frame with only an SSE
subscriber is ``delivered >= 1`` -> NO push fired (the critical
regression test)
Run via ``scripts/run_tests.sh tests/gateway/test_android_http.py``.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import importlib.util
import json
import os
import sys
import time
from http.client import HTTPConnection
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
# Test-only token (not a credential; the adapter is built with it via
# monkeypatch in the fixture below).
# pi-lens-ignore: S105
TOKEN = "test-iris-http-token-0123456789"
DEVICE_ID = "test-http-device"
CHAT_ID = "default"
# 1x1 PNG (same fixture as test_android.py).
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
)
def _plugin_dir() -> Path:
env = os.environ.get("IRIS_PLUGIN_DIR")
if env:
return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
# (repo root is parents[3]).
here = Path(__file__).resolve()
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
if (candidate / "protocol.py").is_file():
return candidate
return here.parents[1]
def _load_plugin():
"""Load the gateway-plugin package under a unique module name (same
pattern as test_android.py)."""
name = "iris_plugin_http_under_test"
cached = sys.modules.get(name)
if cached is not None:
return cached
pkg_dir = _plugin_dir()
if not (pkg_dir / "__init__.py").is_file():
pytest.fail(f"iris plugin not found at {pkg_dir}")
spec = importlib.util.spec_from_file_location(
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
)
if spec is None or spec.loader is None:
pytest.fail(f"could not build import spec for {pkg_dir}")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(name, None)
raise
return module
@pytest.fixture(scope="module")
def plugin():
return _load_plugin()
@pytest.fixture
def adapter(plugin, monkeypatch):
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
from gateway.platform_registry import PlatformEntry, platform_registry
if not platform_registry.is_registered("iris"):
platform_registry.register(
PlatformEntry(
name="iris",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
)
)
config = SimpleNamespace(
extra={
"host": "127.0.0.1",
"port": 0, # ephemeral WS port
"http_port": 0, # ephemeral HTTP port
"max_upload_bytes": 1024 * 1024,
},
home_channel=None,
)
a = plugin.adapter.IrisAdapter(config)
yield a
with contextlib.suppress(Exception):
a._devices.close()
with contextlib.suppress(Exception):
a._outbox.close()
@pytest_asyncio.fixture
async def gw(adapter):
"""Connected adapter (WS + HTTP legs up); the HTTP port is ephemeral."""
await adapter.connect()
try:
yield adapter
finally:
await adapter.disconnect()
def http_port(adapter) -> int:
assert adapter._http_server.enabled, "HTTP leg should be enabled after connect()"
return adapter._http_server.bound_port
# ── Blocking HTTP helpers (run via asyncio.to_thread) ──────────────────────
def _request(
port: int,
method: str,
path: str,
*,
token: str | None = TOKEN,
device: str | None = DEVICE_ID,
body: bytes | str | None = None,
content_type: str = "application/json",
timeout: float = 10.0,
extra_headers: dict | None = None,
) -> tuple[int, bytes]:
conn = HTTPConnection("127.0.0.1", port, timeout=timeout)
headers = {}
if token is not None:
headers["Authorization"] = f"Bearer {token}"
if device is not None:
headers["X-Iris-Device"] = device
if extra_headers:
headers.update(extra_headers)
if body is not None:
data = body if isinstance(body, bytes) else body.encode("utf-8")
headers["Content-Type"] = content_type
conn.request(method, path, body=data, headers=headers)
else:
conn.request(method, path, headers=headers)
resp = conn.getresponse()
payload = resp.read()
status = resp.status
conn.close()
return status, payload
def _post_frame(port: int, frame: dict, **kw) -> tuple[int, dict]:
status, payload = _request(port, "POST", "/v1/frame", body=json.dumps(frame), **kw)
return status, json.loads(payload)
def _frame_json(frame: dict) -> dict:
return {"v": 1, **frame}
def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str]], int]:
"""Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count."""
events: list[tuple[str | None, str | None, str]] = []
comments = 0
cur_event: str | None = None
cur_id: str | None = None
cur_data: list[str] = []
for raw in lines:
line = raw.rstrip("\r\n")
if line == "":
if cur_data:
events.append((cur_event, cur_id, "\n".join(cur_data)))
cur_event, cur_id, cur_data = None, None, []
elif line.startswith(":"):
comments += 1
else:
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
cur_event = value
elif field == "id":
cur_id = value
elif field == "data":
cur_data.append(value)
return events, comments
def _sse_open(port: int, *, cursor: int | None = None, last_event_id: str | None = None):
"""Open an SSE connection (blocking); returns the HTTPResponse (read
lines via ``_sse_read_lines``; close with ``resp.close()``)."""
conn = HTTPConnection("127.0.0.1", port, timeout=30)
path = "/v1/events" + (f"?cursor={cursor}" if cursor is not None else "")
headers = {
"Authorization": f"Bearer {TOKEN}",
"X-Iris-Device": DEVICE_ID,
}
if last_event_id is not None:
headers["Last-Event-ID"] = last_event_id
conn.request("GET", path, headers=headers)
resp = conn.getresponse()
assert resp.status == 200, f"SSE open failed: {resp.status}"
assert resp.getheader("Content-Type", "").startswith("text/event-stream")
return resp
def _sse_read_lines(resp, n: int, timeout: float = 10.0) -> list[str]:
"""Read up to n lines from the SSE stream (blocking)."""
raw = resp.fp.raw
sock = getattr(raw, "_sock", None)
if sock is not None:
sock.settimeout(timeout)
lines: list[str] = []
while len(lines) < n:
line = resp.fp.readline()
if not line:
break
lines.append(line.decode("utf-8"))
return lines
# ── /v1/health ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_health_no_auth(gw):
status, payload = await asyncio.to_thread(
_request, http_port(gw), "GET", "/v1/health", token=None, device=None
)
assert status == 200
assert json.loads(payload) == {"ok": True}
@pytest.mark.asyncio
async def test_unknown_path_404(gw):
status, _ = await asyncio.to_thread(_request, http_port(gw), "GET", "/v1/nope")
assert status == 404
# ── Auth ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_post_bad_token_401(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
token="wrong-token",
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
@pytest.mark.asyncio
async def test_post_missing_token_401(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
token=None,
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
@pytest.mark.asyncio
async def test_post_missing_device_401(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
device=None,
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
@pytest.mark.asyncio
async def test_post_allowlist_rejection_401(gw):
gw.allowed_users = ["some-other-device"]
gw.allow_all = False
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
)
assert status == 401
# ── POST /v1/frame: validation ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_post_bad_json_400(gw):
status, payload = await asyncio.to_thread(
_request, http_port(gw), "POST", "/v1/frame", body=b"not json"
)
assert status == 400
frame = json.loads(payload)
assert frame["type"] == "error"
@pytest.mark.asyncio
async def test_post_wrong_content_type_400(gw):
status, _ = await asyncio.to_thread(
_request,
http_port(gw),
"POST",
"/v1/frame",
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
content_type="text/plain",
)
assert status == 400
@pytest.mark.asyncio
async def test_post_oversize_body_413(gw):
big = json.dumps(_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}}))
status, _ = await asyncio.to_thread(_request, http_port(gw), "POST", "/v1/frame", body=big)
assert status == 413
@pytest.mark.asyncio
async def test_post_empty_message_400(gw):
gw.handle_message = AsyncMock()
status, payload = await asyncio.to_thread(
_post_frame,
http_port(gw),
_frame_json(
{"id": 7, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": " "}}
),
)
assert status == 400
frame = payload
assert frame["type"] == "error"
assert frame["id"] == 7
assert frame["payload"]["code"] == "unsupported"
gw.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_post_automation_channel_400(gw):
gw.handle_message = AsyncMock()
entry = gw._channels.create(name="Cron")
gw._channels.set_automation(entry["chat_id"], True)
status, payload = await asyncio.to_thread(
_post_frame,
http_port(gw),
_frame_json(
{
"id": 8,
"type": "message.send",
"chat_id": entry["chat_id"],
"payload": {"text": "hi"},
}
),
)
assert status == 400
assert payload["type"] == "error"
gw.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_post_rate_limit_429(gw):
gw.handle_message = AsyncMock()
port = http_port(gw)
# Exhaust the per-device bucket (INBOUND_BURST = 40) then expect 429.
got_429 = False
for i in range(60):
status, _ = await asyncio.to_thread(
_post_frame,
port,
_frame_json({"id": i, "type": "ping", "payload": {}}),
)
if status == 429:
got_429 = True
break
assert got_429, "expected a 429 within 60 rapid frames"
# ── POST /v1/frame: dispatch ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_post_message_send_dispatches(gw):
"""202 ack; the user echo + read receipt arrive on the SSE stream; the
agent turn fires (docs/19 §19.7: async responses on the event stream)."""
gw.handle_message = AsyncMock()
port = http_port(gw)
conn = _sse_open(port)
try:
# Consume the open sequence (hello + status = 6 lines) first.
lines = _sse_read_lines(conn, 6, timeout=5)
events, _ = _parse_sse(lines)
assert events[0][0] == "hello"
status, payload = await asyncio.to_thread(
_post_frame,
port,
_frame_json(
{
"id": 42,
"type": "message.send",
"chat_id": CHAT_ID,
"payload": {"text": "hi there"},
}
),
)
# The read receipt (sent when the turn is handed to the agent) is
# the handler's single point-to-point reply -> 200 with the frame
# as the body (a plain 202 {"ok": true} is also valid when no
# synchronous reply exists).
assert status in (200, 202)
if status == 200:
assert payload["type"] == "read.receipt"
else:
assert payload == {"ok": True}
# The echo must arrive on the stream, tagged with its outbox
# cursor as the SSE id (live frames carry no cursor in the
# envelope, same as the WS path).
deadline = time.monotonic() + 10
echo = None
while time.monotonic() < deadline and echo is None:
lines = _sse_read_lines(conn, 4, timeout=5)
for _event, sse_id, data in _parse_sse(lines)[0]:
frame = json.loads(data)
if (
frame.get("type") == "message"
and frame.get("payload", {}).get("text") == "hi there"
):
echo = (frame, sse_id)
assert echo is not None, "user echo did not arrive on the SSE stream"
assert echo[1] is not None # SSE id = outbox cursor
await asyncio.sleep(0.2)
gw.handle_message.assert_called_once()
finally:
conn.close()
# ── SSE: catch-up, hello, live, resume, heartbeat ──────────────────────────
@pytest.mark.asyncio
async def test_sse_catchup_and_hello(gw):
"""Catch-up rows carry correct ids + cursor envelope; hello present."""
port = http_port(gw)
# Park two frames through the real outbound path (no live devices).
push_calls: list = []
gw._maybe_push = AsyncMock(side_effect=lambda *a, **k: push_calls.append(a))
for text in ("one", "two"):
await _park_frame(gw, text)
cursors = [1, 2]
conn = _sse_open(port, cursor=0)
try:
# 2 replayed frames (id + event + data + blank = 4 lines each) +
# hello (3 lines) + status (3 lines) = 14 lines.
lines = _sse_read_lines(conn, 14, timeout=5)
events, _ = _parse_sse(lines)
assert events[0][0] == "frame"
assert events[0][1] == str(cursors[0])
f0 = json.loads(events[0][2])
assert f0["payload"]["text"] == "one"
assert f0["cursor"] == cursors[0]
assert events[1][0] == "frame"
assert events[1][1] == str(cursors[1])
assert json.loads(events[1][2])["payload"]["text"] == "two"
assert events[2][0] == "hello"
hello = json.loads(events[2][2])
assert hello["type"] == "hello.ack"
assert hello["payload"]["sync_cursor"] == 2
assert events[3][0] == "frame"
assert json.loads(events[3][2])["type"] == "status"
finally:
conn.close()
@pytest.mark.asyncio
async def test_sse_live_frame_after_connect(gw):
port = http_port(gw)
conn = _sse_open(port)
try:
# Consume the open sequence (hello + status = 6 lines).
_sse_read_lines(conn, 6, timeout=5)
await _park_frame(gw, "live!")
deadline = time.monotonic() + 10
got = None
while time.monotonic() < deadline and got is None:
lines = _sse_read_lines(conn, 4, timeout=5)
for _event, sse_id, data in _parse_sse(lines)[0]:
frame = json.loads(data)
if frame.get("payload", {}).get("text") == "live!":
got = (frame, sse_id)
assert got is not None, "live frame did not arrive on the SSE stream"
assert got[1] is not None # SSE id = outbox cursor
finally:
conn.close()
@pytest.mark.asyncio
async def test_sse_last_event_id_resume(gw):
"""Resume with Last-Event-ID replays exactly the delta."""
port = http_port(gw)
for text in ("a", "b", "c"):
await _park_frame(gw, text)
conn = _sse_open(port, last_event_id="1")
try:
# Frames 2 and 3 replayed (8 lines) + hello (3) + status (3) = 14.
lines = _sse_read_lines(conn, 14, timeout=5)
events, _ = _parse_sse(lines)
replayed = [e for e in events if e[0] == "frame" and e[1] is not None]
assert [e[1] for e in replayed] == ["2", "3"]
assert json.loads(replayed[0][2])["payload"]["text"] == "b"
assert json.loads(replayed[1][2])["payload"]["text"] == "c"
finally:
conn.close()
@pytest.mark.asyncio
async def test_sse_heartbeat(gw, monkeypatch, plugin):
"""A comment heartbeat is written when the stream is idle."""
monkeypatch.setattr(plugin.http_server, "SSE_HEARTBEAT_S", 1.0)
port = http_port(gw)
conn = _sse_open(port)
try:
# Consume the open sequence (6 lines), then wait for the heartbeat.
_sse_read_lines(conn, 6, timeout=5)
lines = _sse_read_lines(conn, 2, timeout=5)
events, comments = _parse_sse(lines)
assert comments >= 1, f"no heartbeat comment in {lines!r}"
assert events == []
finally:
conn.close()
# ── Long-poll ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_poll_returns_on_new_frame(gw):
port = http_port(gw)
await _park_frame(gw, "pre") # cursor 1: returned immediately (catch-up)
# A second poll at the high-water mark blocks until a new frame lands.
def poll_and_broadcast():
status, payload = _request(port, "GET", "/v1/poll?cursor=1", timeout=30)
return status, json.loads(payload)
async def late_frame():
await asyncio.sleep(0.5)
await _park_frame(gw, "late")
poll_task = asyncio.create_task(asyncio.to_thread(poll_and_broadcast))
late_task = asyncio.create_task(late_frame())
status, body = await asyncio.wait_for(poll_task, timeout=15)
await late_task
assert status == 200
assert body["cursor"] >= 2
assert len(body["frames"]) == 1
assert json.loads(body["frames"][0])["payload"]["text"] == "late"
@pytest.mark.asyncio
async def test_poll_timeout_empty(gw, monkeypatch, plugin):
monkeypatch.setattr(plugin.http_server, "POLL_TIMEOUT_S", 1.0)
port = http_port(gw)
await _park_frame(gw, "x")
hwm = gw._outbox.latest_cursor()
status, payload = await asyncio.to_thread(
_request, port, "GET", f"/v1/poll?cursor={hwm}", timeout=15
)
body = json.loads(payload)
assert status == 200
assert body["frames"] == []
assert body["cursor"] == hwm
# ── Delivery counting (docs/19 §19.8 — the critical regression) ────────────
@pytest.mark.asyncio
async def test_sse_subscriber_counts_as_delivered_no_push(gw):
"""A frame with only an SSE subscriber is delivered >= 1 -> NO push."""
port = http_port(gw)
conn = _sse_open(port)
try:
_sse_read_lines(conn, 6, timeout=5) # open sequence
push = AsyncMock()
gw._maybe_push = push
await _park_frame(gw, "no push for me")
await asyncio.sleep(0.2)
push.assert_not_called()
finally:
conn.close()
@pytest.mark.asyncio
async def test_no_subscribers_still_pushes(gw):
"""Control: with no live devices at all, the push path still fires."""
push = AsyncMock()
gw._maybe_push = push
await _park_frame(gw, "wake me up")
await asyncio.sleep(0.2)
push.assert_called_once()
# ── Media over HTTP (docs/19 §19.15, v2) ──────────────────────────────────
def _upload(
port: int,
data: bytes,
*,
media_ref: str = "mu_http1",
kind: str = "image",
mime: str = "image/png",
filename: str = "t.png",
sha256: str | None = None,
**kw,
) -> tuple[int, dict]:
import hashlib
headers = {
"X-Iris-Media-Ref": media_ref,
"X-Iris-Media-Kind": kind,
"X-Iris-Media-Filename": filename,
"X-Iris-Media-Sha256": sha256 if sha256 is not None else hashlib.sha256(data).hexdigest(),
}
status, payload = _request(
port,
"POST",
"/v1/media",
body=data,
content_type=mime,
extra_headers=headers,
**kw,
)
return status, json.loads(payload)
@pytest.mark.asyncio
async def test_media_upload_ok(gw):
port = http_port(gw)
status, body = await asyncio.to_thread(_upload, port, PNG_1X1)
assert status == 201, body
assert body["type"] == "media.upload.ack"
assert body["payload"]["ok"] is True
assert body["payload"]["media_ref"] == "mu_http1"
entry = gw._media.get_inbound("mu_http1")
assert entry is not None
assert entry.kind == "image"
assert entry.size == len(PNG_1X1)
@pytest.mark.asyncio
async def test_media_upload_sha_mismatch(gw):
port = http_port(gw)
status, body = await asyncio.to_thread(
_upload, port, PNG_1X1, media_ref="mu_badsha", sha256="0" * 64
)
assert status == 500, body # internal: digest mismatch
assert body["type"] == "error"
assert body["payload"]["code"] == "internal"
assert gw._media.get_inbound("mu_badsha") is None
@pytest.mark.asyncio
async def test_media_upload_oversize_413(gw):
port = http_port(gw)
oversize = b"x" * (gw.max_upload_bytes + 1)
status, body = await asyncio.to_thread(_upload, port, oversize, media_ref="mu_big")
assert status == 413, body
assert body["payload"]["code"] == "media_too_large"
@pytest.mark.asyncio
async def test_media_upload_missing_ref_400(gw):
port = http_port(gw)
status, payload = await asyncio.to_thread(
_request,
port,
"POST",
"/v1/media",
body=PNG_1X1,
content_type="image/png",
extra_headers={"X-Iris-Media-Kind": "image"},
)
body = json.loads(payload)
assert status == 400
assert body["payload"]["code"] == "unsupported"
@pytest.mark.asyncio
async def test_media_upload_bad_kind_400(gw):
port = http_port(gw)
status, body = await asyncio.to_thread(_upload, port, PNG_1X1, kind="hologram")
assert status == 400
assert body["payload"]["code"] == "unsupported"
@pytest.mark.asyncio
async def test_media_upload_auth_401(gw):
port = http_port(gw)
status, _ = await asyncio.to_thread(_upload, port, PNG_1X1, token="wrong-token")
assert status == 401
@pytest.mark.asyncio
async def test_media_upload_liar_reclassified(gw):
"""Lies about being a PNG: magic-byte re-sniff keeps it out of the image
cache (lands as a document) — same contract as the WS path."""
port = http_port(gw)
payload = b"<html>not an image</html>"
status, body = await asyncio.to_thread(
_upload, port, payload, media_ref="mu_liar", filename="liar.html"
)
assert status == 201, body
entry = gw._media.get_inbound("mu_liar")
assert entry is not None
assert entry.kind == "document"
@pytest.mark.asyncio
async def test_media_pull_ok(gw):
from gateway.platforms.base import get_image_cache_dir
img = get_image_cache_dir() / "http_pull_test.png"
img.write_bytes(PNG_1X1)
entry = gw._media.register_outbound(
str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1)
)
port = http_port(gw)
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
assert status == 200
assert payload == PNG_1X1
conn = HTTPConnection("127.0.0.1", port, timeout=10)
conn.request(
"GET",
f"/v1/media/{entry.media_id}",
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
)
resp = conn.getresponse()
resp.read()
assert resp.getheader("Content-Type") == "image/png"
assert resp.getheader("Content-Length") == str(len(PNG_1X1))
conn.close()
@pytest.mark.asyncio
async def test_media_pull_unknown_404(gw):
port = http_port(gw)
status, payload = await asyncio.to_thread(_request, port, "GET", "/v1/media/md_nope")
body = json.loads(payload)
assert status == 404
assert body["payload"]["code"] == "not_found"
@pytest.mark.asyncio
async def test_media_pull_denied_path_404(gw):
"""Known id, but the path fails delivery validation (denylist) — same
re-check at pull time as the WS path."""
entry = gw._media.register_outbound("/etc/passwd", "document", "text/plain", "passwd", 100)
port = http_port(gw)
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
body = json.loads(payload)
assert status == 404
assert body["payload"]["code"] == "not_found"
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _park_frame(adapter, text: str) -> int:
"""Emit one message frame through ``_broadcast_or_log`` (the real
outbound path); returns the outbox cursor."""
plugin = _load_plugin()
frame = plugin.protocol.message(
chat_id=CHAT_ID,
message_id=f"m_{abs(hash(text)) % 10**8:08x}",
role="assistant",
text=text,
)
await adapter._broadcast_or_log(CHAT_ID, frame)
return adapter._outbox.latest_cursor()
File diff suppressed because it is too large. Load diff
-435
View File
@@ -1,435 +0,0 @@
"""WebSocket server, connection registry, and frame routing.
Runs on the gateway's asyncio loop (started in ``AndroidAdapter.connect()``).
Uses the ``websockets`` core dep (v15): ``websockets.asyncio.server.serve(
handler, host, port, ssl=ctx)``.
Per-connection handler:
1. Await first frame (bounded); 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 the device registry (SQLite) + 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. Inbound JSON
frames are rate-limited per connection (token bucket, ``INBOUND_RATE_PER_S``
/ ``INBOUND_BURST``); binary media-upload chunks are exempt.
4. On close: deregister.
Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
model). Heartbeat via WS ping/pong (websockets built-in) + app-level
``ping``/``pong`` frames.
Milestone M1.
"""
import asyncio
import contextlib
import logging
import ssl
import time
from dataclasses import dataclass, field
from typing import Any
from websockets.asyncio.server import ServerConnection, serve
from websockets.exceptions import ConnectionClosed
from . import protocol
from .pairing import DeviceRegistry, verify_token
logger = logging.getLogger(__name__)
# How long a new socket may take to present its ``hello`` before we drop it.
HELLO_TIMEOUT_S = 10.0
# Max time a single outbound send may block on a peer's full write buffer
# before we give up on that peer (so one stalled client can't starve the
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
SEND_TIMEOUT_S = 10.0
# Inbound JSON control-frame rate limit (per connection, token bucket).
# A legitimate app sends pings + occasional user-initiated requests — far
# below 20/s sustained. Binary media-upload chunks are EXEMPT (see
# ``_on_frame``): a 100 MB upload is 400 x 256 KiB frames in a tight loop
# and would exhaust any sane bucket; uploads are bounded instead by the
# per-frame ``max_size`` and the per-upload total cap (``media.py``).
INBOUND_RATE_PER_S = 20.0
INBOUND_BURST = 40
# Close codes (4000-4999 are reserved for applications).
CLOSE_AUTH_FAILED = 4401
CLOSE_REPLACED = 4402
CLOSE_RATE_LIMITED = 4403
CLOSE_SHUTDOWN = 1001
# Max length of a client-supplied device_id.
MAX_DEVICE_ID_LEN = 128
class _TokenBucket:
"""Minimal token bucket (stdlib only). One instance per connection."""
__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
@dataclass
class DeviceConnection:
"""One live, authenticated device socket."""
device_id: str
device_name: str
ws: ServerConnection
caps: dict[str, Any] = field(default_factory=dict)
fcm_token: str | None = None
ntfy_topic: str | None = None
connected_at: float = field(default_factory=time.time)
rate_bucket: _TokenBucket = field(
default_factory=lambda: _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST)
)
class WsServer:
"""The plugin's WebSocket server + live connection registry."""
def __init__(self, adapter: Any, devices: DeviceRegistry):
self._adapter = adapter
self._devices = devices
self._server: Any | None = None
self._connections: dict[str, DeviceConnection] = {}
self._lock = asyncio.Lock()
# ── Lifecycle ─────────────────────────────────────────────────────────
async def start(self) -> None:
"""Bind and start serving. Raises on bind failure (adapter maps it
to a retryable fatal error)."""
adapter = self._adapter
ssl_ctx: ssl.SSLContext | None = None
if adapter.ws_cert and adapter.ws_key:
try:
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_ctx.load_cert_chain(adapter.ws_cert, adapter.ws_key)
except Exception as e:
adapter._set_fatal_error(
"tls_config", f"WS TLS cert/key invalid: {e}", retryable=False
)
raise
try:
self._server = await serve(
self._handler,
adapter.host,
adapter.port,
ssl=ssl_ctx,
# Media uploads (M4) are chunked binary frames; allow the
# configured max upload size per frame.
max_size=adapter.max_upload_bytes,
# WS-level heartbeat: dead peers are reaped by websockets.
ping_interval=20,
ping_timeout=20,
open_timeout=10,
)
except OSError as e:
adapter._set_fatal_error(
"bind_failed",
f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
retryable=True,
)
raise
scheme = "wss" if ssl_ctx else "ws"
logger.info(
"android: WS server listening on %s://%s:%s/ws",
scheme,
adapter.host,
adapter.port,
)
async def stop(self) -> None:
"""Stop serving and close all device sockets."""
if self._server is not None:
self._server.close()
# Best-effort: the server is already closing; a failure here is
# not actionable (nothing left to clean up besides the registry).
with contextlib.suppress(Exception):
await self._server.wait_closed()
self._server = None
for conn in list(self._connections.values()):
# Best-effort: a socket that is already gone needs no handling.
with contextlib.suppress(Exception):
await conn.ws.close(code=CLOSE_SHUTDOWN, reason="gateway shutting down")
self._connections.clear()
# ── Registry ──────────────────────────────────────────────────────────
@property
def connections(self) -> dict[str, DeviceConnection]:
return dict(self._connections)
def has_devices(self) -> bool:
return bool(self._connections)
def device_ids(self) -> list:
return list(self._connections.keys())
def connection(self, device_id: str) -> DeviceConnection | None:
return self._connections.get(device_id)
# ── Outbound ──────────────────────────────────────────────────────────
async def broadcast(self, frame: protocol.Frame) -> int:
"""Send a frame to every connected device. Returns devices reached.
Best-effort: a dead or stalled socket is skipped (deregistered on its
own close) so one slow peer can't starve the others."""
data = frame.to_json()
sent = 0
for conn in list(self._connections.values()):
# Best-effort: a dead or stalled socket is skipped (it is
# deregistered on its own close); one slow peer must not starve
# the rest of the broadcast.
with contextlib.suppress(Exception):
await asyncio.wait_for(conn.ws.send(data), timeout=SEND_TIMEOUT_S)
sent += 1
return sent
async def send_to(self, device_id: str, frame: protocol.Frame) -> bool:
"""Send a frame to one device (request responses / errors)."""
conn = self._connections.get(device_id)
if conn is None:
return False
try:
await asyncio.wait_for(conn.ws.send(frame.to_json()), timeout=SEND_TIMEOUT_S)
return True
except Exception:
return False
# ── Per-connection handler ────────────────────────────────────────────
async def _handler(self, ws: ServerConnection) -> None:
# 1. hello auth -----------------------------------------------------
try:
raw = await asyncio.wait_for(ws.recv(), timeout=HELLO_TIMEOUT_S)
except (asyncio.TimeoutError, ConnectionClosed) as e:
if isinstance(e, asyncio.TimeoutError):
logger.warning("android: dropping socket with no hello (timeout)")
await self._close_quiet(ws, 1000, "no hello")
# A peer that vanished before hello needs no further handling.
return
frame = protocol.Frame.from_json(raw)
if frame is None or frame.type != protocol.TYPE_HELLO:
await self._reject(ws, "first frame must be hello")
return
payload = frame.payload
if not verify_token(payload.get("token"), self._adapter.token):
peer = getattr(ws, "remote_address", None)
logger.warning("android: hello rejected: invalid token (peer=%s)", peer)
await self._reject(ws, "invalid token")
return
device_id = str(payload.get("device_id") or "").strip()
if not device_id or len(device_id) > MAX_DEVICE_ID_LEN:
await self._reject(ws, "device_id required")
return
if (
not self._adapter.allow_all
and self._adapter.allowed_users
and device_id not in self._adapter.allowed_users
):
logger.warning("android: hello rejected: device %s not allowlisted", device_id)
await self._reject(ws, "device not allowed")
return
device_name = str(payload.get("device_name") or device_id)[:120]
caps = payload.get("caps")
if not isinstance(caps, dict):
caps = {}
fcm_token = payload.get("fcm_token")
if not isinstance(fcm_token, str):
fcm_token = None
ntfy_topic = payload.get("ntfy_topic")
if not isinstance(ntfy_topic, str):
ntfy_topic = None
# 2. register --------------------------------------------------------
try:
self._devices.upsert(device_id, device_name, caps, fcm_token, ntfy_topic)
except Exception:
logger.warning("android: device registry upsert failed", exc_info=True)
conn = DeviceConnection(
device_id=device_id,
device_name=device_name,
ws=ws,
caps=caps,
fcm_token=fcm_token,
ntfy_topic=ntfy_topic,
)
async with self._lock:
old = self._connections.pop(device_id, None)
self._connections[device_id] = conn
if old is not None:
# Same device re-paired from a new socket: the new one wins.
# Best-effort close of the superseded socket.
with contextlib.suppress(Exception):
await old.ws.close(code=CLOSE_REPLACED, reason="replaced by newer connection")
ack = protocol.hello_ack(
server_caps=self._adapter.server_caps(),
sync_cursor=self._adapter._outbox.latest_cursor(),
channels=self._adapter.channel_list(),
# M5: lets the app dedupe sync-replayed notifications that
# already woke this device via push (docs/08 §8.7).
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
)
try:
await ws.send(ack.to_json())
# M7: tell late-joining clients the current gateway health state
# (the startup broadcast only reaches clients already connected).
await ws.send(protocol.status(self._adapter.gateway_status()).to_json())
except Exception:
return
logger.info("android: device paired: %s (%s)", device_name, device_id)
# 3. frame loop ------------------------------------------------------
try:
async for raw in ws:
# ``_on_frame`` returns False once it has closed the socket
# (rate limit); stop draining the buffered frames so a
# flood doesn't re-trigger the error+close per frame.
if not await self._on_frame(ws, device_id, raw):
break
except Exception as e:
# A clean disconnect (ConnectionClosed) is the normal path and is
# not worth a warning; anything else is unexpected.
if not isinstance(e, ConnectionClosed):
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
finally:
async with self._lock:
current = self._connections.get(device_id)
if current is not None and current.ws is ws:
self._connections.pop(device_id, None)
# M4: drop in-flight upload temp files for this socket.
try:
self._adapter.on_connection_closed(device_id)
except Exception:
logger.warning(
"android: connection cleanup failed for %s", device_id, exc_info=True
)
logger.info("android: device disconnected: %s", device_id)
# ── Inbound dispatch ──────────────────────────────────────────────────
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> bool:
"""Dispatch one inbound frame. Returns False once the socket has been
closed (rate limit) so the caller stops draining buffered frames."""
# M4: binary frames are media upload chunks (raw bytes, no JSON
# envelope). Route them to the active upload session. They are
# EXEMPT from the inbound rate limit: a 100 MB upload is 400 x
# 256 KiB frames in a tight loop, which would exhaust any sane
# frame bucket. Uploads are bounded instead by the per-frame
# ``max_size`` and the per-upload total cap (``media.py``).
if isinstance(raw, (bytes, bytearray, memoryview)):
await self._adapter.on_media_chunk(device_id, bytes(raw))
return True
# Inbound rate limit (JSON control frames only). On exceed: error +
# close, same pattern as auth rejection.
conn = self._connection_for(ws)
if conn is not None and not conn.rate_bucket.consume():
logger.warning("android: inbound rate limit exceeded for %s; closing", device_id)
await self._send_quiet(
ws,
protocol.error(protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"),
)
await self._close_quiet(ws, CLOSE_RATE_LIMITED, "rate limited")
return False
frame = protocol.Frame.from_json(raw)
if frame is None:
return True # malformed JSON: ignore (forward-compat)
if frame.type == protocol.TYPE_PING:
ts = frame.payload.get("ts")
await self._send_quiet(ws, protocol.pong(ts if isinstance(ts, int) else None))
elif frame.type == protocol.TYPE_MESSAGE_SEND:
await self._adapter.on_message_send(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_CREATE:
await self._adapter.on_channel_create(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_RENAME:
await self._adapter.on_channel_rename(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
await self._adapter.on_channel_set_default(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
await self._adapter.on_channel_favorite(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_ICON:
await self._adapter.on_channel_icon(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
await self._adapter.on_channel_set_automation(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await self._adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST:
await self._adapter.on_channel_list(frame, device_id)
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
await self._adapter.on_commands_catalog(frame, device_id)
elif frame.type == protocol.TYPE_SEARCH:
await self._adapter.on_search(frame, device_id)
elif frame.type == protocol.TYPE_SYNC:
await self._adapter.on_sync(frame, device_id)
elif frame.type == protocol.TYPE_HISTORY:
await self._adapter.on_history(frame, device_id)
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
await self._adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
await self._adapter.on_media_upload_start(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
await self._adapter.on_media_upload_end(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_PULL:
await self._adapter.on_media_pull(frame, device_id)
elif frame.type == protocol.TYPE_FCM_REGISTER:
await self._adapter.on_fcm_register(frame, device_id)
# Unknown types are ignored (forward-compat).
return True
# ── Helpers ───────────────────────────────────────────────────────────
def _connection_for(self, ws: ServerConnection) -> DeviceConnection | None:
"""The live registry entry for this exact socket (identity match, so
a replaced socket never consumes the new connection's bucket)."""
for conn in self._connections.values():
if conn.ws is ws:
return conn
return None
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
# "Quiet" by contract: the caller does not care whether the peer was
# still there (e.g. an error frame right before the close).
with contextlib.suppress(Exception):
await ws.send(frame.to_json())
async def _reject(self, ws: ServerConnection, reason: str) -> None:
await self._send_quiet(ws, protocol.error(protocol.ERR_AUTH, reason))
await self._close_quiet(ws, CLOSE_AUTH_FAILED, "auth failed")
async def _close_quiet(self, ws: ServerConnection, code: int, reason: str) -> None:
# "Quiet" by contract: closing an already-closed socket is a no-op.
with contextlib.suppress(Exception):
await ws.close(code=code, reason=reason)
+4 -3
View File
@@ -16,13 +16,14 @@ if [ -e "$OUT" ]; then
exit 1
fi
# PKCS12 keystores do not support a separate key password (keytool ignores
# -keypass), so one password covers both the store and the key.
STORE_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
KEY_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
keytool -genkeypair -v \
-keystore "$OUT" -storetype PKCS12 \
-alias iris -keyalg RSA -keysize 2048 -validity 10000 \
-storepass "$STORE_PASS" -keypass "$KEY_PASS" \
-storepass "$STORE_PASS" \
-dname "CN=Iris Release, OU=Mobile, O=Iris, C=DE"
echo
@@ -35,4 +36,4 @@ echo " ANDROID_KEYSTORE_BASE64 = $(base64 -w0 "$OUT")"
echo
echo " ANDROID_KEYSTORE_PASSWORD = $STORE_PASS"
echo " ANDROID_KEY_ALIAS = iris"
echo " ANDROID_KEY_PASSWORD = $KEY_PASS"
echo " ANDROID_KEY_PASSWORD = $STORE_PASS # same: PKCS12 has no separate key password"