From 31bd4a0744fc5e96e032c121f12ca2cdc6cff848 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sun, 12 Apr 2026 19:43:58 -0700 Subject: [PATCH 1/3] Add web push --- AGENTS.md | 18 + app/AGENTS.md | 27 +- app/main.py | 10 + app/migrations/_057_web_push.py | 47 ++ app/push/__init__.py | 0 app/push/manager.py | 148 ++++ app/push/send.py | 45 ++ app/push/vapid.py | 65 ++ app/repository/push_subscriptions.py | 145 ++++ app/routers/push.py | 132 ++++ app/websocket.py | 4 + frontend/AGENTS.md | 12 + frontend/public/sw.js | 57 +- frontend/src/App.tsx | 25 + frontend/src/api.ts | 27 + frontend/src/components/ChatHeader.tsx | 39 +- frontend/src/components/ConversationPane.tsx | 12 + .../settings/SettingsLocalSection.tsx | 126 +++- frontend/src/hooks/usePushSubscription.ts | 260 +++++++ frontend/src/main.tsx | 5 + frontend/src/types.ts | 11 + pyproject.toml | 1 + uv.lock | 674 ++++++++++++++++++ 23 files changed, 1881 insertions(+), 9 deletions(-) create mode 100644 app/migrations/_057_web_push.py create mode 100644 app/push/__init__.py create mode 100644 app/push/manager.py create mode 100644 app/push/send.py create mode 100644 app/push/vapid.py create mode 100644 app/repository/push_subscriptions.py create mode 100644 app/routers/push.py create mode 100644 frontend/src/hooks/usePushSubscription.ts diff --git a/AGENTS.md b/AGENTS.md index 64659a8..b3a684a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,6 +197,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup │ ├── event_handlers.py # Radio events │ ├── decoder.py # Packet decryption │ ├── websocket.py # Real-time broadcasts +│ ├── push/ # Web Push notification subsystem (VAPID keys, dispatch, send) │ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md) ├── frontend/ # React frontend │ ├── AGENTS.md # Frontend documentation @@ -380,6 +381,12 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) | | POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts | | GET | `/api/statistics` | Aggregated mesh network statistics | +| GET | `/api/push/vapid-public-key` | VAPID public key for browser push subscription | +| POST | `/api/push/subscribe` | Register/upsert a push subscription | +| GET | `/api/push/subscriptions` | List all push subscriptions | +| PATCH | `/api/push/subscriptions/{id}` | Update subscription label or filter preferences | +| DELETE | `/api/push/subscriptions/{id}` | Delete a push subscription | +| POST | `/api/push/subscriptions/{id}/test` | Send a test push notification | | WS | `/api/ws` | Real-time updates | ## Key Concepts @@ -434,6 +441,17 @@ All external integrations are managed through the fanout bus (`app/fanout/`). Ea Community MQTT forwards raw packets only. Its derived `path` field, when present on direct packets, is a comma-separated list of hop identifiers as reported by the packet format. Token width therefore varies with the packet's path hash mode; it is intentionally not a flat per-byte rendering. +### Web Push Notifications + +Web Push is a standalone subsystem (`app/push/`) that sends browser push notifications for incoming messages even when the browser tab is closed. It is **not** a fanout module — it manages its own per-browser subscriptions with server-side filter preferences. + +- **Requires HTTPS** (self-signed certificates work) and outbound internet from the server to reach browser push services (Google FCM, Mozilla autopush). +- VAPID key pair is auto-generated on first startup and stored in `app_settings`. +- Each browser subscription is stored in `push_subscriptions` with per-conversation filter preferences (`all_messages`, `all_dms`, or `selected` conversations). +- `broadcast_event()` in `websocket.py` dispatches to `push_manager.dispatch_message()` alongside fanout for `message` events. +- Expired subscriptions (HTTP 404/410 from push service) are auto-deleted. +- Frontend: service worker (`sw.js`) handles push display and notification click navigation. The `BellRing` icon in `ChatHeader` toggles per-conversation push. Device management lives in Settings > Local. + ### Server-Side Decryption The server can decrypt packets using stored keys, both in real-time and for historical packets. diff --git a/app/AGENTS.md b/app/AGENTS.md index 6021c85..68fd09a 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -50,6 +50,10 @@ app/ ├── events.py # Typed WS event payload serialization ├── websocket.py # WS manager + broadcast helpers ├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS +├── push/ # Web Push notification subsystem +│ ├── vapid.py # VAPID key generation, storage, caching +│ ├── send.py # pywebpush wrapper (async via thread executor) +│ └── manager.py # Push dispatch: filter, build payload, concurrent send ├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md) ├── dependencies.py # Shared FastAPI dependency providers ├── path_utils.py # Path hex rendering and hop-width helpers @@ -71,6 +75,7 @@ app/ ├── fanout.py ├── repeaters.py ├── statistics.py + ├── push.py └── ws.py ``` @@ -168,6 +173,17 @@ app/ - Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes. - See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes. +### Web Push notifications + +Web Push is a standalone subsystem in `app/push/`, separate from the fanout module system. It sends browser push notifications for incoming messages even when the tab is closed. + +- **Not a fanout module** — Web Push manages per-browser subscriptions (N browsers, each with own endpoint and preferences), unlike fanout which is one-config-to-one-destination. +- **VAPID keys**: auto-generated P-256 key pair on first startup, stored in `app_settings.vapid_private_key` / `vapid_public_key`. Cached in-module by `app/push/vapid.py`. +- **Dispatch**: `broadcast_event()` in `websocket.py` fires `push_manager.dispatch_message(data)` alongside fanout for `message` events. The manager loads all subscriptions, filters each by its `filter_mode` (`all_messages`, `all_dms`, `selected`), builds a notification payload, and sends concurrently via `pywebpush` (run in thread executor). +- **Stale cleanup**: HTTP 404/410 from the push service triggers immediate subscription deletion. +- **Subscriptions stored** in `push_subscriptions` table with `UNIQUE(endpoint)` for upsert semantics. +- Requires HTTPS (self-signed OK) and outbound internet to reach browser push services. + ## API Surface (all under `/api`) ### Health @@ -258,6 +274,14 @@ app/ ### Statistics - `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels) +### Push +- `GET /push/vapid-public-key` — VAPID public key for browser `PushManager.subscribe()` +- `POST /push/subscribe` — register/upsert push subscription (keyed by endpoint URL) +- `GET /push/subscriptions` — list all push subscriptions +- `PATCH /push/subscriptions/{id}` — update label or filter preferences +- `DELETE /push/subscriptions/{id}` — delete subscription +- `POST /push/subscriptions/{id}/test` — send test notification + ### WebSocket - `WS /ws` @@ -290,7 +314,8 @@ Main tables: - `contact_name_history` (tracks name changes over time) - `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters) - `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs) -- `app_settings` +- `push_subscriptions` (Web Push browser subscriptions with per-conversation filter preferences; UNIQUE on endpoint) +- `app_settings` (includes `vapid_private_key` and `vapid_public_key` for Web Push VAPID signing) Contact route state is canonicalized on the backend: - stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*` diff --git a/app/main.py b/app/main.py index 11a6289..40b327c 100644 --- a/app/main.py +++ b/app/main.py @@ -67,6 +67,7 @@ from app.routers import ( health, messages, packets, + push, radio, read_state, repeaters, @@ -102,6 +103,14 @@ async def lifespan(app: FastAPI): await db.connect() logger.info("Database connected") + # Initialize VAPID keys for Web Push (generates on first run) + from app.push.vapid import ensure_vapid_keys + + try: + await ensure_vapid_keys() + except Exception: + logger.warning("Failed to initialize VAPID keys for Web Push", exc_info=True) + # Ensure default channels exist in the database even before the radio # connects. Without this, a fresh or disconnected instance would return # zero channels from GET /channels until the first successful radio sync. @@ -185,6 +194,7 @@ app.include_router(packets.router, prefix="/api") app.include_router(read_state.router, prefix="/api") app.include_router(settings.router, prefix="/api") app.include_router(statistics.router, prefix="/api") +app.include_router(push.router, prefix="/api") app.include_router(ws.router, prefix="/api") # Serve frontend static files in production diff --git a/app/migrations/_057_web_push.py b/app/migrations/_057_web_push.py new file mode 100644 index 0000000..72914ac --- /dev/null +++ b/app/migrations/_057_web_push.py @@ -0,0 +1,47 @@ +import logging + +import aiosqlite + +logger = logging.getLogger(__name__) + + +async def migrate(conn: aiosqlite.Connection) -> None: + """Add VAPID key columns and push_subscriptions table for Web Push.""" + + # VAPID key pair stored in app_settings (one per instance) + table_check = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'" + ) + if await table_check.fetchone(): + cursor = await conn.execute("PRAGMA table_info(app_settings)") + columns = {row[1] for row in await cursor.fetchall()} + + if "vapid_private_key" not in columns: + await conn.execute( + "ALTER TABLE app_settings ADD COLUMN vapid_private_key TEXT DEFAULT ''" + ) + if "vapid_public_key" not in columns: + await conn.execute( + "ALTER TABLE app_settings ADD COLUMN vapid_public_key TEXT DEFAULT ''" + ) + + # Push subscriptions — one row per browser + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + filter_mode TEXT NOT NULL DEFAULT 'all_messages', + filter_conversations TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + last_success_at INTEGER, + failure_count INTEGER DEFAULT 0, + UNIQUE(endpoint) + ) + """ + ) + + await conn.commit() diff --git a/app/push/__init__.py b/app/push/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/push/manager.py b/app/push/manager.py new file mode 100644 index 0000000..eb362fe --- /dev/null +++ b/app/push/manager.py @@ -0,0 +1,148 @@ +"""Web Push dispatch manager. + +Handles filtering subscriptions by their preferences and sending push +notifications concurrently when a new message arrives. +""" + +import asyncio +import json +import logging + +from pywebpush import WebPushException + +from app.push.send import send_push +from app.push.vapid import get_vapid_private_key +from app.repository.push_subscriptions import PushSubscriptionRepository + +logger = logging.getLogger(__name__) + +_SEND_TIMEOUT = 10 # seconds per push send +_VAPID_CLAIMS = {"sub": "mailto:noreply@meshcore.local"} + + +def _state_key_for_message(data: dict) -> str: + """Derive the conversation state key from a message event payload.""" + msg_type = data.get("type", "") + conversation_key = data.get("conversation_key", "") + if msg_type == "PRIV": + return f"contact-{conversation_key}" + return f"channel-{conversation_key}" + + +def _matches_filter(sub: dict, data: dict) -> bool: + """Check whether a message event matches a subscription's filter.""" + mode = sub.get("filter_mode", "all_messages") + if mode == "all_messages": + return True + if mode == "all_dms": + return data.get("type") == "PRIV" + if mode == "selected": + key = _state_key_for_message(data) + return key in (sub.get("filter_conversations") or []) + return False + + +def _build_payload(data: dict) -> str: + """Build the push notification JSON payload from a message event.""" + msg_type = data.get("type", "") + text = data.get("text", "") + sender_name = data.get("sender_name") or "" + channel_name = data.get("channel_name") or "" + + if msg_type == "PRIV": + title = f"Message from {sender_name}" if sender_name else "New direct message" + body = text + else: + # Channel messages include "SenderName: text" in the text field + title = f"#{channel_name}" if channel_name else "Channel message" + body = text + + conversation_key = data.get("conversation_key", "") + if msg_type == "PRIV": + url_hash = f"#contact/{conversation_key}" + else: + url_hash = f"#channel/{conversation_key}" + + return json.dumps( + { + "title": title, + "body": body, + "tag": f"meshcore-{data.get('id', '')}", + "url_hash": url_hash, + } + ) + + +def _subscription_info(sub: dict) -> dict: + """Build the subscription_info dict that pywebpush expects.""" + return { + "endpoint": sub["endpoint"], + "keys": { + "p256dh": sub["p256dh"], + "auth": sub["auth"], + }, + } + + +class PushManager: + async def dispatch_message(self, data: dict) -> None: + """Send push notifications for a message event to matching subscriptions.""" + # Don't notify for messages the operator just sent themselves + if data.get("outgoing"): + return + + try: + subs = await PushSubscriptionRepository.get_all() + except Exception: + logger.debug("Push dispatch: failed to load subscriptions", exc_info=True) + return + + if not subs: + return + + matching = [s for s in subs if _matches_filter(s, data)] + if not matching: + return + + payload = _build_payload(data) + vapid_key = get_vapid_private_key() + if not vapid_key: + logger.debug("Push dispatch: no VAPID key configured, skipping") + return + + tasks = [self._send_one(sub, payload, vapid_key) for sub in matching] + await asyncio.gather(*tasks, return_exceptions=True) + + async def _send_one(self, sub: dict, payload: str, vapid_key: str) -> None: + sub_id = sub["id"] + try: + async with asyncio.timeout(_SEND_TIMEOUT): + await send_push( + subscription_info=_subscription_info(sub), + payload=payload, + vapid_private_key=vapid_key, + vapid_claims=_VAPID_CLAIMS, + ) + await PushSubscriptionRepository.record_success(sub_id) + except WebPushException as e: + status = getattr(e, "response", None) + status_code = getattr(status, "status_code", 0) if status else 0 + if status_code in (404, 410): + logger.info( + "Push subscription expired (HTTP %d), removing %s", + status_code, + sub_id, + ) + await PushSubscriptionRepository.delete(sub_id) + else: + logger.warning("Push send failed for %s: %s", sub_id, e) + await PushSubscriptionRepository.record_failure(sub_id) + except TimeoutError: + logger.warning("Push send timed out for %s", sub_id) + await PushSubscriptionRepository.record_failure(sub_id) + except Exception: + logger.debug("Push send error for %s", sub_id, exc_info=True) + await PushSubscriptionRepository.record_failure(sub_id) + + +push_manager = PushManager() diff --git a/app/push/send.py b/app/push/send.py new file mode 100644 index 0000000..843884e --- /dev/null +++ b/app/push/send.py @@ -0,0 +1,45 @@ +"""Thin wrapper around pywebpush for sending push notifications. + +Isolates the pywebpush dependency and runs the synchronous send in +a thread executor to avoid blocking the event loop. +""" + +import asyncio +import logging + +from pywebpush import webpush + +logger = logging.getLogger(__name__) + + +async def send_push( + subscription_info: dict, + payload: str, + vapid_private_key: str, + vapid_claims: dict, +) -> int: + """Send an encrypted push notification. + + Args: + subscription_info: {"endpoint": ..., "keys": {"p256dh": ..., "auth": ...}} + payload: JSON string to encrypt and send + vapid_private_key: PEM-encoded VAPID private key + vapid_claims: {"sub": "mailto:..."} or {"sub": "https://..."} + + Returns: + HTTP status code from the push service. + + Raises: + WebPushException: on push service error (caller handles 404/410 cleanup). + """ + loop = asyncio.get_running_loop() + response = await loop.run_in_executor( + None, + lambda: webpush( + subscription_info=subscription_info, + data=payload, + vapid_private_key=vapid_private_key, + vapid_claims=vapid_claims, + ), + ) + return response.status_code # type: ignore[union-attr] diff --git a/app/push/vapid.py b/app/push/vapid.py new file mode 100644 index 0000000..706778f --- /dev/null +++ b/app/push/vapid.py @@ -0,0 +1,65 @@ +"""VAPID key management for Web Push. + +Generates a P-256 key pair on first use and caches it in app_settings. +The public key is served to browsers for PushManager.subscribe(). +""" + +import base64 +import logging + +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from py_vapid import Vapid + +from app.database import db + +logger = logging.getLogger(__name__) + +_cached_private_key: str = "" +_cached_public_key: str = "" + + +async def ensure_vapid_keys() -> tuple[str, str]: + """Read or generate VAPID keys. Call once at startup after DB connect.""" + global _cached_private_key, _cached_public_key + + cursor = await db.conn.execute( + "SELECT vapid_private_key, vapid_public_key FROM app_settings WHERE id = 1" + ) + row = await cursor.fetchone() + + if row and row["vapid_private_key"] and row["vapid_public_key"]: + _cached_private_key = row["vapid_private_key"] + _cached_public_key = row["vapid_public_key"] + logger.info("VAPID keys loaded from database") + return _cached_private_key, _cached_public_key + + # Generate new key pair + vapid = Vapid() + vapid.generate_keys() + + # Private key as PEM for pywebpush + _cached_private_key = vapid.private_pem().decode("utf-8") + + # Public key as uncompressed P-256 point, base64url-encoded (no padding) + # for the browser Push API's applicationServerKey + raw_pub = vapid.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) # type: ignore[union-attr] + _cached_public_key = base64.urlsafe_b64encode(raw_pub).rstrip(b"=").decode("ascii") + + await db.conn.execute( + "UPDATE app_settings SET vapid_private_key = ?, vapid_public_key = ? WHERE id = 1", + (_cached_private_key, _cached_public_key), + ) + await db.conn.commit() + logger.info("Generated and stored new VAPID key pair") + + return _cached_private_key, _cached_public_key + + +def get_vapid_public_key() -> str: + """Return the cached VAPID public key (base64url). Must call ensure_vapid_keys() first.""" + return _cached_public_key + + +def get_vapid_private_key() -> str: + """Return the cached VAPID private key (PEM). Must call ensure_vapid_keys() first.""" + return _cached_private_key diff --git a/app/repository/push_subscriptions.py b/app/repository/push_subscriptions.py new file mode 100644 index 0000000..104e178 --- /dev/null +++ b/app/repository/push_subscriptions.py @@ -0,0 +1,145 @@ +"""Repository for push_subscriptions table.""" + +import json +import logging +import time +import uuid +from typing import Any + +from app.database import db + +logger = logging.getLogger(__name__) + + +def _row_to_dict(row: Any) -> dict[str, Any]: + result = { + "id": row["id"], + "endpoint": row["endpoint"], + "p256dh": row["p256dh"], + "auth": row["auth"], + "label": row["label"] or "", + "filter_mode": row["filter_mode"] or "all_messages", + "filter_conversations": json.loads(row["filter_conversations"]) + if row["filter_conversations"] + else [], + "created_at": row["created_at"] or 0, + "last_success_at": row["last_success_at"], + "failure_count": row["failure_count"] or 0, + } + return result + + +class PushSubscriptionRepository: + @staticmethod + async def create( + endpoint: str, + p256dh: str, + auth: str, + label: str = "", + filter_mode: str = "all_messages", + filter_conversations: list[str] | None = None, + ) -> dict[str, Any]: + """Create or upsert a push subscription (keyed by endpoint).""" + sub_id = str(uuid.uuid4()) + now = int(time.time()) + convos_json = json.dumps(filter_conversations or []) + + # Upsert: if endpoint already exists, update keys/label but keep the ID + await db.conn.execute( + """ + INSERT INTO push_subscriptions + (id, endpoint, p256dh, auth, label, filter_mode, + filter_conversations, created_at, failure_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(endpoint) DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth, + label = CASE WHEN excluded.label != '' THEN excluded.label ELSE push_subscriptions.label END, + failure_count = 0 + """, + (sub_id, endpoint, p256dh, auth, label, filter_mode, convos_json, now), + ) + await db.conn.commit() + + # Return the actual row (may be existing on upsert) + return await PushSubscriptionRepository.get_by_endpoint(endpoint) # type: ignore[return-value] + + @staticmethod + async def get(subscription_id: str) -> dict[str, Any] | None: + cursor = await db.conn.execute( + "SELECT * FROM push_subscriptions WHERE id = ?", (subscription_id,) + ) + row = await cursor.fetchone() + return _row_to_dict(row) if row else None + + @staticmethod + async def get_by_endpoint(endpoint: str) -> dict[str, Any] | None: + cursor = await db.conn.execute( + "SELECT * FROM push_subscriptions WHERE endpoint = ?", (endpoint,) + ) + row = await cursor.fetchone() + return _row_to_dict(row) if row else None + + @staticmethod + async def get_all() -> list[dict[str, Any]]: + cursor = await db.conn.execute("SELECT * FROM push_subscriptions ORDER BY created_at DESC") + rows = await cursor.fetchall() + return [_row_to_dict(row) for row in rows] + + @staticmethod + async def update(subscription_id: str, **fields: Any) -> dict[str, Any] | None: + updates: list[str] = [] + params: list[Any] = [] + + for key in ("label", "filter_mode"): + if key in fields: + updates.append(f"{key} = ?") + params.append(fields[key]) + + if "filter_conversations" in fields: + updates.append("filter_conversations = ?") + params.append(json.dumps(fields["filter_conversations"])) + + if not updates: + return await PushSubscriptionRepository.get(subscription_id) + + params.append(subscription_id) + await db.conn.execute( + f"UPDATE push_subscriptions SET {', '.join(updates)} WHERE id = ?", + params, + ) + await db.conn.commit() + return await PushSubscriptionRepository.get(subscription_id) + + @staticmethod + async def delete(subscription_id: str) -> bool: + cursor = await db.conn.execute( + "DELETE FROM push_subscriptions WHERE id = ?", (subscription_id,) + ) + await db.conn.commit() + return cursor.rowcount > 0 + + @staticmethod + async def delete_by_endpoint(endpoint: str) -> bool: + cursor = await db.conn.execute( + "DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,) + ) + await db.conn.commit() + return cursor.rowcount > 0 + + @staticmethod + async def record_success(subscription_id: str) -> None: + now = int(time.time()) + await db.conn.execute( + "UPDATE push_subscriptions SET last_success_at = ?, failure_count = 0 WHERE id = ?", + (now, subscription_id), + ) + await db.conn.commit() + + @staticmethod + async def record_failure(subscription_id: str) -> None: + await db.conn.execute( + "UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ?", + (subscription_id,), + ) + await db.conn.commit() diff --git a/app/routers/push.py b/app/routers/push.py new file mode 100644 index 0000000..8e8299b --- /dev/null +++ b/app/routers/push.py @@ -0,0 +1,132 @@ +"""Web Push subscription management endpoints.""" + +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.push.send import send_push +from app.push.vapid import get_vapid_private_key, get_vapid_public_key +from app.repository.push_subscriptions import PushSubscriptionRepository + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/push", tags=["push"]) + + +# ── Request/response models ───────────────────────────────────────────── + + +class VapidPublicKeyResponse(BaseModel): + public_key: str + + +class PushSubscribeRequest(BaseModel): + endpoint: str = Field(min_length=1) + p256dh: str = Field(min_length=1) + auth: str = Field(min_length=1) + label: str = "" + + +class PushSubscriptionUpdate(BaseModel): + label: str | None = None + filter_mode: str | None = None + filter_conversations: list[str] | None = None + + +# ── Endpoints ──────────────────────────────────────────────────────────── + + +@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse) +async def vapid_public_key() -> VapidPublicKeyResponse: + """Return the VAPID public key for browser PushManager.subscribe().""" + key = get_vapid_public_key() + if not key: + raise HTTPException(status_code=503, detail="VAPID keys not initialized") + return VapidPublicKeyResponse(public_key=key) + + +@router.post("/subscribe") +async def subscribe(body: PushSubscribeRequest) -> dict: + """Register or update a push subscription. Upserts by endpoint.""" + sub = await PushSubscriptionRepository.create( + endpoint=body.endpoint, + p256dh=body.p256dh, + auth=body.auth, + label=body.label, + ) + return sub + + +@router.get("/subscriptions") +async def list_subscriptions() -> list[dict]: + """List all push subscriptions.""" + return await PushSubscriptionRepository.get_all() + + +@router.patch("/subscriptions/{subscription_id}") +async def update_subscription(subscription_id: str, body: PushSubscriptionUpdate) -> dict: + """Update a subscription's label or filter preferences.""" + existing = await PushSubscriptionRepository.get(subscription_id) + if not existing: + raise HTTPException(status_code=404, detail="Subscription not found") + + updates = {} + if body.label is not None: + updates["label"] = body.label + if body.filter_mode is not None: + if body.filter_mode not in ("all_messages", "all_dms", "selected"): + raise HTTPException(status_code=400, detail="Invalid filter_mode") + updates["filter_mode"] = body.filter_mode + if body.filter_conversations is not None: + updates["filter_conversations"] = body.filter_conversations + + result = await PushSubscriptionRepository.update(subscription_id, **updates) + return result or existing + + +@router.delete("/subscriptions/{subscription_id}") +async def unsubscribe(subscription_id: str) -> dict: + """Delete a push subscription.""" + deleted = await PushSubscriptionRepository.delete(subscription_id) + if not deleted: + raise HTTPException(status_code=404, detail="Subscription not found") + return {"deleted": True} + + +@router.post("/subscriptions/{subscription_id}/test") +async def test_push(subscription_id: str) -> dict: + """Send a test notification to a subscription.""" + sub = await PushSubscriptionRepository.get(subscription_id) + if not sub: + raise HTTPException(status_code=404, detail="Subscription not found") + + vapid_key = get_vapid_private_key() + if not vapid_key: + raise HTTPException(status_code=503, detail="VAPID keys not initialized") + + import json + + payload = json.dumps( + { + "title": "RemoteTerm Test", + "body": "Push notifications are working!", + "tag": "meshcore-test", + "url_hash": "", + } + ) + + try: + await send_push( + subscription_info={ + "endpoint": sub["endpoint"], + "keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]}, + }, + payload=payload, + vapid_private_key=vapid_key, + vapid_claims={"sub": "mailto:noreply@meshcore.local"}, + ) + return {"status": "sent"} + except Exception as e: + logger.warning("Test push failed: %s", e) + raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None diff --git a/app/websocket.py b/app/websocket.py index ba08694..1c54235 100644 --- a/app/websocket.py +++ b/app/websocket.py @@ -108,6 +108,10 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No if event_type == "message": asyncio.create_task(fanout_manager.broadcast_message(data)) + + from app.push.manager import push_manager + + asyncio.create_task(push_manager.dispatch_message(data)) elif event_type == "raw_packet": asyncio.create_task(fanout_manager.broadcast_raw(data)) elif event_type == "contact": diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 32ac676..084f51d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -57,6 +57,7 @@ frontend/src/ │ ├── useConversationRouter.ts # URL hash → active conversation routing │ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion │ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch +│ ├── usePushSubscription.ts # Web Push subscription lifecycle, per-conversation filters │ ├── useFaviconBadge.ts # Browser tab unread badge state │ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history │ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence @@ -429,6 +430,17 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear - **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling. - **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page). +## Web Push Notifications + +Web Push allows notifications even when the browser tab is closed. Requires HTTPS (self-signed OK). + +- **Service worker**: `frontend/public/sw.js` handles `push` events (show notification) and `notificationclick` (focus/open tab, navigate via `url_hash`). Registered in `main.tsx` on secure contexts only. +- **`usePushSubscription` hook**: manages the full subscription lifecycle — subscribe (register SW → `PushManager.subscribe()` → POST to backend), unsubscribe, per-conversation filter management (`addConversation`/`removeConversation`), device listing and deletion. +- **ChatHeader integration**: `BellRing` icon (amber when active) appears next to the existing desktop notification `Bell` on secure contexts. First click subscribes the browser and enables push for that conversation; subsequent clicks toggle the conversation on/off. +- **Settings > Local**: `PushDeviceManagement` component shows subscription status, lists all registered devices with test/delete buttons. Uses `usePushSubscription` hook directly. +- Auto-generates device labels from User-Agent (e.g., "Chrome on macOS"). +- `PushSubscriptionInfo` type in `types.ts`; API methods in `api.ts`. + ## Styling UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`. diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 61901c7..a5aa56b 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,12 +1,59 @@ -// Minimal service worker required for PWA installability. -// No caching — this app is network-dependent. All fetches pass through. +/* Service worker for PWA installability and Web Push notifications. */ -self.addEventListener("install", function () { +self.addEventListener("install", () => { self.skipWaiting(); }); -self.addEventListener("activate", function (event) { +self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); -self.addEventListener("fetch", function () {}); +// No-op fetch handler — required for PWA installability criteria. +// We don't cache anything; the app always fetches from the network. +self.addEventListener("fetch", () => {}); + +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = { title: "New message", body: event.data?.text() || "" }; + } + + const title = data.title || "New message"; + const options = { + body: data.body || "", + icon: "./favicon-256x256.png", + badge: "./favicon-96x96.png", + tag: data.tag || "meshcore-push", + data: { url_hash: data.url_hash || "" }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const urlHash = event.notification.data?.url_hash || ""; + + event.waitUntil( + clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((windowClients) => { + // Focus an existing tab if one is open + for (const client of windowClients) { + if (client.url.includes(self.location.origin)) { + client.focus(); + if (urlHash) { + client.navigate(self.location.origin + "/" + urlHash); + } + return; + } + } + // Otherwise open a new tab + return clients.openWindow( + self.location.origin + "/" + (urlHash || "") + ); + }) + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e277a7..b8e9f2b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { toast } from './components/ui/sonner'; import { AppShell } from './components/AppShell'; import type { MessageInputHandle } from './components/MessageInput'; import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; +import { usePushSubscription } from './hooks/usePushSubscription'; import { messageContainsMention } from './utils/messageParser'; import { getStateKey } from './utils/conversationState'; import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types'; @@ -99,6 +100,7 @@ export function App() { toggleConversationNotifications, notifyIncomingMessage, } = useBrowserNotifications(); + const pushSubscription = usePushSubscription(); const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession(); const { showNewMessage, @@ -615,6 +617,29 @@ export function App() { ); } }, + pushSupported: pushSubscription.isSupported, + pushSubscribed: pushSubscription.isSubscribed, + pushEnabledForConversation: + activeConversation?.type === 'contact' || activeConversation?.type === 'channel' + ? pushSubscription.isConversationPushEnabled( + getStateKey(activeConversation.type, activeConversation.id) + ) + : false, + onTogglePush: () => { + if ( + !activeConversation || + (activeConversation.type !== 'contact' && activeConversation.type !== 'channel') + ) + return; + const key = getStateKey(activeConversation.type, activeConversation.id); + if (!pushSubscription.isSubscribed) { + void pushSubscription.subscribe(key); + } else if (pushSubscription.isConversationPushEnabled(key)) { + void pushSubscription.removeConversation(key); + } else { + void pushSubscription.addConversation(key); + } + }, trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [], onToggleTrackedTelemetry: handleToggleTrackedTelemetry, repeaterAutoLoginKey, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b9322e8..32c5889 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -22,6 +22,7 @@ import type { RadioTraceResponse, RadioDiscoveryTarget, PathDiscoveryResponse, + PushSubscriptionInfo, ResendChannelMessageResponse, RepeaterAclResponse, RepeaterAdvertIntervalsResponse, @@ -441,4 +442,30 @@ export const api = { fetchJson(`/contacts/${publicKey}/room/lpp-telemetry`, { method: 'POST', }), + + // Push Notifications + getVapidPublicKey: () => fetchJson<{ public_key: string }>('/push/vapid-public-key'), + pushSubscribe: (subscription: { + endpoint: string; + p256dh: string; + auth: string; + label?: string; + }) => + fetchJson('/push/subscribe', { + method: 'POST', + body: JSON.stringify(subscription), + }), + getPushSubscriptions: () => fetchJson('/push/subscriptions'), + updatePushSubscription: ( + id: string, + update: { label?: string; filter_mode?: string; filter_conversations?: string[] } + ) => + fetchJson(`/push/subscriptions/${id}`, { + method: 'PATCH', + body: JSON.stringify(update), + }), + deletePushSubscription: (id: string) => + fetchJson<{ deleted: boolean }>(`/push/subscriptions/${id}`, { method: 'DELETE' }), + testPushSubscription: (id: string) => + fetchJson<{ status: string }>(`/push/subscriptions/${id}/test`, { method: 'POST' }), }; diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 3d86a3a..d47ef2f 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; +import { Bell, BellRing, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; import { toast } from './ui/sonner'; import { DirectTraceIcon } from './DirectTraceIcon'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; @@ -26,6 +26,10 @@ interface ChatHeaderProps { onTrace: () => void; onPathDiscovery: (publicKey: string) => Promise; onToggleNotifications: () => void; + pushSupported?: boolean; + pushSubscribed?: boolean; + pushEnabledForConversation?: boolean; + onTogglePush?: () => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void; onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void; @@ -46,6 +50,10 @@ export function ChatHeader({ onTrace, onPathDiscovery, onToggleNotifications, + pushSupported, + pushSubscribed, + pushEnabledForConversation, + onTogglePush, onToggleFavorite, onSetChannelFloodScopeOverride, onSetChannelPathHashModeOverride, @@ -317,6 +325,35 @@ export function ChatHeader({ )} )} + {pushSupported && !activeContactIsRoomServer && onTogglePush && ( + + )} {conversation.type === 'channel' && onSetChannelFloodScopeOverride && ( + ) : ( + + )} + + {allSubscriptions.length > 0 && ( +
+ + {expanded && ( +
+ {allSubscriptions.map((sub) => ( +
+
+ {sub.label || 'Unknown device'} + + {sub.last_success_at + ? `Last push: ${new Date(sub.last_success_at * 1000).toLocaleDateString()}` + : 'Never pushed'} + {sub.failure_count > 0 && ` · ${sub.failure_count} failures`} + +
+
+ + +
+
+ ))} +
+ )} +
+ )} + + ); +} + export function SettingsLocalSection({ onLocalLabelChange, className, @@ -398,6 +516,10 @@ function ThemePreview({ className }: { className?: string }) { + + + + {/* ── Style Reference (collapsible) ── */}