Benachrichtigungen: Geraet, Abos und der Reiter Meldungen

Der Geraetesuchlauf legt "Benachrichtigungen" an (benachrichtigung_module.py),
ein Kommando je Kanal. Damit kann jede Automatik melden, ohne dass Editor,
Zeitleiste oder Datenbank etwas davon wissen muessten.

Fuer die Meldung aufs Handy braucht es keine App: Der Browser meldet sich
unter Einstellungen -> Meldungen selbst an (sw.js, ajax/push.php), das Abo
steht in homeMesh.push_abos, verschickt wird vom Runner. Auf dem iPhone geht
es erst ab der Ablage auf dem Home-Bildschirm - der Reiter sagt das auch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 08:37:31 +02:00
co-authored by Claude Opus 5
parent 061dfa3fc5
commit df48432133
10 changed files with 720 additions and 1 deletions
+124
View File
@@ -0,0 +1,124 @@
/*
* Benachrichtigungen auf diesem Gerät ein- und ausschalten.
*
* Web Push braucht drei Dinge, und alle drei gibt es hier schon: eine
* sichere Verbindung, einen Service Worker (sw.js im Wurzelverzeichnis) und
* ein Schlüsselpaar (VAPID, liegt auf der NAS). Eine eigene App braucht es
* nicht - unter iOS muss die Seite allerdings einmal über "Zum
* Home-Bildschirm" abgelegt werden, sonst verweigert Safari die Erlaubnis.
*
* Das Abo gehört dem Browser, nicht dem Konto: Jedes Gerät meldet sich
* einzeln an. Gespeichert wird es in homeMesh.push_abos (ajax/push.php),
* verschickt vom Runner auf der NAS.
*/
const PUSH_ENDPUNKT = "ajax/push.php";
let pushStand = { schluessel: "", abos: [] };
/** base64url aus der Datenbank in den Bytesalat, den der Browser will. */
function pushSchluesselBytes(text) {
const voll = (text + "=".repeat((4 - text.length % 4) % 4)).replace(/-/g, "+").replace(/_/g, "/");
const roh = atob(voll);
return Uint8Array.from(roh, z => z.charCodeAt(0));
}
/** Was der Browser über sich verrät, auf das Nötige gekürzt. */
function pushBrowserName() {
const ua = navigator.userAgent;
const name = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera"
: /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari"
: /Firefox\//.test(ua) ? "Firefox" : "Browser";
const os = /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS"
: /Windows/.test(ua) ? "Windows" : /Mac OS/.test(ua) ? "Mac" : "";
return os ? name + " · " + os : name;
}
function pushMoeglich() {
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
}
async function pushAnfrage(action, body) {
const antwort = await fetch(PUSH_ENDPUNKT + "?action=" + action, body ? {
method: "POST",
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
body: JSON.stringify(body),
} : { cache: "no-store" });
const d = await antwort.json();
if (d.error) throw new Error(d.error);
return d;
}
/** Das Abo dieses Browsers, oder null. */
async function pushAbo() {
if (!pushMoeglich()) return null;
const reg = await navigator.serviceWorker.getRegistration();
return reg ? await reg.pushManager.getSubscription() : null;
}
/**
* Auf diesem Gerät anmelden.
*
* Die Reihenfolge ist wichtig: erst den Service Worker, dann die Erlaubnis,
* dann das Abo. Fragt man zuerst nach der Erlaubnis und der Service Worker
* scheitert danach, hat der Nutzer eine Erlaubnis vergeben, die zu nichts
* führt - und der Browser fragt kein zweites Mal.
*/
async function pushAnmelden(name) {
if (!pushMoeglich()) {
throw new Error("Dieser Browser kann keine Benachrichtigungen empfangen.");
}
if (!pushStand.schluessel) {
throw new Error("Auf der NAS fehlt der Schlüssel (push_vapid.json).");
}
const reg = await navigator.serviceWorker.register("sw.js");
await navigator.serviceWorker.ready;
const erlaubnis = await Notification.requestPermission();
if (erlaubnis !== "granted") {
throw new Error(erlaubnis === "denied"
? "Benachrichtigungen sind für diese Seite blockiert im Browser unter „Website-Einstellungen“ wieder erlauben."
: "Ohne Erlaubnis geht es nicht.");
}
const abo = await reg.pushManager.getSubscription()
|| await reg.pushManager.subscribe({
// Ohne diese Zusage weigern sich die Browser: Jede Nachricht muss den
// Nutzer erreichen, stille Datenübertragung ist nicht erlaubt.
userVisibleOnly: true,
applicationServerKey: pushSchluesselBytes(pushStand.schluessel),
});
const d = await pushAnfrage("anmelden", {
abo: abo.toJSON(),
name: name || pushBrowserName(),
});
pushStand.abos = d.abos;
return d;
}
/** Auf diesem Gerät abmelden - Abo im Browser und Zeile in der Datenbank. */
async function pushAbmelden() {
const abo = await pushAbo();
if (!abo) return;
const endpoint = abo.endpoint;
await abo.unsubscribe();
const d = await pushAnfrage("abmelden", { endpoint: endpoint });
pushStand.abos = d.abos;
}
/** Ein fremdes Gerät aus der Liste werfen (Handy verkauft, Browser weg). */
async function pushEntfernen(id) {
const d = await pushAnfrage("entfernen", { id: id });
pushStand.abos = d.abos;
}
/** Probemeldung über den Runner an alle angemeldeten Geräte. */
async function pushProbe() {
await pushAnfrage("probe", { titel: "Probe", text: "Wenn das ankommt, stimmt der Weg." });
}
async function pushStandLaden() {
pushStand = await pushAnfrage("stand");
return pushStand;
}