This commit is contained in:
Jack Kingsman
2026-04-16 18:56:57 -07:00
parent 31bd4a0744
commit af76546287
27 changed files with 1352 additions and 473 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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,

View File

@@ -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()

View File

@@ -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]

View File

@@ -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

View File

@@ -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,),
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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").

View File

@@ -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

View File

@@ -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 || ""));
})
);
});

View File

@@ -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())));

View File

@@ -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 }),
}),
};

View File

@@ -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 &rarr; 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

View File

@@ -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}

View File

@@ -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}
/>
)}

View File

@@ -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&apos;s push service and will arrive even when you&apos;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"

View 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);
}

View File

@@ -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,
};
}

View File

@@ -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>
);

View File

@@ -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();
});
});

View File

@@ -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', () => {

View 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');
});
});

View File

@@ -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;

View File

@@ -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
View 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,
)