Default push backend to ntfy; FCM opt-in with privacy warning (issue #10)
CI / Gateway plugin tests (push) Successful in 5m3s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m6s

- IRIS_PUSH_BACKEND now defaults to ntfy (keeps push metadata on your own
  infrastructure); FCM is opt-in via IRIS_PUSH_BACKEND=fcm
- build_push_backend(): ntfy for empty/unknown names, FCM only on explicit 'fcm'
- gateway setup: warn when FCM is chosen (metadata routed via Google's servers)
- README: privacy note + dedicated push section; new docs/playstore-listing.md
  with the FCM/ntfy privacy note for the Play Store listing
- docs: 00/02/03/08/12/16 + setup.md updated to ntfy-default wording
- tests: default-backend assertion updated (86/86 pass)
This commit is contained in:
ARIA committed 2026-08-24 19:04:40 +02:00
1 parent 70282dfb65
commit 746d809d48
14 files changed
+133 -53

No files matched your search

+19 -2
View File
@@ -8,6 +8,8 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app - **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
- **Absolute Privacy!** — everything stays on your own infrastructure - **Absolute Privacy!** — everything stays on your own infrastructure
(push: ntfy by default; FCM is opt-in and routes push metadata via Google —
see [Push notifications](#push-notifications))
- **No file limit** - **No file limit**
- **No character limit** - **No character limit**
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting… - **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
@@ -36,7 +38,22 @@ hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (And
- The app is a first-class hermes *messaging platform*, so everything the gateway - The app is a first-class hermes *messaging platform*, so everything the gateway
already does just works: slash commands, cron delivery, `send_message` routing, already does just works: slash commands, cron delivery, `send_message` routing,
coexistence with Telegram/Discord/etc. coexistence with Telegram/Discord/etc.
- Push notifications: FCM (primary) or ntfy (fallback). - Push notifications: ntfy (default) or FCM (opt-in).
## Push notifications
Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost.
- **ntfy (default)** — push metadata stays on your own infrastructure
(self-hosted ntfy recommended). This is the backend for truly private
communication.
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
metadata (notification title, device token) is routed through **Google's
servers**. If you want truly private communication, use ntfy instead.
Setup: [`docs/setup.md`](docs/setup.md) §4; details:
[`docs/08-push.md`](docs/08-push.md).
## Build from source ## Build from source
@@ -99,7 +116,7 @@ Notes:
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS - Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`). (`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
Full walkthrough, push setup (FCM/ntfy), and troubleshooting: Full walkthrough, push setup (ntfy/FCM), and troubleshooting:
[`docs/setup.md`](docs/setup.md). [`docs/setup.md`](docs/setup.md).
## Contributing ## Contributing
+1 -1
View File
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
| Decision | Choice | | Decision | Choice |
|---|---| |---|---|
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) | | Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
| Push backend | **Both** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) | | Push backend | **Both** — ntfy default, FCM optional (`IRIS_PUSH_BACKEND`) |
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) | | 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) | | Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
+2 -2
View File
@@ -61,8 +61,8 @@ iris_x_hermes/
- **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound - **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound
`media.offer`/`media.pull` chunked streaming. `media.offer`/`media.pull` chunked streaming.
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor. - **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and - **`push.py`** — `PushBackend` interface; `NtfyBackend` (default) and
`NtfyBackend` (reuses hermes ntfy publish). Selected by `IRIS_PUSH_BACKEND`. `FcmBackend` (httpx, FCM HTTP v1). Selected by `IRIS_PUSH_BACKEND`.
- **`pairing.py`** — token generation/verification (constant-time), device - **`pairing.py`** — token generation/verification (constant-time), device
registry (SQLite), QR payload. registry (SQLite), QR payload.
- **`search.py`** — FTS5 query bridge over the hermes session store. - **`search.py`** — FTS5 query bridge over the hermes session store.
+1 -1
View File
@@ -48,7 +48,7 @@ optional_env:
prompt: "Allow all devices? (true/false)" prompt: "Allow all devices? (true/false)"
password: false password: false
- name: IRIS_PUSH_BACKEND - name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy" description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
prompt: "Push backend" prompt: "Push backend"
password: false password: false
- name: IRIS_FCM_SERVICE_ACCOUNT - name: IRIS_FCM_SERVICE_ACCOUNT
+7 -4
View File
@@ -1,7 +1,10 @@
# 08 — Push Notifications, Outbox & Sync # 08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`). relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
Privacy: FCM push metadata (notification title, device token) is routed
through Google's servers — for truly private communication use ntfy
(self-hosted), which keeps everything on your own infrastructure.
## 8.1 When push fires ## 8.1 When push fires
@@ -54,9 +57,9 @@ class PushBackend(Protocol):
def configured(self) -> bool: ... def configured(self) -> bool: ...
``` ```
Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`). Selected at adapter init by `IRIS_PUSH_BACKEND` (`ntfy` default, `fcm`).
### 8.2.1 `FcmBackend` (primary) ### 8.2.1 `FcmBackend` (optional; metadata via Google)
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service - **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
@@ -74,7 +77,7 @@ Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few - Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
devices). devices).
### 8.2.2 `NtfyBackend` (fallback, self-host friendly) ### 8.2.2 `NtfyBackend` (default, self-host friendly)
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter). - 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 - Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
+4 -3
View File
@@ -77,15 +77,16 @@ hermes --version # sanity
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and 4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
registers it via `hello` / `fcm.register`. registers it via `hello` / `fcm.register`.
> Skip Firebase → set `IRIS_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` / > Skip Firebase → the default is already ntfy: leave `IRIS_PUSH_BACKEND` unset
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`. > (or set it to `ntfy`) and configure `NTFY_TOPIC` / `NTFY_SERVER_URL`
> (self-host ntfy or use ntfy.sh). See `08-push.md`.
## 12.6 Environment variables (summary) ## 12.6 Environment variables (summary)
**Secrets (`~/.hermes/.env`):** **Secrets (`~/.hermes/.env`):**
``` ```
IRIS_TOKEN=<64-hex> IRIS_TOKEN=<64-hex>
IRIS_PUSH_BACKEND=fcm # or ntfy IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google)
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account # IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
# NTFY_TOPIC=iris-push # when ntfy # NTFY_TOPIC=iris-push # when ntfy
+1 -1
View File
@@ -5,7 +5,7 @@
| # | Decision | Choice | Rationale | | # | Decision | Choice | Rationale |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. | | 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. `IRIS_PUSH_BACKEND`. | | 2 | Push backend | **Both — ntfy default, FCM optional** (issue #10: FCM metadata — title, device token — is routed via Google's servers, contradicting the privacy claim; ntfy keeps it on your own infrastructure) | `IRIS_PUSH_BACKEND`. |
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. | | 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. | | 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
+2 -1
View File
@@ -30,7 +30,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
| 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. | | 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. |
| 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. | | 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. |
| 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. | | 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. |
| 8 | [`08-push.md`](08-push.md) | Push (FCM + ntfy), outbox, sync. | | 8 | [`08-push.md`](08-push.md) | Push (ntfy default + FCM optional), outbox, sync. |
| 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. | | 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. |
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Android app. | | 10 | [`10-android-app.md`](10-android-app.md) | When building the Android app. |
| 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. | | 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. |
@@ -47,6 +47,7 @@ Machine-readable / diagrams:
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema. - [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture. - [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
- [`playstore-listing.md`](playstore-listing.md) — Play Store listing text (incl. the FCM/ntfy privacy note).
--- ---
+40
View File
@@ -0,0 +1,40 @@
# Play Store Listing
Copy-paste text for the Play Console (and the F-Droid / sideload pages).
Keep this file in sync with the README whenever the privacy story changes.
## Short description (≤ 80 chars)
Private chat for your personal AI agent — Android & desktop.
## Full description
Iris is a native chat app for your personal AI agent (hermes-agent):
streaming replies, visible reasoning, structured tool activity, channels,
threads, media, search — Telegram-quality, on your own infrastructure.
**Absolute Privacy!** — everything stays on your own infrastructure:
- Your gateway, your machine, your data. No cloud middleman for chat.
- **Push notifications:** ntfy by default — push metadata stays on your own
(self-hosted) ntfy server.
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
push metadata (notification title, device token) is routed through
**Google's servers**. If you want truly private communication, use ntfy
(self-hosted) instead — it is the default.
No file limit. No character limit. Full markdown + HTML artifact preview.
All settings live in the app.
Runs on Android and desktop (Linux, macOS, Windows) from one shared codebase.
## Privacy note (for the "Data safety" section / FAQ)
Iris talks directly to your own hermes gateway over a private, token-authenticated
connection. By default, push notifications use ntfy, which you can self-host so
that push metadata never leaves your infrastructure. If you explicitly enable
FCM, push metadata (notification title, device token) is sent via Google's FCM
servers; chat content itself is not sent to Google — FCM only carries a short
preview, and full content is fetched from your gateway over the authenticated
connection. For truly private communication, use the default ntfy backend
(self-hosted).
+23 -19
View File
@@ -39,7 +39,8 @@ What it does:
- Generates `IRIS_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). `~/.hermes/.env` (it prints the token once, at generation).
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`), - Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
and push backend (`fcm` or `ntfy`, default `fcm`). and push backend (`ntfy` or `fcm`, default `ntfy`); warns when `fcm` is
chosen (push metadata via Google's servers).
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…` - Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
string), a scannable QR of that payload, and the server URL string), a scannable QR of that payload, and the server URL
(`ws://<host>:8790/ws`). (`ws://<host>:8790/ws`).
@@ -100,26 +101,10 @@ Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost. Push fires when the device is offline, plus for outbox, so nothing is lost. Push fires when the device is offline, plus for
high-priority events (approvals, clarifies, cron) even when a device is live. high-priority events (approvals, clarifies, cron) even when a device is live.
### FCM (default; needs a Firebase project) ### ntfy (default; zero-config)
1. Create a Firebase project (console.firebase.google.com) and add an Android
app with the app's applicationId; download `google-services.json` into
`app/androidApp/`.
2. Create a service account (Project settings → Service accounts → Generate new
private key) and store the JSON path in `~/.hermes/.env`:
`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.
**What you see:** system notifications for new messages when the app is
backgrounded; tapping one deep-links to the chat.
### ntfy (zero-config fallback)
``` ```
IRIS_PUSH_BACKEND=ntfy IRIS_PUSH_BACKEND=ntfy # the default — can be left unset
``` ```
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed); - The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
@@ -128,10 +113,29 @@ IRIS_PUSH_BACKEND=ntfy
recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its
web UI instead of the stream), while a self-hosted instance gives reliable web UI instead of the stream), while a self-hosted instance gives reliable
SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`. SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`.
- **Privacy:** ntfy keeps push metadata (title, topic) on your own
infrastructure — this is the backend for truly private communication.
**What you see:** a low-priority foreground "ntfy listener" notification while **What you see:** a low-priority foreground "ntfy listener" notification while
the app is off; incoming pushes trigger a silent sync. the app is off; incoming pushes trigger a silent sync.
### FCM (opt-in; needs a Firebase project)
> **Privacy note:** FCM push metadata (notification title, device token) is
> routed through **Google's servers**. If you want truly private
> communication, use ntfy (self-hosted) instead — it is the default.
1. Create a Firebase project (console.firebase.google.com) and add an Android
app with the app's applicationId; download `google-services.json` into
`app/androidApp/`.
2. Create a service account (Project settings → Service accounts → Generate new
private key) and store the JSON path in `~/.hermes/.env`:
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Set `IRIS_PUSH_BACKEND=fcm`.
Without a Firebase project the FCM path is **inert** (the app's FCM service
does nothing) — use ntfy (the default), or add Firebase later.
## 5. Remote access ## 5. Remote access
- **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP; - **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP;
+13 -6
View File
@@ -33,8 +33,8 @@ register the (delivery-validated) file in the media registry and emit
``validate_media_delivery_path`` at pull time. ``validate_media_delivery_path`` at pull time.
Milestone M5: push + offline. Frames with no live subscriber are parked in Milestone M5: push + offline. Frames with no live subscriber are parked in
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM the outbox (M3) AND wake the device via the push backend (``push.py``: ntfy
HTTP v1 primary, ntfy fallback, selected by ``IRIS_PUSH_BACKEND``). default, FCM HTTP v1 as an option, selected by ``IRIS_PUSH_BACKEND``).
``notification`` frames render in-app banners and mirror to push (channel ``notification`` frames render in-app banners and mirror to push (channel
events, cron deliveries, approvals, clarifies); high-priority kinds push even events, cron deliveries, approvals, clarifies); high-priority kinds push even
when a device is live. ``fcm.register`` rotates push tokens (registry + live when a device is live. ``fcm.register`` rotates push tokens (registry + live
@@ -526,7 +526,7 @@ DEFAULT_PORT = 8790
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
DEFAULT_HOME_CHANNEL = "default" DEFAULT_HOME_CHANNEL = "default"
DEFAULT_HOME_CHANNEL_NAME = "Default" DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "fcm" DEFAULT_PUSH_BACKEND = "ntfy"
DEFAULT_OUTBOX_RETENTION_HOURS = 72 DEFAULT_OUTBOX_RETENTION_HOURS = 72
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
@@ -1201,10 +1201,17 @@ def interactive_setup() -> None:
) )
save_env_value("IRIS_HTTP_PORT", str(_parse_port(port))) save_env_value("IRIS_HTTP_PORT", str(_parse_port(port)))
backend = prompt( backend = prompt(
"Push backend (fcm/ntfy)", "Push backend (ntfy/fcm)",
default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND, default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
) )
save_env_value("IRIS_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower()) backend = (backend or DEFAULT_PUSH_BACKEND).strip().lower()
save_env_value("IRIS_PUSH_BACKEND", backend)
if backend == "fcm":
print_warning(
"FCM push metadata (notification title, device token) is routed "
"through Google's servers. For truly private communication use "
"ntfy (self-hosted) instead."
)
# Pairing payload for the app's Connect screen (manual entry + QR scan). # Pairing payload for the app's Connect screen (manual entry + QR scan).
# Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is # Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
@@ -1346,7 +1353,7 @@ class IrisAdapter(BasePlatformAdapter):
# /fast): picker_id -> pending state. In-memory only — a gateway # /fast): picker_id -> pending state. In-memory only — a gateway
# restart expires them (a stale picker.select is a no-op). # restart expires them (a stale picker.select is a no-op).
self._pending_pickers: dict[str, dict] = {} self._pending_pickers: dict[str, dict] = {}
# M5: push backend (FCM primary, ntfy fallback) + the throttle for # M5: push backend (ntfy default, FCM optional) + the throttle for
# the outbox-prune banner. # the outbox-prune banner.
self._push: PushBackend = build_push_backend( self._push: PushBackend = build_push_backend(
self.push_backend, self.push_backend,
+1 -1
View File
@@ -38,7 +38,7 @@ optional_env:
prompt: "Allow all devices? (true/false)" prompt: "Allow all devices? (true/false)"
password: false password: false
- name: IRIS_PUSH_BACKEND - name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy" description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
prompt: "Push backend" prompt: "Push backend"
password: false password: false
- name: IRIS_FCM_SERVICE_ACCOUNT - name: IRIS_FCM_SERVICE_ACCOUNT
+18 -11
View File
@@ -1,4 +1,4 @@
"""Push backends: FCM (primary) + ntfy (fallback). """Push backends: ntfy (default) + FCM (optional).
``PushBackend`` interface with two implementations: ``PushBackend`` interface with two implementations:
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account - ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
@@ -8,7 +8,7 @@
(default ``https://ntfy.sh``) via ``httpx``; the app's listener (default ``https://ntfy.sh``) via ``httpx``; the app's listener
subscribes to the topic. subscribes to the topic.
Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback). Selected by ``IRIS_PUSH_BACKEND`` (``ntfy`` default, ``fcm`` optional).
Fired when a frame has no live subscriber; the data payload drives a silent Fired when a frame has no live subscriber; the data payload drives a silent
sync on the device (docs/08-push.md). sync on the device (docs/08-push.md).
@@ -33,7 +33,9 @@ import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging" FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token" # Not a secret: the well-known Google OAuth2 token endpoint.
# pi-lens-ignore: S105
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token" # noqa: S105
FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send" FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send" FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send"
# Refresh the cached access token this long before its expiry. # Refresh the cached access token this long before its expiry.
@@ -54,7 +56,7 @@ class PushBackend:
name: str = "push" name: str = "push"
# DeviceRegistry column that carries this backend's target token. # DeviceRegistry column that carries this backend's target token.
# Not a secret: a DB column name (string literal), not a credential. # Not a secret: a DB column name (string literal), not a credential.
# pi-lens-ignore: python-hardcoded-secrets # pi-lens-ignore: S105, python-hardcoded-secrets
token_field: str = "" token_field: str = ""
def configured(self) -> bool: def configured(self) -> bool:
@@ -87,8 +89,8 @@ class FcmBackend(PushBackend):
name = "fcm" name = "fcm"
# Not a secret: a DB column name (string literal), not a credential. # Not a secret: a DB column name (string literal), not a credential.
# pi-lens-ignore: python-hardcoded-secrets # pi-lens-ignore: S105
token_field = "fcm_token" token_field = "fcm_token" # noqa: S105
def __init__( def __init__(
self, self,
@@ -257,8 +259,8 @@ class NtfyBackend(PushBackend):
name = "ntfy" name = "ntfy"
# Not a secret: a DB column name (string literal), not a credential. # Not a secret: a DB column name (string literal), not a credential.
# pi-lens-ignore: python-hardcoded-secrets # pi-lens-ignore: S105
token_field = "ntfy_topic" token_field = "ntfy_topic" # noqa: S105
def __init__( def __init__(
self, self,
@@ -332,9 +334,14 @@ def build_push_backend(
ntfy_server_url: str | None = None, ntfy_server_url: str | None = None,
ntfy_auth_token: str | None = None, ntfy_auth_token: str | None = None,
) -> PushBackend: ) -> PushBackend:
"""Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default).""" """Select the backend by name (``IRIS_PUSH_BACKEND``; ntfy default).
if (name or "").strip().lower() == "ntfy":
ntfy is the default: it keeps push metadata on your own infrastructure.
FCM is opt-in (``IRIS_PUSH_BACKEND=fcm``) — its metadata (title, device
token) is routed through Google's servers.
"""
if (name or "").strip().lower() == "fcm":
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
return NtfyBackend( return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
) )
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
+1 -1
View File
@@ -1404,7 +1404,7 @@ def test_push_backend_selection(plugin):
push = plugin.push push = plugin.push
assert isinstance(push.build_push_backend("fcm"), push.FcmBackend) assert isinstance(push.build_push_backend("fcm"), push.FcmBackend)
assert isinstance(push.build_push_backend("ntfy"), push.NtfyBackend) assert isinstance(push.build_push_backend("ntfy"), push.NtfyBackend)
assert isinstance(push.build_push_backend(None), push.FcmBackend) # default assert isinstance(push.build_push_backend(None), push.NtfyBackend) # default
assert isinstance(push.build_push_backend(" NTFY "), push.NtfyBackend) assert isinstance(push.build_push_backend(" NTFY "), push.NtfyBackend)
assert push.build_push_backend("ntfy", ntfy_topic="my-topic").configured() is True assert push.build_push_backend("ntfy", ntfy_topic="my-topic").configured() is True