mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
Pass 2
This commit is contained in:
@@ -443,11 +443,11 @@ Community MQTT forwards raw packets only. Its derived `path` field, when present
|
||||
|
||||
### 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.
|
||||
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, while the set of push-enabled conversations is stored once per server instance.
|
||||
|
||||
- **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).
|
||||
- Each browser subscription is stored in `push_subscriptions` with device identity and delivery state. The set of push-enabled conversations is stored globally in `app_settings.push_conversations`, so all subscribed browsers receive the same configured rooms/DMs.
|
||||
- `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.
|
||||
|
||||
@@ -177,9 +177,9 @@ app/
|
||||
|
||||
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.
|
||||
- **Not a fanout module** — Web Push manages per-browser subscriptions (N browsers, each with its own endpoint and delivery state), 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).
|
||||
- **Dispatch**: `broadcast_event()` in `websocket.py` fires `push_manager.dispatch_message(data)` alongside fanout for `message` events. The manager checks the global `app_settings.push_conversations` list, then sends to all currently registered subscriptions via `pywebpush` (run in a 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.
|
||||
@@ -314,7 +314,7 @@ 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)
|
||||
- `push_subscriptions` (Web Push browser subscriptions with per-conversation filter preferences; UNIQUE on endpoint)
|
||||
- `push_subscriptions` (Web Push browser subscriptions with delivery metadata; 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:
|
||||
|
||||
@@ -6,9 +6,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def migrate(conn: aiosqlite.Connection) -> None:
|
||||
"""Add VAPID key columns and push_subscriptions table for Web Push."""
|
||||
"""Add Web Push support: VAPID keys, push subscriptions table, and global conversation list."""
|
||||
|
||||
# VAPID key pair stored in app_settings (one per instance)
|
||||
# VAPID key pair + global push conversation list in app_settings
|
||||
table_check = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
@@ -24,8 +24,12 @@ async def migrate(conn: aiosqlite.Connection) -> None:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN vapid_public_key TEXT DEFAULT ''"
|
||||
)
|
||||
if "push_conversations" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN push_conversations TEXT DEFAULT '[]'"
|
||||
)
|
||||
|
||||
# Push subscriptions — one row per browser
|
||||
# Push subscriptions — one row per browser/device
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
@@ -34,8 +38,6 @@ async def migrate(conn: aiosqlite.Connection) -> None:
|
||||
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,
|
||||
@@ -1,22 +1,25 @@
|
||||
"""Web Push dispatch manager.
|
||||
|
||||
Handles filtering subscriptions by their preferences and sending push
|
||||
notifications concurrently when a new message arrives.
|
||||
Checks the global push-enabled conversation list (stored in app_settings)
|
||||
and sends push notifications to ALL registered devices when a matching
|
||||
incoming message arrives.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
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
|
||||
from app.repository.settings import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEND_TIMEOUT = 10 # seconds per push send
|
||||
_SEND_TIMEOUT = 15 # seconds per push send
|
||||
_VAPID_CLAIMS = {"sub": "mailto:noreply@meshcore.local"}
|
||||
|
||||
|
||||
@@ -29,19 +32,6 @@ def _state_key_for_message(data: dict) -> str:
|
||||
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", "")
|
||||
@@ -53,11 +43,11 @@ def _build_payload(data: dict) -> str:
|
||||
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"
|
||||
title = channel_name if channel_name else "Channel message"
|
||||
body = text
|
||||
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
state_key = _state_key_for_message(data)
|
||||
if msg_type == "PRIV":
|
||||
url_hash = f"#contact/{conversation_key}"
|
||||
else:
|
||||
@@ -67,7 +57,10 @@ def _build_payload(data: dict) -> str:
|
||||
{
|
||||
"title": title,
|
||||
"body": body,
|
||||
"tag": f"meshcore-{data.get('id', '')}",
|
||||
# Tag per conversation so different conversations coexist in the
|
||||
# notification tray, while repeated messages in the same
|
||||
# conversation replace each other.
|
||||
"tag": f"meshcore-{state_key}",
|
||||
"url_hash": url_hash,
|
||||
}
|
||||
)
|
||||
@@ -84,13 +77,31 @@ def _subscription_info(sub: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SendResult:
|
||||
sub_id: str
|
||||
success: bool = False
|
||||
expired: bool = False
|
||||
|
||||
|
||||
class PushManager:
|
||||
async def dispatch_message(self, data: dict) -> None:
|
||||
"""Send push notifications for a message event to matching subscriptions."""
|
||||
"""Send push notifications for a message event to all devices."""
|
||||
# Don't notify for messages the operator just sent themselves
|
||||
if data.get("outgoing"):
|
||||
return
|
||||
|
||||
# Check the global conversation list
|
||||
state_key = _state_key_for_message(data)
|
||||
try:
|
||||
push_conversations = await AppSettingsRepository.get_push_conversations()
|
||||
except Exception:
|
||||
logger.debug("Push dispatch: failed to load push_conversations", exc_info=True)
|
||||
return
|
||||
|
||||
if state_key not in push_conversations:
|
||||
return
|
||||
|
||||
try:
|
||||
subs = await PushSubscriptionRepository.get_all()
|
||||
except Exception:
|
||||
@@ -100,21 +111,40 @@ class PushManager:
|
||||
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)
|
||||
results = await asyncio.gather(
|
||||
*(self._send_one(sub, payload, vapid_key) for sub in subs),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _send_one(self, sub: dict, payload: str, vapid_key: str) -> None:
|
||||
# Batch-update all delivery outcomes in one transaction.
|
||||
success_ids: list[str] = []
|
||||
failure_ids: list[str] = []
|
||||
remove_ids: list[str] = []
|
||||
for r in results:
|
||||
if isinstance(r, _SendResult):
|
||||
if r.expired:
|
||||
remove_ids.append(r.sub_id)
|
||||
elif r.success:
|
||||
success_ids.append(r.sub_id)
|
||||
else:
|
||||
failure_ids.append(r.sub_id)
|
||||
if success_ids or failure_ids or remove_ids:
|
||||
try:
|
||||
await PushSubscriptionRepository.batch_record_outcomes(
|
||||
success_ids, failure_ids, remove_ids
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Push dispatch: failed to record outcomes", exc_info=True)
|
||||
|
||||
async def _send_one(self, sub: dict, payload: str, vapid_key: str) -> _SendResult:
|
||||
sub_id = sub["id"]
|
||||
result = _SendResult(sub_id=sub_id)
|
||||
try:
|
||||
async with asyncio.timeout(_SEND_TIMEOUT):
|
||||
await send_push(
|
||||
@@ -123,26 +153,20 @@ class PushManager:
|
||||
vapid_private_key=vapid_key,
|
||||
vapid_claims=_VAPID_CLAIMS,
|
||||
)
|
||||
await PushSubscriptionRepository.record_success(sub_id)
|
||||
result.success = True
|
||||
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)
|
||||
if status_code in (403, 404, 410):
|
||||
logger.info("Push subscription expired (HTTP %d), removing %s", status_code, sub_id)
|
||||
result.expired = True
|
||||
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)
|
||||
return result
|
||||
|
||||
|
||||
push_manager = PushManager()
|
||||
|
||||
202
app/push/send.py
202
app/push/send.py
@@ -6,11 +6,201 @@ a thread executor to avoid blocking the event loop.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any, cast
|
||||
|
||||
import requests
|
||||
import urllib3.connection
|
||||
import urllib3.connectionpool
|
||||
from pywebpush import webpush
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
from requests.exceptions import ConnectTimeout as RequestsConnectTimeout
|
||||
from urllib3.exceptions import ConnectTimeoutError, NameResolutionError, NewConnectionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TIMEOUT = object()
|
||||
DEFAULT_PUSH_CONNECT_TIMEOUT_SECONDS = 3
|
||||
IPV4_FALLBACK_CONNECT_TIMEOUT_SECONDS = 10
|
||||
DEFAULT_PUSH_READ_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def _create_ipv4_connection(
|
||||
address: tuple[str, int],
|
||||
timeout: float | None | object = DEFAULT_TIMEOUT,
|
||||
source_address: tuple[str, int] | None = None,
|
||||
socket_options=None,
|
||||
) -> socket.socket:
|
||||
"""Create a socket connection using IPv4 only."""
|
||||
host, port = address
|
||||
if host.startswith("["):
|
||||
host = host.strip("[]")
|
||||
|
||||
err: OSError | None = None
|
||||
for res in socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM):
|
||||
af, socktype, proto, _, sa = res
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
if socket_options:
|
||||
for opt in socket_options:
|
||||
sock.setsockopt(*opt)
|
||||
if timeout is not DEFAULT_TIMEOUT:
|
||||
sock.settimeout(cast(float | None, timeout))
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sa)
|
||||
return sock
|
||||
except OSError as exc:
|
||||
err = exc
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
|
||||
if err is not None:
|
||||
raise err
|
||||
raise OSError("getaddrinfo returns an empty list")
|
||||
|
||||
|
||||
class IPv4HTTPConnection(urllib3.connection.HTTPConnection):
|
||||
"""urllib3 HTTP connection that resolves and connects via IPv4 only."""
|
||||
|
||||
def _new_conn(self) -> socket.socket:
|
||||
try:
|
||||
return _create_ipv4_connection(
|
||||
(self._dns_host, self.port),
|
||||
self.timeout,
|
||||
source_address=self.source_address,
|
||||
socket_options=self.socket_options,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise NameResolutionError(self.host, self, exc) from exc
|
||||
except TimeoutError as exc:
|
||||
raise ConnectTimeoutError(
|
||||
self,
|
||||
f"Connection to {self.host} timed out. (connect timeout={self.timeout})",
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise NewConnectionError(self, f"Failed to establish a new connection: {exc}") from exc
|
||||
|
||||
|
||||
class IPv4HTTPSConnection(urllib3.connection.HTTPSConnection):
|
||||
"""urllib3 HTTPS connection that resolves and connects via IPv4 only."""
|
||||
|
||||
def _new_conn(self) -> socket.socket:
|
||||
try:
|
||||
return _create_ipv4_connection(
|
||||
(self._dns_host, self.port),
|
||||
self.timeout,
|
||||
source_address=self.source_address,
|
||||
socket_options=self.socket_options,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise NameResolutionError(self.host, self, exc) from exc
|
||||
except TimeoutError as exc:
|
||||
raise ConnectTimeoutError(
|
||||
self,
|
||||
f"Connection to {self.host} timed out. (connect timeout={self.timeout})",
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise NewConnectionError(self, f"Failed to establish a new connection: {exc}") from exc
|
||||
|
||||
|
||||
class IPv4HTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
|
||||
ConnectionCls = cast(Any, IPv4HTTPConnection)
|
||||
|
||||
|
||||
class IPv4HTTPSConnectionPool(urllib3.connectionpool.HTTPSConnectionPool):
|
||||
ConnectionCls = cast(Any, IPv4HTTPSConnection)
|
||||
|
||||
|
||||
def _configure_pool_manager_for_ipv4(manager: Any) -> None:
|
||||
manager.pool_classes_by_scheme = manager.pool_classes_by_scheme.copy()
|
||||
manager.pool_classes_by_scheme["http"] = IPv4HTTPConnectionPool
|
||||
manager.pool_classes_by_scheme["https"] = IPv4HTTPSConnectionPool
|
||||
|
||||
|
||||
class IPv4HTTPAdapter(HTTPAdapter):
|
||||
"""requests adapter that uses IPv4-only urllib3 connection pools."""
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
||||
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
|
||||
_configure_pool_manager_for_ipv4(self.poolmanager)
|
||||
|
||||
def proxy_manager_for(self, *args, **kwargs):
|
||||
manager = super().proxy_manager_for(*args, **kwargs)
|
||||
_configure_pool_manager_for_ipv4(manager)
|
||||
return manager
|
||||
|
||||
|
||||
def _build_default_requests_session() -> requests.Session:
|
||||
return requests.Session()
|
||||
|
||||
|
||||
def _build_ipv4_requests_session() -> requests.Session:
|
||||
session = requests.Session()
|
||||
adapter = IPv4HTTPAdapter()
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
return session
|
||||
|
||||
|
||||
def _send_push_with_session(
|
||||
*,
|
||||
subscription_info: dict,
|
||||
payload: str,
|
||||
vapid_private_key: str,
|
||||
vapid_claims: dict,
|
||||
session: requests.Session,
|
||||
connect_timeout_seconds: int,
|
||||
) -> int:
|
||||
response = webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=payload,
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims=vapid_claims,
|
||||
content_encoding="aes128gcm",
|
||||
timeout=cast(Any, (connect_timeout_seconds, DEFAULT_PUSH_READ_TIMEOUT_SECONDS)),
|
||||
requests_session=session,
|
||||
)
|
||||
return response.status_code # type: ignore[union-attr]
|
||||
|
||||
|
||||
def _send_push_with_fallback(
|
||||
subscription_info: dict,
|
||||
payload: str,
|
||||
vapid_private_key: str,
|
||||
vapid_claims: dict,
|
||||
) -> int:
|
||||
"""Send using normal dual-stack resolution, then retry with IPv4-only on connect failures."""
|
||||
session = _build_default_requests_session()
|
||||
try:
|
||||
return _send_push_with_session(
|
||||
subscription_info=subscription_info,
|
||||
payload=payload,
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims=vapid_claims,
|
||||
session=session,
|
||||
connect_timeout_seconds=DEFAULT_PUSH_CONNECT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (RequestsConnectTimeout, RequestsConnectionError) as exc:
|
||||
logger.info("Push delivery retrying via IPv4 after initial network failure: %s", exc)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
session = _build_ipv4_requests_session()
|
||||
try:
|
||||
return _send_push_with_session(
|
||||
subscription_info=subscription_info,
|
||||
payload=payload,
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims=vapid_claims,
|
||||
session=session,
|
||||
connect_timeout_seconds=IPV4_FALLBACK_CONNECT_TIMEOUT_SECONDS,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
async def send_push(
|
||||
subscription_info: dict,
|
||||
@@ -23,7 +213,7 @@ async def send_push(
|
||||
Args:
|
||||
subscription_info: {"endpoint": ..., "keys": {"p256dh": ..., "auth": ...}}
|
||||
payload: JSON string to encrypt and send
|
||||
vapid_private_key: PEM-encoded VAPID private key
|
||||
vapid_private_key: base64url-encoded raw EC private key scalar
|
||||
vapid_claims: {"sub": "mailto:..."} or {"sub": "https://..."}
|
||||
|
||||
Returns:
|
||||
@@ -33,13 +223,9 @@ async def send_push(
|
||||
WebPushException: on push service error (caller handles 404/410 cleanup).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=payload,
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims=vapid_claims,
|
||||
lambda: _send_push_with_fallback(
|
||||
subscription_info, payload, vapid_private_key, vapid_claims
|
||||
),
|
||||
)
|
||||
return response.status_code # type: ignore[union-attr]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""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().
|
||||
Generates a P-256 key pair on first use and caches it in app_settings
|
||||
via ``AppSettingsRepository``. The public key is served to browsers
|
||||
for ``PushManager.subscribe()``.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -10,7 +11,7 @@ import logging
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from py_vapid import Vapid
|
||||
|
||||
from app.database import db
|
||||
from app.repository.settings import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,14 +23,10 @@ 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"]
|
||||
private, public = await AppSettingsRepository.get_vapid_keys()
|
||||
if private and public:
|
||||
_cached_private_key = private
|
||||
_cached_public_key = public
|
||||
logger.info("VAPID keys loaded from database")
|
||||
return _cached_private_key, _cached_public_key
|
||||
|
||||
@@ -37,19 +34,17 @@ async def ensure_vapid_keys() -> tuple[str, str]:
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
|
||||
# Private key as PEM for pywebpush
|
||||
_cached_private_key = vapid.private_pem().decode("utf-8")
|
||||
# Private key as base64url-encoded raw 32-byte EC scalar — the format
|
||||
# that pywebpush passes to ``Vapid.from_string()``.
|
||||
raw_priv = vapid.private_key.private_numbers().private_value.to_bytes(32, "big") # type: ignore[union-attr]
|
||||
_cached_private_key = base64.urlsafe_b64encode(raw_priv).rstrip(b"=").decode("ascii")
|
||||
|
||||
# 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()
|
||||
await AppSettingsRepository.set_vapid_keys(_cached_private_key, _cached_public_key)
|
||||
logger.info("Generated and stored new VAPID key pair")
|
||||
|
||||
return _cached_private_key, _cached_public_key
|
||||
@@ -61,5 +56,5 @@ def get_vapid_public_key() -> str:
|
||||
|
||||
|
||||
def get_vapid_private_key() -> str:
|
||||
"""Return the cached VAPID private key (PEM). Must call ensure_vapid_keys() first."""
|
||||
"""Return the cached VAPID private key (base64url). Must call ensure_vapid_keys() first."""
|
||||
return _cached_private_key
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Repository for push_subscriptions table."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
@@ -10,23 +9,22 @@ from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Auto-delete subscriptions that have failed this many times consecutively
|
||||
# without any successful delivery in between.
|
||||
MAX_CONSECUTIVE_FAILURES = 15
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
result = {
|
||||
return {
|
||||
"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:
|
||||
@@ -36,54 +34,58 @@ class PushSubscriptionRepository:
|
||||
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()
|
||||
async with db.tx() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO push_subscriptions
|
||||
(id, endpoint, p256dh, auth, label, 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, now),
|
||||
)
|
||||
async with conn.execute(
|
||||
"SELECT * FROM push_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
# Return the actual row (may be existing on upsert)
|
||||
return await PushSubscriptionRepository.get_by_endpoint(endpoint) # type: ignore[return-value]
|
||||
return _row_to_dict(row) if row else {"id": sub_id} # type: ignore[arg-type]
|
||||
|
||||
@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()
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT * FROM push_subscriptions WHERE id = ?", (subscription_id,)
|
||||
) as cursor:
|
||||
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()
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT * FROM push_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
) as cursor:
|
||||
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()
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT * FROM push_subscriptions ORDER BY created_at DESC"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
@@ -91,55 +93,70 @@ class PushSubscriptionRepository:
|
||||
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 "label" in fields:
|
||||
updates.append("label = ?")
|
||||
params.append(fields["label"])
|
||||
|
||||
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)
|
||||
async with db.tx() as conn:
|
||||
await conn.execute(
|
||||
f"UPDATE push_subscriptions SET {', '.join(updates)} WHERE id = ?",
|
||||
params,
|
||||
)
|
||||
async with conn.execute(
|
||||
"SELECT * FROM push_subscriptions WHERE id = ?", (subscription_id,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
@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
|
||||
async with db.tx() as conn:
|
||||
async with conn.execute(
|
||||
"DELETE FROM push_subscriptions WHERE id = ?", (subscription_id,)
|
||||
) as cursor:
|
||||
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
|
||||
async with db.tx() as conn:
|
||||
async with conn.execute(
|
||||
"DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,)
|
||||
) as cursor:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def record_success(subscription_id: str) -> None:
|
||||
async def batch_record_outcomes(
|
||||
success_ids: list[str], failure_ids: list[str], remove_ids: list[str]
|
||||
) -> None:
|
||||
"""Batch-update delivery outcomes in a single transaction."""
|
||||
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()
|
||||
async with db.tx() as conn:
|
||||
if remove_ids:
|
||||
placeholders = ",".join("?" for _ in remove_ids)
|
||||
await conn.execute(
|
||||
f"DELETE FROM push_subscriptions WHERE id IN ({placeholders})",
|
||||
remove_ids,
|
||||
)
|
||||
if success_ids:
|
||||
placeholders = ",".join("?" for _ in success_ids)
|
||||
await conn.execute(
|
||||
f"UPDATE push_subscriptions SET last_success_at = ?, failure_count = 0 "
|
||||
f"WHERE id IN ({placeholders})",
|
||||
[now, *success_ids],
|
||||
)
|
||||
if failure_ids:
|
||||
placeholders = ",".join("?" for _ in failure_ids)
|
||||
await conn.execute(
|
||||
f"UPDATE push_subscriptions SET failure_count = failure_count + 1 "
|
||||
f"WHERE id IN ({placeholders})",
|
||||
failure_ids,
|
||||
)
|
||||
# Evict subscriptions that have exceeded the failure threshold
|
||||
await conn.execute(
|
||||
"DELETE FROM push_subscriptions WHERE failure_count >= ?",
|
||||
(MAX_CONSECUTIVE_FAILURES,),
|
||||
)
|
||||
|
||||
@@ -282,6 +282,85 @@ class AppSettingsRepository:
|
||||
await AppSettingsRepository._apply_updates(conn, blocked_names=new_names)
|
||||
return await AppSettingsRepository._get_in_conn(conn)
|
||||
|
||||
@staticmethod
|
||||
async def get_vapid_keys() -> tuple[str, str]:
|
||||
"""Return (private_key_pem, public_key_b64url) from app_settings.
|
||||
|
||||
These are internal-only columns not exposed via the AppSettings model.
|
||||
"""
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT vapid_private_key, vapid_public_key FROM app_settings WHERE id = 1"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row and row["vapid_private_key"] and row["vapid_public_key"]:
|
||||
return row["vapid_private_key"], row["vapid_public_key"]
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
async def set_vapid_keys(private_key: str, public_key: str) -> None:
|
||||
"""Persist auto-generated VAPID key pair to app_settings."""
|
||||
async with db.tx() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE app_settings SET vapid_private_key = ?, vapid_public_key = ? WHERE id = 1",
|
||||
(private_key, public_key),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_push_conversations() -> list[str]:
|
||||
"""Return the global list of push-enabled conversation state keys.
|
||||
|
||||
Internal-only column, not exposed via the AppSettings model.
|
||||
"""
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT push_conversations FROM app_settings WHERE id = 1"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row and row["push_conversations"]:
|
||||
try:
|
||||
return json.loads(row["push_conversations"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def set_push_conversations(conversations: list[str]) -> list[str]:
|
||||
"""Replace the global push-enabled conversation list."""
|
||||
async with db.tx() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE app_settings SET push_conversations = ? WHERE id = 1",
|
||||
(json.dumps(conversations),),
|
||||
)
|
||||
return conversations
|
||||
|
||||
@staticmethod
|
||||
async def toggle_push_conversation(key: str) -> list[str]:
|
||||
"""Add or remove a conversation state key from the global push list.
|
||||
|
||||
Atomic read-modify-write under a single ``db.tx()`` lock.
|
||||
"""
|
||||
async with db.tx() as conn:
|
||||
async with conn.execute(
|
||||
"SELECT push_conversations FROM app_settings WHERE id = 1"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
current: list[str] = []
|
||||
if row and row["push_conversations"]:
|
||||
try:
|
||||
current = json.loads(row["push_conversations"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current = []
|
||||
if key in current:
|
||||
current = [k for k in current if k != key]
|
||||
else:
|
||||
current.append(key)
|
||||
await conn.execute(
|
||||
"UPDATE app_settings SET push_conversations = ? WHERE id = 1",
|
||||
(json.dumps(current),),
|
||||
)
|
||||
return current
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Web Push subscription management endpoints."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from pywebpush import WebPushException
|
||||
|
||||
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
|
||||
from app.repository.settings import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,11 +34,13 @@ class PushSubscribeRequest(BaseModel):
|
||||
|
||||
class PushSubscriptionUpdate(BaseModel):
|
||||
label: str | None = None
|
||||
filter_mode: str | None = None
|
||||
filter_conversations: list[str] | None = None
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
class PushConversationToggle(BaseModel):
|
||||
key: str = Field(min_length=1)
|
||||
|
||||
|
||||
# ─<><E29480><EFBFBD> Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
|
||||
@@ -48,7 +54,7 @@ async def vapid_public_key() -> VapidPublicKeyResponse:
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe(body: PushSubscribeRequest) -> dict:
|
||||
"""Register or update a push subscription. Upserts by endpoint."""
|
||||
"""Register or update a push subscription (device). Upserts by endpoint."""
|
||||
sub = await PushSubscriptionRepository.create(
|
||||
endpoint=body.endpoint,
|
||||
p256dh=body.p256dh,
|
||||
@@ -60,13 +66,13 @@ async def subscribe(body: PushSubscribeRequest) -> dict:
|
||||
|
||||
@router.get("/subscriptions")
|
||||
async def list_subscriptions() -> list[dict]:
|
||||
"""List all push subscriptions."""
|
||||
"""List all push subscriptions (devices)."""
|
||||
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."""
|
||||
"""Update a subscription's label."""
|
||||
existing = await PushSubscriptionRepository.get(subscription_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
@@ -74,12 +80,6 @@ async def update_subscription(subscription_id: str, body: PushSubscriptionUpdate
|
||||
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
|
||||
@@ -87,7 +87,7 @@ async def update_subscription(subscription_id: str, body: PushSubscriptionUpdate
|
||||
|
||||
@router.delete("/subscriptions/{subscription_id}")
|
||||
async def unsubscribe(subscription_id: str) -> dict:
|
||||
"""Delete a push subscription."""
|
||||
"""Delete a push subscription (device)."""
|
||||
deleted = await PushSubscriptionRepository.delete(subscription_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
@@ -105,8 +105,6 @@ async def test_push(subscription_id: str) -> dict:
|
||||
if not vapid_key:
|
||||
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
|
||||
|
||||
import json
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"title": "RemoteTerm Test",
|
||||
@@ -117,16 +115,50 @@ async def test_push(subscription_id: str) -> dict:
|
||||
)
|
||||
|
||||
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"},
|
||||
)
|
||||
async with asyncio.timeout(15):
|
||||
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 TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="Push delivery timed out") from None
|
||||
except WebPushException as e:
|
||||
status_code = getattr(getattr(e, "response", None), "status_code", 0)
|
||||
if status_code in (403, 404, 410):
|
||||
logger.info(
|
||||
"Test push: subscription stale (HTTP %d), removing %s",
|
||||
status_code,
|
||||
subscription_id,
|
||||
)
|
||||
await PushSubscriptionRepository.delete(subscription_id)
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="Subscription is stale (VAPID key mismatch or expired). "
|
||||
"Re-enable push from a conversation header.",
|
||||
) from None
|
||||
logger.warning("Test push failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
|
||||
except Exception as e:
|
||||
logger.warning("Test push failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
|
||||
|
||||
|
||||
# ── Global push conversation management ──────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def get_push_conversations() -> list[str]:
|
||||
"""Return the global list of push-enabled conversation state keys."""
|
||||
return await AppSettingsRepository.get_push_conversations()
|
||||
|
||||
|
||||
@router.post("/conversations/toggle")
|
||||
async def toggle_push_conversation(body: PushConversationToggle) -> list[str]:
|
||||
"""Add or remove a conversation from the global push list."""
|
||||
return await AppSettingsRepository.toggle_push_conversation(body.key)
|
||||
|
||||
@@ -435,7 +435,7 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
|
||||
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.
|
||||
- **`usePushSubscription` hook**: manages the full subscription lifecycle — subscribe (register SW → `PushManager.subscribe()` → POST to backend), unsubscribe, global push-conversation toggles, 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").
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
|
||||
<link rel="manifest" href="./site.webmanifest" crossorigin="use-credentials" />
|
||||
<script>
|
||||
// Register minimal service worker for PWA installability.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('./sw.js').catch(function() {});
|
||||
}
|
||||
// Service worker registration moved to main.tsx (requires isSecureContext
|
||||
// for Web Push). Do not duplicate here.
|
||||
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
|
||||
@@ -35,6 +35,9 @@ self.addEventListener("push", (event) => {
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const urlHash = event.notification.data?.url_hash || "";
|
||||
// Use the SW registration scope as the base URL so subpath deployments
|
||||
// (e.g. archworks.co/meshcore/) navigate correctly.
|
||||
const base = self.registration.scope;
|
||||
|
||||
event.waitUntil(
|
||||
clients
|
||||
@@ -42,18 +45,16 @@ self.addEventListener("notificationclick", (event) => {
|
||||
.then((windowClients) => {
|
||||
// Focus an existing tab if one is open
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes(self.location.origin)) {
|
||||
if (client.url.startsWith(base)) {
|
||||
client.focus();
|
||||
if (urlHash) {
|
||||
client.navigate(self.location.origin + "/" + urlHash);
|
||||
client.navigate(base + urlHash);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Otherwise open a new tab
|
||||
return clients.openWindow(
|
||||
self.location.origin + "/" + (urlHash || "")
|
||||
);
|
||||
return clients.openWindow(base + (urlHash || ""));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,7 +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 { usePush } from './contexts/PushSubscriptionContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
@@ -100,7 +100,7 @@ export function App() {
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
} = useBrowserNotifications();
|
||||
const pushSubscription = usePushSubscription();
|
||||
const pushSubscription = usePush();
|
||||
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
||||
const {
|
||||
showNewMessage,
|
||||
@@ -625,20 +625,27 @@ export function App() {
|
||||
getStateKey(activeConversation.type, activeConversation.id)
|
||||
)
|
||||
: false,
|
||||
onTogglePush: () => {
|
||||
onTogglePush: async () => {
|
||||
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);
|
||||
const pushEnabled = pushSubscription.isConversationPushEnabled(key);
|
||||
|
||||
if (!pushEnabled && !pushSubscription.isSubscribed) {
|
||||
const subscriptionId = await pushSubscription.subscribe();
|
||||
if (!subscriptionId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await pushSubscription.toggleConversation(key);
|
||||
},
|
||||
onOpenPushSettings: () => {
|
||||
setSettingsSection('local');
|
||||
if (!showSettings) handleToggleSettingsView();
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
@@ -673,6 +680,7 @@ export function App() {
|
||||
onToggleBlockedKey: handleBlockKey,
|
||||
onToggleBlockedName: handleBlockName,
|
||||
contacts,
|
||||
channels,
|
||||
onBulkDeleteContacts: (deletedKeys: string[]) => {
|
||||
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||
|
||||
@@ -456,16 +456,14 @@ export const api = {
|
||||
body: JSON.stringify(subscription),
|
||||
}),
|
||||
getPushSubscriptions: () => fetchJson<PushSubscriptionInfo[]>('/push/subscriptions'),
|
||||
updatePushSubscription: (
|
||||
id: string,
|
||||
update: { label?: string; filter_mode?: string; filter_conversations?: string[] }
|
||||
) =>
|
||||
fetchJson<PushSubscriptionInfo>(`/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' }),
|
||||
getPushConversations: () => fetchJson<string[]>('/push/conversations'),
|
||||
togglePushConversation: (key: string) =>
|
||||
fetchJson<string[]>('/push/conversations/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, BellRing, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
@@ -30,6 +30,7 @@ interface ChatHeaderProps {
|
||||
pushSubscribed?: boolean;
|
||||
pushEnabledForConversation?: boolean;
|
||||
onTogglePush?: () => void;
|
||||
onOpenPushSettings?: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
@@ -54,6 +55,7 @@ export function ChatHeader({
|
||||
pushSubscribed,
|
||||
pushEnabledForConversation,
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
@@ -66,14 +68,29 @@ export function ChatHeader({
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||
const [notifDropdownOpen, setNotifDropdownOpen] = useState(false);
|
||||
const notifDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
setPathDiscoveryOpen(false);
|
||||
setChannelOverrideOpen(false);
|
||||
setPathHashModeOverrideOpen(false);
|
||||
setNotifDropdownOpen(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
// Close notification dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!notifDropdownOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (notifDropdownRef.current && !notifDropdownRef.current.contains(e.target as Node)) {
|
||||
setNotifDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [notifDropdownOpen]);
|
||||
|
||||
const activeChannel =
|
||||
conversation.type === 'channel'
|
||||
? channels.find((channel) => channel.key === conversation.id)
|
||||
@@ -296,63 +313,94 @@ export function ChatHeader({
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
|
||||
<div className="relative" ref={notifDropdownRef}>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setNotifDropdownOpen((v) => !v)}
|
||||
title="Notification settings"
|
||||
aria-label="Notification settings"
|
||||
aria-expanded={notifDropdownOpen}
|
||||
>
|
||||
<Bell
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
notificationsEnabled || pushEnabledForConversation
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
fill={notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{notifDropdownOpen && (
|
||||
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
|
||||
{notificationsSupported && (
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={notificationsEnabled}
|
||||
disabled={notificationsPermission === 'denied'}
|
||||
onChange={onToggleNotifications}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Desktop notifications (legacy)
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{notificationsPermission === 'denied'
|
||||
? 'Blocked by browser — check site permissions'
|
||||
: 'Alerts while this tab is open'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{pushSupported && onTogglePush && (
|
||||
<>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
|
||||
checked={!!pushEnabledForConversation}
|
||||
onChange={onTogglePush}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground block leading-tight">
|
||||
Web Push
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
{pushSubscribed
|
||||
? 'Alerts even when the browser is closed'
|
||||
: 'Alerts even when the browser is closed. Requires HTTPS.'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
|
||||
All notification types require a trusted HTTPS context. Depending on your
|
||||
browser, a snakeoil certificate may not be sufficient.
|
||||
</span>
|
||||
{onOpenPushSettings && (
|
||||
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
|
||||
Manage Web Push enabled devices in{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotifDropdownOpen(false);
|
||||
onOpenPushSettings();
|
||||
}}
|
||||
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
Settings → Local
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{pushSupported && !activeContactIsRoomServer && onTogglePush && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTogglePush}
|
||||
title={
|
||||
pushEnabledForConversation
|
||||
? 'Disable push notifications for this conversation'
|
||||
: pushSubscribed
|
||||
? 'Enable push notifications for this conversation'
|
||||
: 'Enable Web Push notifications (works when tab is closed)'
|
||||
}
|
||||
aria-label={
|
||||
pushEnabledForConversation
|
||||
? 'Disable push notifications'
|
||||
: 'Enable push notifications'
|
||||
}
|
||||
>
|
||||
<BellRing
|
||||
className={`h-4 w-4 ${pushEnabledForConversation ? 'text-amber-500' : 'text-muted-foreground'}`}
|
||||
fill={pushEnabledForConversation ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{pushEnabledForConversation && (
|
||||
<span className="hidden md:inline text-[0.6875rem] font-medium text-amber-500">
|
||||
Push On
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<button
|
||||
|
||||
@@ -86,6 +86,7 @@ interface ConversationPaneProps {
|
||||
pushSubscribed?: boolean;
|
||||
pushEnabledForConversation?: boolean;
|
||||
onTogglePush?: () => void;
|
||||
onOpenPushSettings?: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
@@ -163,6 +164,7 @@ export function ConversationPane({
|
||||
pushSubscribed,
|
||||
pushEnabledForConversation,
|
||||
onTogglePush,
|
||||
onOpenPushSettings,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
@@ -300,6 +302,7 @@ export function ConversationPane({
|
||||
pushSubscribed={pushSubscribed}
|
||||
pushEnabledForConversation={pushEnabledForConversation}
|
||||
onTogglePush={onTogglePush}
|
||||
onOpenPushSettings={onOpenPushSettings}
|
||||
onTrace={onTrace}
|
||||
onPathDiscovery={onPathDiscovery}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Channel,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
@@ -49,6 +50,7 @@ interface SettingsModalBaseProps {
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
@@ -86,6 +88,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
channels,
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
@@ -228,6 +231,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
{isSectionVisible('local') && (
|
||||
<SettingsLocalSection
|
||||
onLocalLabelChange={onLocalLabelChange}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BellRing, ChevronRight, Logs, MessageSquare, Send, Settings, Trash2 } from 'lucide-react';
|
||||
import { ChevronRight, Logs, MessageSquare, Send, Settings, X } from 'lucide-react';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { usePushSubscription } from '../../hooks/usePushSubscription';
|
||||
import { usePush } from '../../contexts/PushSubscriptionContext';
|
||||
import type { Channel, Contact } from '../../types';
|
||||
import { getContactDisplayName } from '../../utils/pubkey';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
@@ -43,31 +45,55 @@ import {
|
||||
setStatusDotPulseEnabled as saveStatusDotPulse,
|
||||
} from '../../utils/statusDotPulse';
|
||||
|
||||
function PushDeviceManagement() {
|
||||
/** Resolve a state key like "contact-abc123" or "channel-def456" to a display name. */
|
||||
function resolveConversationName(
|
||||
stateKey: string,
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): string {
|
||||
if (stateKey.startsWith('contact-')) {
|
||||
const pubkey = stateKey.slice('contact-'.length);
|
||||
const contact = contacts.find((c) => c.public_key === pubkey);
|
||||
return contact ? getContactDisplayName(contact.name, contact.public_key) : pubkey.slice(0, 12);
|
||||
}
|
||||
if (stateKey.startsWith('channel-')) {
|
||||
const key = stateKey.slice('channel-'.length);
|
||||
const channel = channels.find((c) => c.key === key);
|
||||
if (channel?.name) return channel.name.startsWith('#') ? channel.name : `#${channel.name}`;
|
||||
return `#${key.slice(0, 12)}`;
|
||||
}
|
||||
return stateKey;
|
||||
}
|
||||
|
||||
function PushDeviceManagement({
|
||||
contacts = [],
|
||||
channels = [],
|
||||
}: {
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
}) {
|
||||
const {
|
||||
isSupported,
|
||||
isSubscribed,
|
||||
allSubscriptions,
|
||||
pushConversations,
|
||||
loading,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
currentSubscriptionId,
|
||||
toggleConversation,
|
||||
deleteSubscription,
|
||||
testPush,
|
||||
refreshSubscriptions,
|
||||
} = usePushSubscription();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
} = usePush();
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) refreshSubscriptions();
|
||||
}, [expanded, refreshSubscriptions]);
|
||||
refreshSubscriptions();
|
||||
}, [refreshSubscriptions]);
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<BellRing className="h-4 w-4" /> Web Push Notifications
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className="space-y-3">
|
||||
<Label>Web Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{window.isSecureContext
|
||||
? 'Push notifications are not supported by this browser.'
|
||||
: 'Web Push requires HTTPS. Access RemoteTerm over HTTPS (self-signed certificates work) to enable push notifications.'}
|
||||
@@ -77,82 +103,107 @@ function PushDeviceManagement() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<BellRing className="h-4 w-4" /> Web Push Notifications
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Receive notifications even when the browser tab is closed. Notifications are delivered via
|
||||
your browser's push service and will arrive even when you're not on the same
|
||||
network as RemoteTerm.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Web Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications even when the browser is closed. Use the bell icon in any
|
||||
conversation header to enable push for that contact or channel, or subscribe this browser
|
||||
to receive notifications for all push-enabled conversations.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The set of channels or DMs that trigger push notifications are global per-install (i.e.
|
||||
all devices that register for Web Push will have the same set of channels/DMs that trigger
|
||||
notifications). Subscribing or unsubscribing a particular browser only controls whether
|
||||
that browser receives notifications for the configured set of channels/DMs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isSubscribed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void unsubscribe()}
|
||||
disabled={loading}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{loading ? 'Updating...' : 'Unsubscribe This Browser'}
|
||||
</Button>
|
||||
) : (
|
||||
{!currentSubscriptionId && (
|
||||
<Button variant="outline" size="sm" onClick={() => void subscribe()} disabled={loading}>
|
||||
{loading ? 'Subscribing...' : 'Subscribe This Browser'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{allSubscriptions.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className={cn('h-3 w-3 transition-transform', expanded && 'rotate-90')} />
|
||||
{allSubscriptions.length} registered device{allSubscriptions.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{allSubscriptions.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1.5 text-sm"
|
||||
{pushConversations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Push-enabled conversations
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pushConversations.map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm"
|
||||
>
|
||||
{resolveConversationName(key, contacts, channels)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleConversation(key)}
|
||||
className="rounded-full p-0.5 hover:bg-accent transition-colors"
|
||||
title="Remove"
|
||||
aria-label={`Remove ${resolveConversationName(key, contacts, channels)} from push`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate">{sub.label || 'Unknown device'}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground">
|
||||
{sub.last_success_at
|
||||
? `Last push: ${new Date(sub.last_success_at * 1000).toLocaleDateString()}`
|
||||
: 'Never pushed'}
|
||||
{sub.failure_count > 0 && ` · ${sub.failure_count} failures`}
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allSubscriptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Registered Devices
|
||||
</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{allSubscriptions.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{sub.label || 'Unknown device'}
|
||||
</span>
|
||||
{sub.id === currentSubscriptionId && (
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[0.625rem] font-medium text-primary">
|
||||
Current device
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void testPush(sub.id)}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
void deleteSubscription(sub.id).then(() => toast.success('Device removed'));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sub.last_success_at
|
||||
? `Last push: ${new Date(sub.last_success_at * 1000).toLocaleDateString()}`
|
||||
: 'Never pushed'}
|
||||
{sub.failure_count > 0 && ` · ${sub.failure_count} failures`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-sm"
|
||||
onClick={() => void testPush(sub.id)}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-sm text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
void deleteSubscription(sub.id).then(() => toast.success('Device removed'));
|
||||
}}
|
||||
>
|
||||
Unsubscribe this device
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -161,9 +212,13 @@ function PushDeviceManagement() {
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
contacts,
|
||||
channels,
|
||||
className,
|
||||
}: {
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||
@@ -441,6 +496,10 @@ export function SettingsLocalSection({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PushDeviceManagement contacts={contacts} channels={channels} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -516,10 +575,6 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PushDeviceManagement />
|
||||
|
||||
{/* ── Style Reference (collapsible) ── */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
35
frontend/src/contexts/PushSubscriptionContext.tsx
Normal file
35
frontend/src/contexts/PushSubscriptionContext.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { usePushSubscription, type PushSubscriptionState } from '../hooks/usePushSubscription';
|
||||
|
||||
const noopAsync = async () => {};
|
||||
const noopAsyncNull = async () => null;
|
||||
|
||||
const defaultState: PushSubscriptionState = {
|
||||
isSupported: false,
|
||||
isSubscribed: false,
|
||||
currentSubscriptionId: null,
|
||||
allSubscriptions: [],
|
||||
pushConversations: [],
|
||||
loading: false,
|
||||
subscribe: noopAsyncNull,
|
||||
unsubscribe: noopAsync,
|
||||
toggleConversation: noopAsync,
|
||||
isConversationPushEnabled: () => false,
|
||||
deleteSubscription: noopAsync,
|
||||
testPush: noopAsync,
|
||||
refreshSubscriptions: async () => [],
|
||||
refreshConversations: noopAsync,
|
||||
};
|
||||
|
||||
const PushSubscriptionContext = createContext<PushSubscriptionState>(defaultState);
|
||||
|
||||
export function PushSubscriptionProvider({ children }: { children: ReactNode }) {
|
||||
const push = usePushSubscription();
|
||||
return (
|
||||
<PushSubscriptionContext.Provider value={push}>{children}</PushSubscriptionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePush(): PushSubscriptionState {
|
||||
return useContext(PushSubscriptionContext);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type { PushSubscriptionInfo } from '../types';
|
||||
|
||||
function generateLabel(): string {
|
||||
const ua = navigator.userAgent;
|
||||
// Extract browser + OS in a human-readable form
|
||||
if (/Firefox/i.test(ua)) {
|
||||
if (/Android/i.test(ua)) return 'Firefox on Android';
|
||||
if (/Mac/i.test(ua)) return 'Firefox on macOS';
|
||||
@@ -29,7 +28,6 @@ function generateLabel(): string {
|
||||
return 'Browser';
|
||||
}
|
||||
|
||||
/** Convert a base64url string to a Uint8Array (for applicationServerKey) */
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
@@ -39,14 +37,64 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function usePushSubscription() {
|
||||
function uint8ArraysEqual(a: Uint8Array | null, b: Uint8Array): boolean {
|
||||
if (!a || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getApplicationServerKeyBytes(
|
||||
key: ArrayBuffer | ArrayBufferView | null | undefined
|
||||
): Uint8Array | null {
|
||||
if (!key) return null;
|
||||
if (ArrayBuffer.isView(key)) {
|
||||
return new Uint8Array(key.buffer, key.byteOffset, key.byteLength);
|
||||
}
|
||||
return new Uint8Array(key);
|
||||
}
|
||||
|
||||
export interface PushSubscriptionState {
|
||||
isSupported: boolean;
|
||||
isSubscribed: boolean;
|
||||
currentSubscriptionId: string | null;
|
||||
allSubscriptions: PushSubscriptionInfo[];
|
||||
/** Global list of push-enabled conversation state keys (device-independent). */
|
||||
pushConversations: string[];
|
||||
loading: boolean;
|
||||
subscribe: () => Promise<string | null>;
|
||||
unsubscribe: () => Promise<void>;
|
||||
/** Toggle a conversation in the global push list (device-independent). */
|
||||
toggleConversation: (conversationKey: string) => Promise<void>;
|
||||
isConversationPushEnabled: (conversationKey: string) => boolean;
|
||||
deleteSubscription: (subscriptionId: string) => Promise<void>;
|
||||
testPush: (subscriptionId: string) => Promise<void>;
|
||||
refreshSubscriptions: () => Promise<PushSubscriptionInfo[]>;
|
||||
refreshConversations: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePushSubscription(): PushSubscriptionState {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [currentSubscriptionId, setCurrentSubscriptionId] = useState<string | null>(null);
|
||||
const [allSubscriptions, setAllSubscriptions] = useState<PushSubscriptionInfo[]>([]);
|
||||
const [pushConversations, setPushConversations] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const vapidKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Check support on mount
|
||||
const reconcileCurrentSubscription = useCallback(
|
||||
(subs: PushSubscriptionInfo[], endpoint: string | null) => {
|
||||
setAllSubscriptions(subs);
|
||||
if (!endpoint) {
|
||||
setCurrentSubscriptionId(null);
|
||||
return;
|
||||
}
|
||||
const match = subs.find((sub) => sub.endpoint === endpoint);
|
||||
setCurrentSubscriptionId(match?.id ?? null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const supported =
|
||||
window.isSecureContext &&
|
||||
@@ -56,105 +104,104 @@ export function usePushSubscription() {
|
||||
setIsSupported(supported);
|
||||
|
||||
if (supported) {
|
||||
// Check if this browser already has an active push subscription
|
||||
// Always load all registered devices so Settings can manage them even
|
||||
// when this particular browser isn't subscribed.
|
||||
const subsPromise = api.getPushSubscriptions().catch(() => [] as PushSubscriptionInfo[]);
|
||||
|
||||
// Check if THIS browser has an active push subscription and match it
|
||||
// to a backend record.
|
||||
navigator.serviceWorker.ready
|
||||
.then((reg) => reg.pushManager.getSubscription())
|
||||
.then(async (sub) => {
|
||||
if (sub) {
|
||||
// Look up this endpoint in backend to get the subscription ID
|
||||
const existing = await api
|
||||
.getPushSubscriptions()
|
||||
.catch(() => [] as PushSubscriptionInfo[]);
|
||||
const match = existing.find((s) => s.endpoint === sub.endpoint);
|
||||
if (match) {
|
||||
setCurrentSubscriptionId(match.id);
|
||||
setAllSubscriptions(existing);
|
||||
}
|
||||
}
|
||||
const existing = await subsPromise;
|
||||
reconcileCurrentSubscription(existing, sub?.endpoint ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Load global conversation list
|
||||
api
|
||||
.getPushConversations()
|
||||
.then(setPushConversations)
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
}, [reconcileCurrentSubscription]);
|
||||
|
||||
const refreshSubscriptions = useCallback(async () => {
|
||||
try {
|
||||
const subs = await api.getPushSubscriptions();
|
||||
setAllSubscriptions(subs);
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
reconcileCurrentSubscription(subs, sub?.endpoint ?? null);
|
||||
return subs;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [reconcileCurrentSubscription]);
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const convos = await api.getPushConversations();
|
||||
setPushConversations(convos);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback(
|
||||
async (conversationKey?: string): Promise<string | null> => {
|
||||
if (!isSupported) return null;
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get VAPID key if not cached
|
||||
if (!vapidKeyRef.current) {
|
||||
const resp = await api.getVapidPublicKey();
|
||||
vapidKeyRef.current = resp.public_key;
|
||||
}
|
||||
const subscribe = useCallback(async (): Promise<string | null> => {
|
||||
if (!isSupported) return null;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await api.getVapidPublicKey();
|
||||
vapidKeyRef.current = resp.public_key;
|
||||
const vapidKeyBytes = urlBase64ToUint8Array(resp.public_key);
|
||||
|
||||
// Register/get service worker
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
let pushSub = await reg.pushManager.getSubscription();
|
||||
const existingKeyBytes = getApplicationServerKeyBytes(pushSub?.options?.applicationServerKey);
|
||||
const requiresRecreate =
|
||||
pushSub !== null && !uint8ArraysEqual(existingKeyBytes, vapidKeyBytes);
|
||||
|
||||
// Reuse existing browser subscription if one exists, otherwise create new
|
||||
let pushSub = await reg.pushManager.getSubscription();
|
||||
if (!pushSub) {
|
||||
pushSub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKeyRef.current).buffer as ArrayBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
const json = pushSub.toJSON();
|
||||
const endpoint = json.endpoint!;
|
||||
const p256dh = json.keys!.p256dh!;
|
||||
const auth = json.keys!.auth!;
|
||||
|
||||
// Register with backend
|
||||
const result = await api.pushSubscribe({
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
label: generateLabel(),
|
||||
});
|
||||
|
||||
// If subscribing for a specific conversation, set filter_mode to selected
|
||||
if (conversationKey) {
|
||||
await api.updatePushSubscription(result.id, {
|
||||
filter_mode: 'selected',
|
||||
filter_conversations: [conversationKey],
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentSubscriptionId(result.id);
|
||||
await refreshSubscriptions();
|
||||
return result.id;
|
||||
} catch (err) {
|
||||
console.error('Push subscribe failed:', err);
|
||||
toast.error('Failed to enable push notifications', {
|
||||
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requiresRecreate) {
|
||||
await pushSub!.unsubscribe();
|
||||
pushSub = null;
|
||||
}
|
||||
},
|
||||
[isSupported, refreshSubscriptions]
|
||||
);
|
||||
|
||||
if (!pushSub) {
|
||||
pushSub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapidKeyBytes.buffer as ArrayBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
const json = pushSub.toJSON();
|
||||
const result = await api.pushSubscribe({
|
||||
endpoint: json.endpoint!,
|
||||
p256dh: json.keys!.p256dh!,
|
||||
auth: json.keys!.auth!,
|
||||
label: generateLabel(),
|
||||
});
|
||||
|
||||
setCurrentSubscriptionId(result.id);
|
||||
await refreshSubscriptions();
|
||||
return result.id;
|
||||
} catch (err) {
|
||||
console.error('Push subscribe failed:', err);
|
||||
toast.error('Failed to enable push notifications', {
|
||||
description: err instanceof Error ? err.message : 'Check that notifications are allowed',
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSupported, refreshSubscriptions]);
|
||||
|
||||
const unsubscribe = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Unsubscribe from browser Push API
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const pushSub = await reg.pushManager.getSubscription();
|
||||
if (pushSub) await pushSub.unsubscribe();
|
||||
|
||||
// Remove from backend
|
||||
if (currentSubscriptionId) {
|
||||
await api.deletePushSubscription(currentSubscriptionId).catch(() => {});
|
||||
}
|
||||
@@ -168,50 +215,20 @@ export function usePushSubscription() {
|
||||
}
|
||||
}, [currentSubscriptionId, refreshSubscriptions]);
|
||||
|
||||
const addConversation = useCallback(
|
||||
async (conversationKey: string) => {
|
||||
if (!currentSubscriptionId) return;
|
||||
const sub = allSubscriptions.find((s) => s.id === currentSubscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
const conversations = [...(sub.filter_conversations || [])];
|
||||
if (!conversations.includes(conversationKey)) {
|
||||
conversations.push(conversationKey);
|
||||
}
|
||||
await api.updatePushSubscription(currentSubscriptionId, {
|
||||
filter_mode: 'selected',
|
||||
filter_conversations: conversations,
|
||||
});
|
||||
await refreshSubscriptions();
|
||||
},
|
||||
[currentSubscriptionId, allSubscriptions, refreshSubscriptions]
|
||||
);
|
||||
|
||||
const removeConversation = useCallback(
|
||||
async (conversationKey: string) => {
|
||||
if (!currentSubscriptionId) return;
|
||||
const sub = allSubscriptions.find((s) => s.id === currentSubscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
const conversations = (sub.filter_conversations || []).filter((k) => k !== conversationKey);
|
||||
await api.updatePushSubscription(currentSubscriptionId, {
|
||||
filter_conversations: conversations,
|
||||
});
|
||||
await refreshSubscriptions();
|
||||
},
|
||||
[currentSubscriptionId, allSubscriptions, refreshSubscriptions]
|
||||
);
|
||||
const toggleConversation = useCallback(async (conversationKey: string) => {
|
||||
try {
|
||||
const updated = await api.togglePushConversation(conversationKey);
|
||||
setPushConversations(updated);
|
||||
} catch {
|
||||
toast.error('Failed to update push preferences');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isConversationPushEnabled = useCallback(
|
||||
(conversationKey: string): boolean => {
|
||||
if (!currentSubscriptionId) return false;
|
||||
const sub = allSubscriptions.find((s) => s.id === currentSubscriptionId);
|
||||
if (!sub) return false;
|
||||
if (sub.filter_mode === 'all_messages') return true;
|
||||
if (sub.filter_mode === 'all_dms') return conversationKey.startsWith('contact-');
|
||||
return (sub.filter_conversations || []).includes(conversationKey);
|
||||
return pushConversations.includes(conversationKey);
|
||||
},
|
||||
[currentSubscriptionId, allSubscriptions]
|
||||
[pushConversations]
|
||||
);
|
||||
|
||||
const deleteSubscription = useCallback(
|
||||
@@ -219,7 +236,6 @@ export function usePushSubscription() {
|
||||
await api.deletePushSubscription(subscriptionId);
|
||||
if (subscriptionId === currentSubscriptionId) {
|
||||
setCurrentSubscriptionId(null);
|
||||
// Also unsubscribe from browser Push API if it's our own
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const pushSub = await reg.pushManager.getSubscription();
|
||||
@@ -247,14 +263,15 @@ export function usePushSubscription() {
|
||||
isSubscribed: !!currentSubscriptionId,
|
||||
currentSubscriptionId,
|
||||
allSubscriptions,
|
||||
pushConversations,
|
||||
loading,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
addConversation,
|
||||
removeConversation,
|
||||
toggleConversation,
|
||||
isConversationPushEnabled,
|
||||
deleteSubscription,
|
||||
testPush,
|
||||
refreshSubscriptions,
|
||||
refreshConversations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import './themes.css';
|
||||
import './styles.css';
|
||||
import { getSavedTheme, applyTheme, initFollowOSListener } from './utils/theme';
|
||||
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
|
||||
import { PushSubscriptionProvider } from './contexts/PushSubscriptionContext';
|
||||
|
||||
// Apply saved theme before first render
|
||||
applyTheme(getSavedTheme());
|
||||
@@ -15,7 +16,9 @@ applyFontScale(getSavedFontScale());
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<PushSubscriptionProvider>
|
||||
<App />
|
||||
</PushSubscriptionProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ const mocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
push: {
|
||||
isSupported: false,
|
||||
isSubscribed: false,
|
||||
subscribe: vi.fn<() => Promise<string | null>>(async () => null),
|
||||
toggleConversation: vi.fn(async () => {}),
|
||||
isConversationPushEnabled: vi.fn(() => false),
|
||||
},
|
||||
hookFns: {
|
||||
fetchOlderMessages: vi.fn(async () => {}),
|
||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||
@@ -51,6 +58,25 @@ vi.mock('../useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/PushSubscriptionContext', () => ({
|
||||
usePush: () => ({
|
||||
isSupported: mocks.push.isSupported,
|
||||
isSubscribed: mocks.push.isSubscribed,
|
||||
currentSubscriptionId: mocks.push.isSubscribed ? 'sub-1' : null,
|
||||
allSubscriptions: [],
|
||||
pushConversations: [],
|
||||
loading: false,
|
||||
subscribe: mocks.push.subscribe,
|
||||
unsubscribe: vi.fn(async () => {}),
|
||||
toggleConversation: mocks.push.toggleConversation,
|
||||
isConversationPushEnabled: mocks.push.isConversationPushEnabled,
|
||||
deleteSubscription: vi.fn(async () => {}),
|
||||
testPush: vi.fn(async () => {}),
|
||||
refreshSubscriptions: vi.fn(async () => []),
|
||||
refreshConversations: vi.fn(async () => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../hooks')>();
|
||||
return {
|
||||
@@ -209,6 +235,10 @@ const publicChannel = {
|
||||
describe('App favorite toggle flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.push.isSupported = false;
|
||||
mocks.push.isSubscribed = false;
|
||||
mocks.push.subscribe.mockResolvedValue(null);
|
||||
mocks.push.isConversationPushEnabled.mockReturnValue(false);
|
||||
|
||||
mocks.api.getRadioConfig.mockResolvedValue(baseConfig);
|
||||
mocks.api.getSettings.mockResolvedValue({ ...baseSettings });
|
||||
@@ -313,4 +343,44 @@ describe('App favorite toggle flow', () => {
|
||||
expect(screen.queryByTestId('settings-modal-section')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('subscribes this browser before enabling web push for a conversation', async () => {
|
||||
mocks.push.isSupported = true;
|
||||
mocks.push.isSubscribed = false;
|
||||
mocks.push.subscribe.mockResolvedValue('sub-1');
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Notification settings' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /web push/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.push.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.push.toggleConversation).toHaveBeenCalledWith(`channel-${publicChannel.key}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not enable web push when subscription setup fails', async () => {
|
||||
mocks.push.isSupported = true;
|
||||
mocks.push.isSubscribed = false;
|
||||
mocks.push.subscribe.mockResolvedValue(null);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Notification settings' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /web push/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.push.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mocks.push.toggleConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('ChatHeader key visibility', () => {
|
||||
expect(screen.getAllByText('#Esperance')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows enabled notification state and toggles when clicked', () => {
|
||||
it('shows filled bell when notifications are enabled and toggles via dropdown', () => {
|
||||
const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' };
|
||||
const onToggleNotifications = vi.fn();
|
||||
|
||||
@@ -164,12 +164,40 @@ describe('ChatHeader key visibility', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Notifications On'));
|
||||
// Bell button should be present; open the dropdown
|
||||
const bellBtn = screen.getByRole('button', { name: 'Notification settings' });
|
||||
fireEvent.click(bellBtn);
|
||||
|
||||
expect(screen.getByText('Notifications On')).toBeInTheDocument();
|
||||
// Desktop notifications checkbox should be checked
|
||||
const checkbox = screen.getByRole('checkbox', { name: /desktop notifications/i });
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
// Toggling calls the handler
|
||||
fireEvent.click(checkbox);
|
||||
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps desktop notifications available when web push is also supported', () => {
|
||||
const conversation: Conversation = { type: 'contact', id: '13'.repeat(32), name: 'Alice' };
|
||||
|
||||
render(
|
||||
<ChatHeader
|
||||
{...baseProps}
|
||||
conversation={conversation}
|
||||
channels={[]}
|
||||
pushSupported
|
||||
pushSubscribed
|
||||
pushEnabledForConversation
|
||||
onTogglePush={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }));
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /desktop notifications/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: /web push/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides trace and notification controls for room-server contacts', () => {
|
||||
const pubKey = '41'.repeat(32);
|
||||
const contact: Contact = {
|
||||
@@ -198,9 +226,7 @@ describe('ChatHeader key visibility', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the delete button for the canonical Public channel', () => {
|
||||
|
||||
203
frontend/src/test/usePushSubscription.test.ts
Normal file
203
frontend/src/test/usePushSubscription.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
getPushSubscriptions: vi.fn(),
|
||||
getPushConversations: vi.fn(),
|
||||
getVapidPublicKey: vi.fn(),
|
||||
pushSubscribe: vi.fn(),
|
||||
deletePushSubscription: vi.fn(),
|
||||
togglePushConversation: vi.fn(),
|
||||
testPushSubscription: vi.fn(),
|
||||
},
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: mocks.api,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: mocks.toast,
|
||||
}));
|
||||
|
||||
function bytesToBase64Url(bytes: number[]): string {
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
describe('usePushSubscription', () => {
|
||||
const vapidOldBytes = [1, 2, 3, 4];
|
||||
const vapidNewBytes = [5, 6, 7, 8];
|
||||
const oldKey = new Uint8Array(vapidOldBytes).buffer;
|
||||
const newKeyBase64 = bytesToBase64Url(vapidNewBytes);
|
||||
|
||||
let activeSubscription: {
|
||||
endpoint: string;
|
||||
options: { applicationServerKey: ArrayBuffer };
|
||||
toJSON: () => { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||
unsubscribe: ReturnType<typeof vi.fn>;
|
||||
} | null;
|
||||
let replacementSubscription: {
|
||||
endpoint: string;
|
||||
options: { applicationServerKey: ArrayBuffer };
|
||||
toJSON: () => { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||
unsubscribe: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let getSubscriptionMock: ReturnType<typeof vi.fn>;
|
||||
let subscribeMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
activeSubscription = {
|
||||
endpoint: 'https://push.example.test/sub-old',
|
||||
options: { applicationServerKey: oldKey },
|
||||
toJSON: () => ({
|
||||
endpoint: 'https://push.example.test/sub-old',
|
||||
keys: { p256dh: 'p256dh-old', auth: 'auth-old' },
|
||||
}),
|
||||
unsubscribe: vi.fn(async () => {
|
||||
activeSubscription = null;
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
replacementSubscription = {
|
||||
endpoint: 'https://push.example.test/sub-new',
|
||||
options: { applicationServerKey: new Uint8Array(vapidNewBytes).buffer },
|
||||
toJSON: () => ({
|
||||
endpoint: 'https://push.example.test/sub-new',
|
||||
keys: { p256dh: 'p256dh-new', auth: 'auth-new' },
|
||||
}),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
getSubscriptionMock = vi.fn(async () => activeSubscription);
|
||||
subscribeMock = vi.fn(async () => {
|
||||
activeSubscription = replacementSubscription;
|
||||
return replacementSubscription;
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
Object.defineProperty(window, 'PushManager', {
|
||||
configurable: true,
|
||||
value: function PushManager() {},
|
||||
});
|
||||
Object.defineProperty(window, 'Notification', {
|
||||
configurable: true,
|
||||
value: function Notification() {},
|
||||
});
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ready: Promise.resolve({
|
||||
pushManager: {
|
||||
getSubscription: getSubscriptionMock,
|
||||
subscribe: subscribeMock,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
mocks.api.getPushConversations.mockResolvedValue([]);
|
||||
mocks.api.getPushSubscriptions.mockResolvedValue([
|
||||
{
|
||||
id: 'sub-1',
|
||||
endpoint: 'https://push.example.test/sub-old',
|
||||
p256dh: 'p256dh-old',
|
||||
auth: 'auth-old',
|
||||
label: 'Chrome on macOS',
|
||||
created_at: 1,
|
||||
last_success_at: null,
|
||||
failure_count: 0,
|
||||
},
|
||||
]);
|
||||
mocks.api.getVapidPublicKey.mockResolvedValue({ public_key: newKeyBase64 });
|
||||
mocks.api.pushSubscribe.mockResolvedValue({
|
||||
id: 'sub-2',
|
||||
endpoint: 'https://push.example.test/sub-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears currentSubscriptionId when refresh no longer finds this browser on the backend', async () => {
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentSubscriptionId).toBe('sub-1');
|
||||
expect(result.current.isSubscribed).toBe(true);
|
||||
});
|
||||
|
||||
mocks.api.getPushSubscriptions.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSubscriptions();
|
||||
});
|
||||
|
||||
expect(result.current.currentSubscriptionId).toBeNull();
|
||||
expect(result.current.isSubscribed).toBe(false);
|
||||
expect(result.current.allSubscriptions).toEqual([]);
|
||||
});
|
||||
|
||||
it('recreates a stale browser subscription when the server VAPID key changed', async () => {
|
||||
const oldSubscription = activeSubscription;
|
||||
mocks.api.getPushSubscriptions
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'sub-1',
|
||||
endpoint: 'https://push.example.test/sub-old',
|
||||
p256dh: 'p256dh-old',
|
||||
auth: 'auth-old',
|
||||
label: 'Chrome on macOS',
|
||||
created_at: 1,
|
||||
last_success_at: null,
|
||||
failure_count: 0,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'sub-2',
|
||||
endpoint: 'https://push.example.test/sub-new',
|
||||
p256dh: 'p256dh-new',
|
||||
auth: 'auth-new',
|
||||
label: 'Chrome on macOS',
|
||||
created_at: 2,
|
||||
last_success_at: null,
|
||||
failure_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSupported).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.subscribe();
|
||||
});
|
||||
|
||||
expect(oldSubscription?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(activeSubscription).toBe(replacementSubscription);
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.api.pushSubscribe).toHaveBeenCalledWith({
|
||||
endpoint: 'https://push.example.test/sub-new',
|
||||
p256dh: 'p256dh-new',
|
||||
auth: 'auth-new',
|
||||
label: expect.any(String),
|
||||
});
|
||||
expect(result.current.currentSubscriptionId).toBe('sub-2');
|
||||
});
|
||||
});
|
||||
@@ -513,9 +513,9 @@ export interface TelemetryHistoryEntry {
|
||||
export interface PushSubscriptionInfo {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
label: string;
|
||||
filter_mode: 'all_messages' | 'all_dms' | 'selected';
|
||||
filter_conversations: string[];
|
||||
created_at: number;
|
||||
last_success_at: number | null;
|
||||
failure_count: number;
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||
# change, not every individual assertion.
|
||||
LATEST_SCHEMA_VERSION = 57
|
||||
LATEST_SCHEMA_VERSION = 58
|
||||
|
||||
74
tests/test_push_send.py
Normal file
74
tests/test_push_send.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for Web Push delivery transport behavior."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from app.push.send import (
|
||||
DEFAULT_PUSH_CONNECT_TIMEOUT_SECONDS,
|
||||
DEFAULT_PUSH_READ_TIMEOUT_SECONDS,
|
||||
IPV4_FALLBACK_CONNECT_TIMEOUT_SECONDS,
|
||||
IPv4HTTPAdapter,
|
||||
send_push,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_push_prefers_default_dual_stack_session_before_any_ipv4_fallback():
|
||||
"""Successful sends should use the normal requests transport without forcing IPv4."""
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
def fake_webpush(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(status_code=201)
|
||||
|
||||
with patch("app.push.send.webpush", side_effect=fake_webpush):
|
||||
status = await send_push(
|
||||
subscription_info={"endpoint": "https://push.example.test", "keys": {}},
|
||||
payload='{"message":"hello"}',
|
||||
vapid_private_key="private-key",
|
||||
vapid_claims={"sub": "mailto:test@example.com"},
|
||||
)
|
||||
|
||||
assert status == 201
|
||||
session = captured_kwargs["requests_session"]
|
||||
assert not isinstance(session.adapters["https://"], IPv4HTTPAdapter)
|
||||
assert captured_kwargs["timeout"] == (
|
||||
DEFAULT_PUSH_CONNECT_TIMEOUT_SECONDS,
|
||||
DEFAULT_PUSH_READ_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_push_retries_with_ipv4_session_after_connect_timeout():
|
||||
"""Connect failures should retry through the isolated IPv4-only transport."""
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_webpush(**kwargs):
|
||||
calls.append(kwargs)
|
||||
if len(calls) == 1:
|
||||
raise requests.exceptions.ConnectTimeout("ipv6 connect timed out")
|
||||
return SimpleNamespace(status_code=201)
|
||||
|
||||
with patch("app.push.send.webpush", side_effect=fake_webpush):
|
||||
status = await send_push(
|
||||
subscription_info={"endpoint": "https://push.example.test", "keys": {}},
|
||||
payload='{"message":"hello"}',
|
||||
vapid_private_key="private-key",
|
||||
vapid_claims={"sub": "mailto:test@example.com"},
|
||||
)
|
||||
|
||||
assert status == 201
|
||||
assert len(calls) == 2
|
||||
assert not isinstance(calls[0]["requests_session"].adapters["https://"], IPv4HTTPAdapter)
|
||||
assert isinstance(calls[1]["requests_session"].adapters["https://"], IPv4HTTPAdapter)
|
||||
assert calls[0]["timeout"] == (
|
||||
DEFAULT_PUSH_CONNECT_TIMEOUT_SECONDS,
|
||||
DEFAULT_PUSH_READ_TIMEOUT_SECONDS,
|
||||
)
|
||||
assert calls[1]["timeout"] == (
|
||||
IPV4_FALLBACK_CONNECT_TIMEOUT_SECONDS,
|
||||
DEFAULT_PUSH_READ_TIMEOUT_SECONDS,
|
||||
)
|
||||
Reference in New Issue
Block a user