News that finds you, including when nothing of ours is open

The dots covered Reports and Messages from the day those sections existed. The
announcement did not: only a chat reply produced an HX-Trigger, so a scheduled
run that filed a report or posted into Messages lit a green dot in a corner and
said nothing at all. That is precisely the arrival nobody is watching for -- a
chat reply is one you asked for a moment ago and are probably looking at.

So every kind announces, each with its own once-only flag, and the payload is a
list of items rather than of titles, because a notification is a thing you click
and a title cannot say where.

One arrival, three channels, and they must not all fire. A toast for somebody
looking at the page; a count in the tab title while it is hidden, cleared on
focus; a system notification for somebody elsewhere entirely. The service worker
is the only place that can tell them apart -- the server cannot see whether a
window is focused and the page cannot see a push it did not receive -- so it
stays quiet when one of its own windows has focus.

And web push, hand-rolled against RFC 8291 and RFC 8292 with the cryptography
already here for Fernet. It exists because everything else is polled by an open
page, and the arrival worth interrupting somebody for is a schedule firing at
seven in the morning with the laptop shut.

The trade is real and is written down rather than glossed: the POST goes to
Google's or Mozilla's push service, the payload is sealed end to end so they
cannot read it, and what they do learn is that this server sent something and
when. Opt-in per device, off until asked for, and the rest of the system works
without it. Nothing else in LLeMbas contacts an outside service on its own.

The encryption is tested by decrypting it back with an independent
implementation of the specification's other half. There is no other way to know:
a push service accepts the POST and forwards bytes it cannot read, so a wrong
derivation is a notification that never appears, with a 201 in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 11:11:04 +02:00
parent 9761082fa1
commit 54ed030732
16 changed files with 1553 additions and 22 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.9.0"
__version__ = "0.9.1"
+41 -8
View File
@@ -722,7 +722,7 @@ async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
@router.get("/unread")
async def unread_poll(db: Db, user: RequiredUser) -> Response:
"""Dots for the sidebar, and a toast for anything newly arrived.
"""Dots for the sidebar, and an announcement for anything newly arrived.
Polled rather than pushed: a browser sitting on a different chat has no
open connection to the one that finished, and a second always-on channel
@@ -730,6 +730,19 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
Returns out-of-band spans so only the dots change -- re-rendering the whole
sidebar would reset the folder open/closed state on every tick.
**Everything that can arrive is announced, not only chats.** The dots have
covered Reports and Messages since those sections existed, but the
announcement did not: only a chat reply produced an `HX-Trigger`, so a
scheduled run that filed a report or posted into Messages lit a dot in the
corner and said nothing at all. That is precisely the arrival nobody is
watching for -- a chat reply is one you asked for a moment ago and are
probably looking at, while a schedule fires while you are elsewhere. So each
kind carries its own `*_notified` flag and each announces once.
The payload is a list of items rather than a list of titles, because the
browser notification wants somewhere to go when it is clicked and a title on
its own cannot say where.
"""
chats = list(
db.scalars(
@@ -749,11 +762,15 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
)
)
# What to announce, in the order it will be read out. Each entry carries
# where it came from and where to go, because a browser notification is a
# thing you click.
items: list[dict[str, str]] = []
fresh = [c for c in chats if c.unread and not c.unread_notified]
for chat in fresh:
chat.unread_notified = True
if fresh:
db.commit()
items.append({"kind": "chat", "title": chat.title, "url": f"/chat/{chat.id}"})
markup = "".join(
f'<span id="unread-{c.id}" class="unread-dot" hx-swap-oob="true"'
@@ -771,6 +788,14 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
'<span id="unread-reports" class="unread-dot" hx-swap-oob="true"'
f'{"" if waiting else " hidden"} title="New reports"></span>'
)
# Announced per report rather than per section, because the title is the
# whole of what makes it worth interrupting somebody for -- "a report
# arrived" is a sentence they have to go and act on to understand.
for report in reports_service.unannounced(db, user):
report.unread_notified = True
items.append(
{"kind": "report", "title": report.title, "url": f"/reports/{report.id}"}
)
# The Messages conversation, read from the row rather than created: this
# runs every ten seconds on every open page, and `for_user` would write one
@@ -783,13 +808,21 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
f'{"" if (conversation and conversation.unread) else " hidden"}'
' title="New messages"></span>'
)
if conversation is not None and conversation.unread and not conversation.unread_notified:
conversation.unread_notified = True
# Not the conversation's title, which is "Messages" and says nothing.
# There is one per person and it is the section, so the section is the
# honest name for it.
items.append({"kind": "message", "title": "Messages", "url": "/messages"})
if items:
db.commit()
response = HTMLResponse(markup)
if fresh:
# HX-Trigger carries the toast; ui.js listens for it.
response.headers["HX-Trigger"] = json.dumps(
{"lembas:unread": {"titles": [c.title for c in fresh]}}
)
if items:
# HX-Trigger carries it; ui.js turns it into a toast, a browser
# notification and a count in the tab title.
response.headers["HX-Trigger"] = json.dumps({"lembas:unread": {"items": items}})
return response
+106
View File
@@ -0,0 +1,106 @@
"""Registering a browser for notifications, and letting it go again.
Three routes and no cleverness. The interesting half is `services/push.py`;
this is the part a browser talks to.
Ownership is the whole authorisation, as everywhere a person's own things are
handled here: a subscription belongs to whoever was signed in when it was made,
and nothing else can reach it.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Response, status
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser
from lembas.db.models import PushSubscription
from lembas.services import push as push_service
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/push", tags=["push"])
# What a browser hands back is its own; these are the bounds that stop a crafted
# POST writing a novel into the row.
MAX_ENDPOINT = 2000
MAX_KEY = 255
@router.get("/key")
async def application_key(db: Db, user: RequiredUser) -> dict[str, str]:
"""The public half of this instance's VAPID key.
A browser needs it to subscribe, and it is public by construction — it is
what every push service is shown on every send. Behind a login anyway,
because there is no reason for it to be readable by anyone who is not about
to use it.
"""
return {"key": push_service.public_key(db)}
@router.post("/subscribe")
async def subscribe(request: Request, db: Db, user: RequiredUser) -> Response:
"""Store what `pushManager.subscribe` handed back.
Idempotent on the endpoint, because a browser that re-subscribes returns the
same one — and two rows for one browser would be two notifications for one
arrival. Re-subscribing also **re-points it at whoever is signed in now**:
the endpoint belongs to the browser, so on a shared machine the second
person to turn notifications on must get them instead of the first, not as
well.
"""
payload = await request.json()
endpoint = str(payload.get("endpoint") or "").strip()[:MAX_ENDPOINT]
keys = payload.get("keys") or {}
p256dh = str(keys.get("p256dh") or "").strip()[:MAX_KEY]
auth = str(keys.get("auth") or "").strip()[:MAX_KEY]
if not endpoint.startswith("https://") or not p256dh or not auth:
return Response(status_code=status.HTTP_400_BAD_REQUEST)
existing = db.scalars(
select(PushSubscription).where(PushSubscription.endpoint == endpoint)
).first()
if existing is not None:
existing.user_id = user.id
existing.p256dh = p256dh
existing.auth_secret = auth
existing.last_error = ""
else:
db.add(
PushSubscription(
user_id=user.id,
endpoint=endpoint,
p256dh=p256dh,
auth_secret=auth,
label=str(request.headers.get("user-agent") or "")[:200],
)
)
db.commit()
log.info("%s registered a browser for notifications", user.email)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/unsubscribe")
async def unsubscribe(request: Request, db: Db, user: RequiredUser) -> Response:
"""Forget one browser.
Answers 204 whether or not there was anything to delete: the browser has
already dropped its own subscription by the time it calls this, and telling
it that the row was missing gives it nothing it could do about it.
"""
payload = await request.json()
endpoint = str(payload.get("endpoint") or "").strip()
row = db.scalars(
select(PushSubscription).where(
PushSubscription.endpoint == endpoint, PushSubscription.user_id == user.id
)
).first()
if row is not None:
db.delete(row)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+2
View File
@@ -93,6 +93,7 @@ from lembas.db.models.user import (
ROLE_ADMIN,
ROLE_PENDING,
Group,
PushSubscription,
Session,
User,
user_groups,
@@ -100,6 +101,7 @@ from lembas.db.models.user import (
__all__ = [
"AUTHOR_MODEL",
"PushSubscription",
"AUTH_KEY",
"AUTH_METHODS",
"AUTH_PASSWORD",
+7
View File
@@ -59,6 +59,13 @@ class Report(UUIDPrimaryKey, Timestamps, Base):
# NOT NULL with a scalar default so `migrations._add_column_sql` can backfill
# it if this column is ever added to a table that already has rows.
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Whether its arrival has already been announced. The dot can be shown for
# as long as it is unread; the toast and the browser notification must fire
# once. Without this the poll would announce the same report every ten
# seconds until somebody opened it, which is the shape of notification
# nobody leaves switched on. `Chat.unread_notified` exists for exactly this
# and this is the same pair.
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Why a run produced nothing worth reading. A scheduled report that failed
# is still a report -- one that silently did not appear is indistinguishable
# from a schedule that never fired.
+44
View File
@@ -105,3 +105,47 @@ class Session(UUIDPrimaryKey, Timestamps, Base):
Index("ix_sessions_user_id", Session.user_id)
class PushSubscription(UUIDPrimaryKey, Timestamps, Base):
"""One browser, on one device, that has agreed to be told.
Per device rather than per account, and that is not a detail: the permission
and the subscription both belong to a browser, so somebody signed in on a
laptop and a phone has two of these and revoking one must not silence the
other. It is also why there is no "notifications on" column on `User` -- the
presence of a row here *is* the state, and it cannot drift from what the
browser thinks.
`endpoint` is chosen by the browser vendor and is the address their push
service will accept a message at. Unique, because a browser that
re-subscribes hands back the same one and two rows would mean two
notifications for one arrival.
`p256dh` and `auth_secret` are the browser's half of the encryption. Stored
as the browser gave them, base64url: they are public key material and a
per-subscription salt, not credentials -- what they protect is the payload,
and a database holding them can already read everything the payload could
say. See services/push.py.
"""
__tablename__ = "push_subscriptions"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
endpoint: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
p256dh: Mapped[str] = mapped_column(String(255), nullable=False)
auth_secret: Mapped[str] = mapped_column(String(64), nullable=False)
# Which device this is, for a list somebody can revoke from. Whatever the
# browser says about itself, trimmed; never parsed.
label: Mapped[str] = mapped_column(String(200), default="")
# The last refusal from the push service, kept so a subscription that has
# stopped working says why rather than being silently useless. A 404 or 410
# deletes the row instead -- that is the end of its life, not a fault.
last_error: Mapped[str] = mapped_column(Text, default="")
user: Mapped[User] = relationship()
Index("ix_push_subscriptions_user_id", PushSubscription.user_id)
+2
View File
@@ -35,6 +35,7 @@ from lembas.api import (
messages,
pages,
preferences,
push,
reports,
schedules,
terminal,
@@ -187,6 +188,7 @@ def create_app() -> FastAPI:
app.include_router(admin_suggestions.router)
app.include_router(admin_tools.router)
app.include_router(admin_agents.router)
app.include_router(push.router)
register_error_handlers(app)
return app
+14
View File
@@ -34,6 +34,7 @@ from lembas.services import compaction as compaction_service
from lembas.services import interaction, settings_store, tokens, tool_labels
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import push as push_service
from lembas.services import tools as tools_service
from lembas.services.agent import policy as agent_policy
from lembas.services.agent import session as agent_session
@@ -2018,6 +2019,19 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
if generation.followers == 0 and not chat.temporary:
chat.unread = True
chat.unread_notified = False
# And out to any browser that asked to be told, which is the
# only channel that reaches somebody with nothing of ours open.
# Here rather than in the unread poll because the poll needs a
# page, and this is exactly the case where there is not one:
# `followers == 0` says so. Fire and forget -- the reply is
# finished and nothing about it should wait on a push service.
push_service.announce_later(
chat.user_id,
title=chat.title or "New reply",
body="Your reply is ready.",
url=f"/chat/{chat.id}",
kind="chat",
)
db.commit()
log.debug(
+401
View File
@@ -0,0 +1,401 @@
"""Web Push: a notification that arrives with nothing of ours running.
Everything else here is polled. `/api/chats/unread` runs in an open page, which
is enough for "a reply landed while you were on another chat" and is nothing at
all for the case this exists for -- a schedule firing at 07:00 on a laptop whose
browser is shut. There is no way to close that gap from inside a page, because
there is no page.
## The trade, stated plainly
A push goes to the **browser vendor's** push service: Google's for Chrome,
Mozilla's for Firefox, Apple's for Safari. The endpoint is chosen by the
browser and there is no version of this feature that avoids it. That sits
against "a self-hosted tool must not report page views to a third party", and
the answer is not that the tension is imaginary:
- The payload is encrypted end to end (RFC 8291) with a key derived from a
secret only the browser and this server hold, so the push service carries
bytes it cannot read.
- What it *does* learn is that this server sent something to that subscription,
and when. On a personal instance that is a timing channel over your own
activity, and it is real.
- So it is **opt-in per device**, off until somebody presses the button, and
the rest of the notification system works without it.
Nothing else in LLeMbas contacts an outside service on its own.
## Hand-rolled, and why
`pywebpush` would do this in three lines and bring `http-ece` and `py-vapid`
with it. The encryption below is one ECDH, two HKDFs and one AES-GCM seal, all
from `cryptography`, which is already a dependency because API keys are
Fernet-encrypted. That is the same call the MCP client makes: a hand-written
client, so what actually goes on the wire is in this repository.
## The two specifications
**RFC 8291** is the payload: `aes128gcm`, one record, the salt and the server's
ephemeral public key carried in the body's own header block.
**RFC 8292** is the authorisation: a JWT signed with a P-256 key whose public
half identifies this server. The keypair is generated once and kept in the
settings table with the private half Fernet-encrypted. It must be **stable** --
the public key is baked into every subscription a browser has made, so
regenerating it silently invalidates all of them.
## Failure is a subscription being dropped, not an error
A push service answers 404 or 410 for a subscription that no longer exists --
the browser was uninstalled, the site's data cleared, the permission revoked.
That is the normal end of a subscription's life and not a fault, so those two
delete the row. Everything else is logged and left, because a push service
having a bad hour is not a reason to lose somebody's registration.
"""
from __future__ import annotations
import base64
import hmac
import json
import logging
import os
import time
from dataclasses import dataclass
from hashlib import sha256
from typing import Any
from urllib.parse import urlsplit
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric import utils as asym_utils
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import PushSubscription, User
from lembas.services import settings_store
from lembas.services.crypto import decrypt, encrypt
log = logging.getLogger(__name__)
# How long a push service should hold an undelivered message. Four hours: long
# enough that a laptop opened after lunch still gets the morning's report, short
# enough that nothing arrives claiming to be news when it is a day old.
TTL_SECONDS = 4 * 3600
# The JWT's life. Twelve hours is the maximum RFC 8292 allows, and a short one
# buys nothing here -- it is minted per request.
JWT_SECONDS = 12 * 3600
# Bigger than any payload we send; the record size field must still be present
# and must exceed the ciphertext.
RECORD_SIZE = 4096
# What a payload may carry. A push service will refuse a large body outright,
# and there is nothing here worth more than a title and a line.
MAX_PAYLOAD_BYTES = 3000
SETTING_PRIVATE = "push_private_key"
SETTING_PUBLIC = "push_public_key"
# --- base64url, without padding, everywhere ------------------------------------
def b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def unb64(text: str) -> bytes:
padded = text + "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
# --- The server's identity -----------------------------------------------------
@dataclass(frozen=True)
class Keys:
private: ec.EllipticCurvePrivateKey
public_b64: str
def keys(db: DBSession) -> Keys:
"""This instance's VAPID keypair, generated once and kept.
Generated on first use rather than by a setup step, because a feature that
needs somebody to run a command before it works is one that is off on every
instance that did not read the release notes. The private half is Fernet-
encrypted at rest, like every other secret here.
**Never regenerated.** The public key is inside every subscription a browser
holds, so a new one silently invalidates all of them -- notifications simply
stop, with nothing anywhere saying why.
"""
stored = settings_store.get(db, SETTING_PRIVATE)
if stored:
private = serialization.load_pem_private_key(decrypt(str(stored)).encode(), password=None)
return Keys(private=private, public_b64=str(settings_store.get(db, SETTING_PUBLIC) or ""))
private = ec.generate_private_key(ec.SECP256R1())
pem = private.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
public_b64 = b64(_raw_public(private.public_key()))
settings_store.update(db, {SETTING_PRIVATE: encrypt(pem), SETTING_PUBLIC: public_b64})
log.info("generated a VAPID keypair for web push")
return Keys(private=private, public_b64=public_b64)
def public_key(db: DBSession) -> str:
"""What a browser needs in order to subscribe."""
return keys(db).public_b64
def _raw_public(key: ec.EllipticCurvePublicKey) -> bytes:
"""The uncompressed 65-byte point, which is the only form either spec uses."""
return key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
# --- RFC 8292: proving who is asking -------------------------------------------
def _jwt(private: ec.EllipticCurvePrivateKey, audience: str, subject: str) -> str:
header = b64(json.dumps({"typ": "JWT", "alg": "ES256"}, separators=(",", ":")).encode())
claims = b64(
json.dumps(
{"aud": audience, "exp": int(time.time()) + JWT_SECONDS, "sub": subject},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{claims}".encode()
der = private.sign(signing_input, ec.ECDSA(hashes.SHA256()))
# JWS wants the raw pair, not DER. `cryptography` only signs to DER, so it
# is decoded and re-emitted fixed-width -- a leading zero dropped here is a
# signature every push service rejects, and the error it gives is 401.
r, s = asym_utils.decode_dss_signature(der)
raw = r.to_bytes(32, "big") + s.to_bytes(32, "big")
return f"{header}.{claims}.{b64(raw)}"
def _audience(endpoint: str) -> str:
parts = urlsplit(endpoint)
return f"{parts.scheme}://{parts.netloc}"
# --- RFC 8291: the payload ------------------------------------------------------
def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
"""Extract-then-expand, written out because the two halves take different
salts here and `HKDF` in one call cannot express that."""
prk = hmac.new(salt, ikm, sha256).digest()
return HKDFExpand(algorithm=hashes.SHA256(), length=length, info=info).derive(prk)
def encrypt_payload(payload: bytes, *, p256dh: str, auth: str) -> bytes:
"""One `aes128gcm` record, ready to be the body of the POST.
The layout is the specification's, and the order matters to a parser that
has never seen our code:
salt (16) | record size (4) | key id length (1) | server key (65) | ct
"""
client_public = ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), unb64(p256dh)
)
auth_secret = unb64(auth)
server_private = ec.generate_private_key(ec.SECP256R1())
server_public = _raw_public(server_private.public_key())
shared = server_private.exchange(ec.ECDH(), client_public)
# The first HKDF is salted with the subscription's own auth secret and its
# info binds both public keys, which is what stops a captured record being
# replayed at a different subscriber.
key_info = b"WebPush: info\x00" + unb64(p256dh) + server_public
ikm = _hkdf(auth_secret, shared, key_info, 32)
salt = os.urandom(16)
content_key = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
# 0x02 is the padding delimiter for the last (here, only) record. 0x01 would
# say another follows, and a receiver would wait for it.
ciphertext = AESGCM(content_key).encrypt(nonce, payload + b"\x02", None)
return (
salt
+ RECORD_SIZE.to_bytes(4, "big")
+ len(server_public).to_bytes(1, "big")
+ server_public
+ ciphertext
)
# --- Sending --------------------------------------------------------------------
def subject_for(db: DBSession) -> str:
"""The `sub` claim: who to contact about this server's pushes.
A URL is as acceptable as a mailto and needs nothing configured, so the
instance's own base URL is used when there is one. Push services require the
claim to be present; none of them checks that it resolves.
"""
configured = str(settings_store.get(db, "public_url") or "").strip()
return configured or "https://lembas.invalid"
async def send_one(db: DBSession, subscription: PushSubscription, payload: dict[str, Any]) -> bool:
"""Deliver to one registration. True if it was accepted.
Never raises: this runs from arrival paths that must not fail because a push
service is having a bad hour.
"""
body = json.dumps(payload, separators=(",", ":")).encode()
if len(body) > MAX_PAYLOAD_BYTES: # pragma: no cover - titles are bounded already
body = json.dumps({"title": payload.get("title", "LLeMbas")}).encode()
try:
encrypted = encrypt_payload(
body, p256dh=subscription.p256dh, auth=subscription.auth_secret
)
token = _jwt(keys(db).private, _audience(subscription.endpoint), subject_for(db))
except Exception: # noqa: BLE001 - a malformed stored key must not kill a reply
log.exception("could not build a push for %s", subscription.id)
return False
headers = {
"TTL": str(TTL_SECONDS),
"Content-Encoding": "aes128gcm",
"Content-Type": "application/octet-stream",
# "high" would let a phone wake for it; this is news, not an alarm.
"Urgency": "normal",
"Authorization": f"vapid t={token}, k={keys(db).public_b64}",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(subscription.endpoint, content=encrypted, headers=headers)
except httpx.RequestError as exc:
log.warning("push to %s failed: %s", _audience(subscription.endpoint), exc)
return False
if response.status_code in (404, 410):
# The normal end of a subscription's life: uninstalled, cleared, or the
# permission revoked. Deleting it is the correct response and not an
# error -- keeping it would mean retrying forever against a dead address.
log.info("push subscription %s is gone; removing it", subscription.id)
db.delete(subscription)
db.commit()
return False
if response.status_code >= 400:
subscription.last_error = f"{response.status_code}: {response.text[:200]}"
db.commit()
log.warning(
"push to %s refused: %s %s",
_audience(subscription.endpoint),
response.status_code,
response.text[:200],
)
return False
if subscription.last_error:
subscription.last_error = ""
db.commit()
return True
def subscriptions_for(db: DBSession, user: User | None) -> list[PushSubscription]:
if user is None:
return []
return list(
db.scalars(select(PushSubscription).where(PushSubscription.user_id == user.id))
)
async def announce(
db: DBSession, user: User | None, *, title: str, body: str, url: str = "", kind: str = ""
) -> int:
"""Tell every device this person has registered. Returns how many took it.
Called at the moment something arrives rather than from the poll, because
the whole point is the case where no page is open to poll. A device with a
page open gets this *and* the in-page toast -- the service worker resolves
that by not showing a notification when one of its own windows is focused,
which is the only place that can be known.
"""
subscriptions = subscriptions_for(db, user)
if not subscriptions:
return 0
payload = {"title": title, "body": body, "url": url, "kind": kind}
delivered = 0
for subscription in list(subscriptions):
if await send_one(db, subscription, payload):
delivered += 1
return delivered
# Fire-and-forget tasks, held so the event loop does not collect one mid-flight.
# asyncio keeps only a weak reference to a task nobody awaits, and a push that
# vanishes halfway is the kind of intermittent nobody reproduces.
_TASKS: set[Any] = set()
def announce_later(user_id: str, *, title: str, body: str, url: str = "", kind: str = "") -> None:
"""Announce from a path that must not wait for it, and must not fail with it.
Called where something *arrives* -- a reply finishing with nobody watching,
a report being filed, a run posting into Messages -- rather than from the
poll. That is the whole point: the poll needs an open page, and the case
worth a notification is the one where there is none.
Each of those sites runs exactly once per arrival, which is what makes this
fire once with no "already notified" flag of its own. `unread_notified` is
the *page's* record of having toasted; borrowing it here would mean whichever
channel got there first silenced the other.
Its own session, opened inside the task: the caller's is usually about to be
committed and closed, and holding one open across a POST to somebody else's
server is how a request comes to wait on a push service having a bad day.
"""
import asyncio
from lembas.db.session import session_scope
# The loop is checked *before* the coroutine is built, not by catching what
# `create_task` raises without one. A coroutine made and then dropped is a
# "never awaited" RuntimeWarning from wherever it was created -- which here
# is every synchronous caller in the suite and every CLI command that files
# a report. The warning would be the only symptom, and it would be
# attributed to the caller rather than to this.
try:
asyncio.get_running_loop()
except RuntimeError:
return
async def run() -> None:
try:
with session_scope() as db:
user = db.get(User, user_id)
if user is None:
return
await announce(db, user, title=title, body=body, url=url, kind=kind)
except Exception: # noqa: BLE001 - nothing upstream can act on this
log.exception("could not announce to %s", user_id)
task = asyncio.create_task(run())
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
__all__ = [
"announce",
"announce_later",
"encrypt_payload",
"keys",
"public_key",
"send_one",
"subscriptions_for",
]
+42
View File
@@ -85,6 +85,32 @@ def unread_count(db: DBSession, user: User | None) -> int:
)
def unannounced(db: DBSession, user: User | None) -> list[Report]:
"""Reports that have arrived and have not been announced yet.
Separate from `unread_count`, which drives the dot: the dot may be shown for
as long as something is unread, while an announcement fires once. Reading
them apart is what stops the poll interrupting somebody every ten seconds
with the same report until they open it.
Ordered oldest first, so several arriving between two ticks are announced in
the order they were filed.
"""
if user is None:
return []
return list(
db.scalars(
select(Report)
.where(
Report.owner_id == user.id,
Report.unread.is_(True),
Report.unread_notified.is_(False),
)
.order_by(Report.created_at)
)
)
def _first_line(body: str) -> str:
"""A summary for a model that did not write one.
@@ -137,6 +163,22 @@ def create(
)
db.add(report)
db.commit()
# Here rather than at the scheduled-run site, because a report is filed from
# two places -- a schedule delivering one, and a model calling `report_write`
# in a chat nobody stayed on -- and both are arrivals somebody would want to
# know about. `unread` is what says it is news; a report filed with it off
# was made by the person reading this screen.
if report.unread:
from lembas.services import push as push_service
push_service.announce_later(
report.owner_id,
title=report.title or "Report filed",
body=report.summary or "A report is waiting for you.",
url=f"/reports/{report.id}",
kind="report",
)
return report
+26
View File
@@ -122,6 +122,18 @@ def _finished_reply(db, chat_id: str, message_id: str) -> Message | None:
return message
def _preview(text: str, limit: int = 160) -> str:
"""The opening of a reply, as the body of a notification.
A notification saying "a scheduled run finished" is one somebody has to open
something to understand, which is most of the reason notifications get
ignored. Flattened to one line because a push service and an operating
system will each do their own thing with newlines.
"""
flat = " ".join((text or "").split())
return flat[: limit - 1] + "" if len(flat) > limit else flat
async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None:
"""Put a finished reply where the schedule said it should go.
@@ -215,6 +227,20 @@ async def deliver(schedule_id: str, message_id: str, *, since: datetime) -> None
conversation.unread_notified = False
db.commit()
# The arrival this whole channel exists for: a run that fired while
# nobody was here, landing somewhere they are not looking. The first
# line of the reply is the body, because "a scheduled run finished"
# is a notification you have to open something to understand.
from lembas.services import push as push_service
push_service.announce_later(
owner.id,
title=schedule.title or "Scheduled run",
body=_preview(message.content or ""),
url="/messages",
kind="message",
)
async def fire(schedule_id: str, *, due_at: datetime | None = None) -> None:
"""Run one schedule now.
+69
View File
@@ -120,3 +120,72 @@ self.addEventListener("fetch", function (event) {
})
);
});
/*
Notifications that arrive with no page open.
This is the only part of LLeMbas that runs when nothing of ours is on screen,
and it is why web push exists here at all: everything else is polled by an open
page, which is exactly what is missing at seven in the morning when a schedule
fires and the laptop is shut.
The payload was encrypted end to end (see services/push.py), so what arrives
here is the first plaintext anybody but this browser and that server has seen.
*/
self.addEventListener("push", function (event) {
var payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch (error) {
payload = { title: "LLeMbas", body: "Something new arrived." };
}
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) {
/* Somebody is looking at it. The page has its own toast and its own
count in the tab title, and a system notification on top of those is
the same news three times -- which is how notifications come to be
switched off for good. This is the only place that can be known: the
server cannot see whether a window is focused, and the page cannot see
a push that it did not receive. */
for (var i = 0; i < clients.length; i++) {
if (clients[i].focused) return null;
}
return self.registration.showNotification(payload.title || "LLeMbas", {
body: payload.body || "",
/* One at a time. A browser left closed all day must not be opened to a
stack of twelve. */
tag: "lembas-" + (payload.kind || "unread"),
renotify: true,
icon: "/static/img/icon-192.png",
badge: "/static/img/icon-192.png",
data: { url: payload.url || "/" },
});
})
);
});
/*
Clicking one.
Focus a window that is already open rather than opening a second: somebody
with LLeMbas open in a tab wants that tab, and `openWindow` would give them
two. `navigate` moves the one they have to whatever arrived.
*/
self.addEventListener("notificationclick", function (event) {
event.notification.close();
var target = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) {
for (var i = 0; i < clients.length; i++) {
var client = clients[i];
if (new URL(client.url).origin !== self.location.origin) continue;
return client.focus().then(function (focused) {
return focused && focused.navigate ? focused.navigate(target) : focused;
});
}
return self.clients.openWindow(target);
})
);
});
+321 -13
View File
@@ -434,22 +434,330 @@
})();
/*
Unread replies.
Something arrived.
The sidebar polls /api/chats/unread; the response carries out-of-band spans
for the dots and, when something has just landed, an HX-Trigger asking for a
toast. Announcing it here rather than server-side keeps the wording and the
timing in one place.
*/
document.addEventListener("lembas:unread", function (event) {
var titles = (event.detail && event.detail.titles) || [];
if (!titles.length || !window.lembas || !window.lembas.notify) return;
for the dots and, when something has just landed, an HX-Trigger listing it.
Announcing it here rather than server-side keeps the wording and the timing in
one place.
var message = titles.length === 1
? "Reply ready in “" + titles[0] + "”"
: titles.length + " chats have new replies";
window.lembas.notify(message, { kind: "success", timeout: 6000 });
});
Three things happen, and they are deliberately not the same thing three times:
- A **toast**, always. It is the answer for somebody who is looking at the
page, and it is the only one of the three that needs no permission and
cannot be switched off by an operating system.
- A **count in the tab title**, while the tab is not the one being looked at.
This is the part that was missing and that nothing else replaces: a schedule
that fires while you are in another tab lit a green dot in a corner you
could not see. Cleared the moment the page is looked at again, because a
badge you have to dismiss is worse than none.
- A **browser notification**, if the reader has asked for one. Only while the
page is hidden -- notifying somebody about something they are watching
happen is the behaviour that gets notifications turned off for good.
*/
(function () {
var NOTIFY_KEY = "lembas-desktop-notifications";
/* Kept per browser rather than on the account, because the *permission* is
per browser and per origin. A preference that followed somebody to a
machine where they had never granted it would be a switch that reads "on"
and does nothing. */
var baseTitle = document.title;
var pending = 0;
function wanted() {
try {
return window.localStorage.getItem(NOTIFY_KEY) === "1";
} catch (error) {
return false;
}
}
function setWanted(on) {
try {
window.localStorage.setItem(NOTIFY_KEY, on ? "1" : "0");
} catch (error) { /* private mode; the toast still works */ }
}
function retitle() {
document.title = pending > 0 ? "(" + pending + ") " + baseTitle : baseTitle;
}
/* The title is rewritten by navigation and by a rename arriving out of band,
so the base is re-read rather than captured once. Without this, renaming a
chat while something is unread would pin the old name until a reload. */
function rebase() {
var shown = document.title;
var stripped = shown.replace(/^\(\d+\)\s*/, "");
if (stripped !== baseTitle) { baseTitle = stripped; retitle(); }
}
function clear() {
if (!pending) return;
pending = 0;
retitle();
}
document.addEventListener("visibilitychange", function () {
if (!document.hidden) clear();
});
window.addEventListener("focus", clear);
function describe(items) {
if (items.length > 1) return items.length + " new arrivals";
var item = items[0];
if (item.kind === "report") return "Report filed: “" + item.title + "”";
if (item.kind === "message") return "New message";
return "Reply ready in “" + item.title + "”";
}
/* `registration.showNotification` where there is a service worker, because
`new Notification()` throws outright on Android Chrome -- so the plain
constructor alone would work on every desktop it was tested on and on no
phone at all. */
function show(message, url) {
if (!wanted() || !("Notification" in window)) return;
if (Notification.permission !== "granted") return;
/* `tag` collapses several into one: a browser left in the background for an
hour must not come back to a stack of them. */
var options = {
body: message,
tag: "lembas-unread",
icon: "/static/img/icon-192.png",
data: { url: url },
};
if (navigator.serviceWorker && navigator.serviceWorker.ready) {
navigator.serviceWorker.ready
.then(function (registration) { registration.showNotification("LLeMbas", options); })
.catch(function () { plain(options, url); });
return;
}
plain(options, url);
}
function plain(options, url) {
try {
var notification = new Notification("LLeMbas", options);
notification.onclick = function () { window.focus(); if (url) location.href = url; };
} catch (error) { /* unsupported; the toast and the title still carry it */ }
}
document.addEventListener("lembas:unread", function (event) {
var items = (event.detail && event.detail.items) || [];
if (!items.length) return;
var message = describe(items);
if (window.lembas && window.lembas.notify) {
window.lembas.notify(message, { kind: "success", timeout: 6000 });
}
if (document.hidden) {
rebase();
pending += items.length;
retitle();
show(message, items.length === 1 ? items[0].url : "");
}
});
/* Asking. `requestPermission` must be called from a gesture, so every path to
it is a button somebody pressed -- a preference restored on load and acted
on is refused by the browser with nothing said anywhere.
Shared by the Settings toggle and by the one-time offer below, because two
copies of "ask, then interpret the three answers" is two places for the
denied case to be got wrong. */
function ask(onSettled) {
if (!("Notification" in window)) {
window.lembas.notify("This browser has no notifications to offer.", { kind: "error" });
return;
}
if (Notification.permission === "denied") {
window.lembas.notify(
"Notifications are blocked for this site in your browser's own settings, " +
"which is the only place that can be undone.",
{ kind: "error", timeout: 8000 }
);
return;
}
Notification.requestPermission().then(function (result) {
if (result !== "granted") {
window.lembas.notify("Left off — nothing was changed.");
if (onSettled) onSettled();
return;
}
setWanted(true);
if (onSettled) onSettled();
subscribe().then(function (pushed) {
window.lembas.notify(
pushed
? "Notifications on, including while LLeMbas is closed."
: "Notifications on while LLeMbas is open.",
{ kind: "success", timeout: 6000 }
);
});
});
}
/*
Registering with the browser's push service.
This is what makes a notification arrive with nothing of ours running --
the poll above needs an open page, and the case worth notifying about is a
schedule firing at seven in the morning.
Best effort, always. It needs a service worker (so HTTPS or localhost), a
push service the browser can reach, and a `PushManager` that some browsers
do not have; every one of those fails to "notifications while LLeMbas is
open", which still works. A permission granted and a subscription refused
must not read as a failure, because most of the feature is still there.
*/
function subscribe() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return Promise.resolve(false);
}
return fetch("/api/push/key")
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (data) {
if (!data || !data.key) return false;
return navigator.serviceWorker.ready.then(function (registration) {
return registration.pushManager.subscribe({
/* Required, and not merely conventional: a browser refuses a
subscription that does not promise every push will be shown to
somebody. It is also why the worker's `push` handler always ends
in a notification unless a window is focused. */
userVisibleOnly: true,
applicationServerKey: bytes(data.key),
});
}).then(function (subscription) {
return fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
}).then(function (response) { return response.ok; });
});
})
.catch(function () { return false; });
}
function unsubscribe() {
if (!("serviceWorker" in navigator)) return Promise.resolve();
return navigator.serviceWorker.ready
.then(function (registration) { return registration.pushManager.getSubscription(); })
.then(function (subscription) {
if (!subscription) return null;
var endpoint = subscription.endpoint;
return subscription.unsubscribe().then(function () {
return fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: endpoint }),
});
});
})
.catch(function () { return null; });
}
/* base64url to bytes. `applicationServerKey` wants the raw 65-byte point and
will not take the string, and `atob` will not take base64url -- the two
substitutions and the padding are the whole of this. */
function bytes(text) {
var padded = (text + "===".slice((text.length + 3) % 4))
.replace(/-/g, "+")
.replace(/_/g, "/");
var raw = window.atob(padded);
var out = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
document.addEventListener("click", function (event) {
var button = event.target.closest("[data-notify-toggle]");
if (!button) return;
event.preventDefault();
if (wanted()) {
setWanted(false);
// The registration goes too. Leaving it would mean this server kept
// sending to a browser that has been told to stop showing them, which is
// traffic to a third party for something switched off.
unsubscribe();
paint(button);
return;
}
ask(function () { paint(button); });
});
/*
Offering, once.
The browser's own permission box cannot be called on page load and should
not be: it appears with no context, and a box somebody dismisses without
reading is a permission that can only be undone in browser settings they
will never find. So the offer is ours first -- a themed dialog that says
what the notifications are for -- and pressing its button is the gesture the
browser needs.
Once, ever, per browser. "Not now" is recorded exactly as firmly as "yes":
an offer that comes back is the thing that makes people block a site to
silence it, and Settings has the switch for anybody who changes their mind.
*/
var ASKED_KEY = "lembas-notifications-asked";
function offer() {
if (!document.body || !document.body.dataset.authenticated) return;
if (!("Notification" in window) || Notification.permission !== "default") return;
try {
if (window.localStorage.getItem(ASKED_KEY)) return;
window.localStorage.setItem(ASKED_KEY, "1");
} catch (error) {
return; // no way to remember having asked, so do not ask
}
if (!window.lembas || !window.lembas.confirm) return;
window.lembas.confirm({
title: "Notifications",
message:
"Let LLeMbas tell you when a reply, a report or a scheduled run arrives " +
"while you are looking at something else?",
confirmLabel: "Turn on",
cancelLabel: "Not now",
}).then(function (yes) { if (yes) ask(scan); });
}
function paint(button) {
var on = wanted() && "Notification" in window && Notification.permission === "granted";
button.textContent = on ? "Turn off notifications" : "Turn on notifications";
button.setAttribute("aria-pressed", on ? "true" : "false");
var hint = document.querySelector("[data-notify-state]");
if (!hint) return;
if (!("Notification" in window)) {
hint.textContent = "This browser has no notifications to offer.";
} else if (Notification.permission === "denied") {
hint.textContent =
"Blocked for this site in your browser's settings, which is the only " +
"place that can be undone.";
} else if (on) {
hint.textContent = "On in this browser. Nothing is shown while you are looking at the page.";
} else {
hint.textContent = "Off in this browser.";
}
}
function scan() {
document.querySelectorAll("[data-notify-toggle]").forEach(paint);
}
function start() {
scan();
/* After the page has settled rather than during it: the offer is a dialog,
and one that appears while the shell is still being painted reads as an
error rather than as a question. */
setTimeout(offer, 1500);
}
document.addEventListener("DOMContentLoaded", start);
document.body && start();
document.addEventListener("htmx:afterSettle", scan);
})();
/*
A toast asked for by the server.
+27
View File
@@ -208,6 +208,33 @@
{% endif %}
</div>
<div class="card">
<h2 class="card__title">Notifications</h2>
<p class="card__lede">
A reply, a filed report or a scheduled run arriving while you are
looking at something else. In the page always; from the browser
as well if you allow it here.
</p>
{# A button rather than a checkbox, and that is not a style choice:
the browser refuses `requestPermission` unless it is called from
something somebody pressed. A checkbox restored on load and
acted upon would be silently declined. ui.js writes the label
and the line below it, because the true state lives in the
browser's own permission and not in anything rendered here. #}
<div class="btn-row">
<button class="btn" type="button" data-notify-toggle aria-pressed="false">
Turn on notifications
</button>
</div>
<p class="field__hint" data-notify-state>
Checking what this browser allows…
</p>
<p class="field__hint">
Kept per browser, because the permission is. Nothing is shown
while you are looking at the page — the toast is there for that.
</p>
</div>
<div class="card">
<h2 class="card__title">Install as an app</h2>
<p class="card__lede">