Geprüft: Die Benachrichtigung kommt an, wenn die App ZU ist

Filipe: „mach noch einen check ob alles mit den benachrichtigungen jetzt
perfekt klappt und dass die leute sie auch bekommen wenn die app zu ist."

DREI PRUEFUNGEN GAB ES SCHON -- UND KEINE BEANTWORTET DIE FRAGE

    pruef-push       die Verschluesselung, gegen die Testvektoren aus
                     RFC 8291 und RFC 8292 gerechnet        (24 ok)
    pruef-push-weg   Zustellung, TTL, VAPID-Kopf, 410-Fall  (20 ok)
    pruef-push-ziel  wer was bekommt und wer nicht          (38 ok)

Sie hoeren alle beim Push-Dienst auf. Danach faengt der Teil an, um den
es geht: Der Browser muss den Service Worker AUFWECKEN, obwohl keine
Seite offen ist, und der muss etwas anzeigen.

NEU: pruef-push-zu.mjs (13 Pruefungen)

    1. Seite auf, Service Worker meldet sich an.
    2. ALLE Seiten des Workspace zu -- nachgezaehlt, nicht behauptet.
    3. Ein echter Push ueber das DevTools-Protokoll
       (`ServiceWorker.deliverPushMessage`) -- derselbe Weg, den
       Apple und Google benutzen.
    4. Erst DANACH wieder eine Seite, und gefragt, was dasteht.

Eine Meldung, die in Schritt 4 dasteht, kann nur in Schritt 3
entstanden sein. Gemessen:

    nach dem Push steht 1 Meldung da — bei geschlossener App
      „Neue Nachricht" · „VanVan hat dir geschrieben."
      sie weiss, wohin sie fuehrt (/workspace/chat.html)
      und traegt das Gesicht des richtigen Hauses (crew-192.png)
      mit dem Abzeichen fuer die Statusleiste (abzeichen-96.png)

DAS MESSINSTRUMENT IST EINE LEERE SEITE, und das steht so im Kommentar:
`ServiceWorker.enable` gibt es nur an einer SEITE, nicht am Browser
(nachgemessen -- am Browser antwortet das Protokoll „wasn't found").
Eine Sitzung an der App-Seite stirbt mit ihr. `about:blank` gehoert
nicht zum Haus, und dass KEINE Workspace-Seite mehr offen ist, wird
ausdruecklich gezaehlt.

AUCH EIN PUSH OHNE DATEN ZEIGT ETWAS AN. Das ist kein Schoenheitstest:
Ein Browser, der eine Push-Berechtigung hat und mehrmals schweigt,
ENTZIEHT sie wieder -- ab da kommt gar nichts mehr an. Der Fehler, der
sich selbst verschlimmert. Gemessen: „Creator Workspace · Es gibt
etwas Neues."

DER DRITTE AUSGANG, UND ER WAR NOETIG

Mein erster Lauf meldete „nach dem Push steht 0 Meldungen da" -- das
sah aus wie ein schwerer Befund am Haus. Es war der Browser.
Fuenf Aufbauten gemessen, eine Antwort:

    headless (Vorgabe), grant mit origin       -> denied
    headless (Vorgabe), grant ohne origin      -> denied
    headless (Vorgabe), permissions im Kontext -> denied
    headless=old                               -> denied
    mit Fenster (headless: false)              -> GRANTED

Ein kopfloser Chromium verweigert Benachrichtigungen, egal wie man die
Erlaubnis erteilt. Die Pruefung oeffnet deshalb ein Fenster -- und wenn
die Berechtigung trotzdem fehlt, endet sie mit Rueckgabewert 2 und dem
Satz „konnte nicht nachsehen. Das ist KEIN Befund am Haus." Eine
Pruefung, die ihre Voraussetzung nicht hat, darf nicht rot werden.

WAS DAMIT NICHT BEWIESEN IST, und das gehoert in denselben Absatz: ob
ein bestimmtes Handy sie auch anzeigt. Das haengt an den Einstellungen
des Geraets (Nicht stoeren, Berechtigung entzogen, auf dem iPhone die
Installation auf dem Startbildschirm). Geprueft ist der Weg bis zum
Browser, nicht die Laune des Telefons.

AM LAUFENDEN SYSTEM NACHGESEHEN (nur gelesen)

Zehn Anmeldungen, alle gesund -- `fehler = 0` bei jeder einzelnen, und
`zuletzt_ok` bei vieren auf heute 12:31 Uhr. Der Push-Dienst hat also
heute Zustellungen angenommen, und der laeuft ueber Apple und Google,
nicht ueber eine offene Seite.

    BananaStift  iPhone, Android, Windows   zuletzt ok 01.10. 18:18
    Diene        Android                    heute 12:31
    Dogfather    Android                    heute 09:18
    Ghost        Android                    heute 12:31
    Marina       Android                    heute 09:53
    Miss         iPhone                     heute 12:31
    Tamy         Android                    01.10. 18:18
    VanVan       Android                    heute 12:31

GEPRUEFT: pruef-push-zu 13 ok · pruef-push 24 · pruef-push-weg 20 ·
pruef-push-ziel 38 · pruef-ports 10 · pruef-portnummern 41 ·
pruef-pruefzaehler 7. (Die Portnummern leiten sich aus der
alphabetischen Stelle ab -- eine neue Pruefdatei verschiebt sie.)

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-10-02 14:53:17 +02:00
co-authored by Claude Opus 5
parent 4c08c8859d
commit 664b799568
+306
View File
@@ -0,0 +1,306 @@
/* KOMMT EINE BENACHRICHTIGUNG AN, WENN DIE APP ZU IST? (02.10.2026)
=====================================================================
Filipe: „mach noch einen check ob alles mit den benachrichtigungen
jetzt perfekt klappt und dass die leute sie auch bekommen wenn die
app zu ist."
WARUM ES DAFUER EINE EIGENE PRUEFUNG BRAUCHT
Drei Pruefungen gibt es schon, und sie decken viel ab:
pruef-push die Verschluesselung, gegen die Testvektoren
aus RFC 8291 und RFC 8292 gerechnet
pruef-push-weg Zustellung, TTL, VAPID-Kopf, der 410-Fall
pruef-push-ziel wer was bekommt und wer nicht
Keine davon beantwortet die Frage. Sie hoeren alle beim Push-Dienst
auf -- und genau danach faengt der Teil an, um den es Filipe geht:
Der Browser muss den Service Worker AUFWECKEN, obwohl keine Seite
offen ist, und der muss eine Meldung anzeigen.
WAS HIER WIRKLICH PASSIERT -- und das ist der Punkt:
1. Die Seite wird geoeffnet, der Service Worker meldet sich an.
2. ALLE SEITEN WERDEN GESCHLOSSEN. Danach gibt es kein Fenster
mehr, kein Skript laeuft, nichts haengt an einer Verbindung.
3. Dem Browser wird ueber das DevTools-Protokoll ein echter
Push zugestellt (`ServiceWorker.deliverPushMessage`) --
derselbe Weg, den Apple und Google benutzen.
4. Erst DANACH wird wieder eine Seite geoeffnet und gefragt,
welche Meldungen dastehen (`registration.getNotifications()`).
Eine Meldung, die in Schritt 4 dasteht, kann nur in Schritt 3
entstanden sein -- und da war nichts offen.
WAS DAMIT NICHT BEWIESEN IST, und das gehoert dazu: Ob ein
bestimmtes Handy sie auch ANZEIGT. Das haengt an den Einstellungen
des Geraets (Nicht stoeren, Berechtigung entzogen, auf dem iPhone
die Installation auf dem Startbildschirm). Geprueft wird der Weg
bis zum Browser, nicht die Laune des Telefons.
DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht nachsehen.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-pushzu-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Pruefung bei geschlossener App");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(300_000, "pruef-push-zu");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }),
}).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0001", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
d.close();
const { chromium } = await import("playwright");
/* ==== MIT FENSTER, UND ZWAR NOTWENDIG ========================
Gemessen, nicht vermutet -- fuenf Aufbauten, eine Antwort:
headless (Vorgabe), grant mit origin -> denied
headless (Vorgabe), grant ohne origin -> denied
headless (Vorgabe), permissions im Kontext -> denied
headless=old -> denied
mit Fenster (headless: false) -> GRANTED
Ein kopfloser Chromium verweigert Benachrichtigungen, egal wie
man die Erlaubnis erteilt. Das ist eine Eigenschaft des
Browsers und kein Befund am Haus -- beim ersten Anlauf sah es
genau danach aus („nach dem Push steht 0 Meldungen da").
Diese Pruefung oeffnet deshalb ein Fenster. Es ist kurz da und
geht wieder zu. */
const browser = await chromium.launch({ headless: false });
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
/* Ohne diese Erlaubnis zeigt der Browser gar nichts an -- und die
Pruefung haette gemessen, dass eine Berechtigung fehlt. */
permissions: ["notifications"],
});
/* DIE ERLAUBNIS BRAUCHT EINE HERKUNFT. Ohne `origin` meldet die
Seite `Notification.permission === "denied"` -- gemessen, nicht
vermutet, und es sah aus wie ein Befund am Haus. */
await kontext.grantPermissions(["notifications"], { origin: BASIS });
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 20000 });
/* ---- 1. Der Service Worker meldet sich an --------------------------- */
const angemeldet = await seite.evaluate(async () => {
if (!("serviceWorker" in navigator)) return { keiner: true };
const reg = await navigator.serviceWorker.register("/workspace/sw.js",
{ scope: "/workspace/" });
await navigator.serviceWorker.ready;
return { bereich: reg.scope, aktiv: !!reg.active };
});
ok(!angemeldet.keiner && angemeldet.aktiv === true,
`der Service Worker ist angemeldet und aktiv (${angemeldet.bereich})`);
/* ---- 1b. DARF DER BROWSER UEBERHAUPT? -------------------------------
DER DRITTE AUSGANG, und er ist hier keine Formsache: Ohne erteilte
Berechtigung zeigt `showNotification` gar nichts an, und alles
darunter waere rot -- mit einem Befund, der dem Haus gehoert,
obwohl er dem Browser gehoert. Genau so sah mein erster Lauf aus.
„Konnte nicht nachsehen" ist etwas anderes als „es geht nicht". */
const erlaubnis = await seite.evaluate(() => Notification.permission);
if (erlaubnis !== "granted") {
console.log(`\n KONNTE NICHT NACHSEHEN: Der Browser erlaubt keine `
+ `Benachrichtigungen (Notification.permission = "${erlaubnis}").\n`
+ " Gemessen am 02.10.2026: Ein kopfloser Chromium sagt immer „denied“,\n"
+ " egal wie die Erlaubnis erteilt wird. Diese Pruefung braucht ein\n"
+ " Fenster. Das ist KEIN Befund am Haus.\n");
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
ok(true, `der Browser erlaubt Benachrichtigungen (${erlaubnis})`);
/* ---- 2. Seine Nummer, ueber das DevTools-Protokoll ------------------
`deliverPushMessage` braucht die Registrierungsnummer. Sie steht
nirgends im Browser zur Verfuegung -- nur das Protokoll kennt sie.
DAS MESSINSTRUMENT IST EINE LEERE SEITE, und das ist mit Absicht so
und keine Schlamperei: `ServiceWorker.enable` gibt es nur an einer
SEITE, nicht am Browser (nachgemessen, nicht geraten -- am Browser
antwortet das Protokoll „wasn't found"). Eine Sitzung an der
App-Seite stirbt mit ihr, und genau die soll ja zugemacht werden.
`about:blank` gehoert nicht zum Haus: kein Skript von dort, kein
Fenster der App, keine Verbindung. Dass zum Zeitpunkt der
Zustellung KEINE Seite des Workspace offen ist, wird unten
ausdruecklich nachgezaehlt -- das ist die Behauptung, nicht „der
Browser ist zu". */
const traeger = await kontext.newPage();
await traeger.goto("about:blank");
const cdp = await kontext.newCDPSession(traeger);
let nummer = null;
const herkunft = BASIS;
cdp.on("ServiceWorker.workerRegistrationUpdated", (e) => {
for (const r of e.registrations || []) {
if (String(r.scopeURL || "").startsWith(herkunft)) nummer = r.registrationId;
}
});
await cdp.send("ServiceWorker.enable");
for (let i = 0; i < 40 && !nummer; i++) await new Promise((r) => setTimeout(r, 150));
if (!nummer) {
/* DRITTER AUSGANG. Ohne Nummer ist die Frage nicht zu beantworten --
und das ist etwas anderes als „es geht nicht". */
console.log("\n KONNTE NICHT NACHSEHEN: Der Browser nennt keine "
+ "Registrierungsnummer fuer den Service Worker.\n Ohne sie laesst sich "
+ "kein Push zustellen. Das ist kein Befund am Haus.\n");
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
ok(true, `der Browser kennt seine Registrierung (#${nummer})`);
/* ---- 3. ALLE SEITEN ZU ---------------------------------------------- */
/* NUR DIE SEITEN DES HAUSES -- der leere Traeger bleibt, er ist das
Messgeraet. Gezaehlt wird genau das, was behauptet wird. */
for (const s of kontext.pages()) {
if (s !== traeger) await s.close();
}
const nochOffen = kontext.pages().filter((s) => s.url().startsWith(herkunft));
ok(nochOffen.length === 0,
`keine einzige Seite des Workspace ist noch offen (${nochOffen.length});`
+ ` offen bleibt nur ${kontext.pages().map((s) => s.url()).join(", ")}`);
/* ---- 4. Und jetzt kommt der Push ------------------------------------ */
const INHALT = {
titel: "Neue Nachricht",
text: "VanVan hat dir geschrieben.",
ziel: "/workspace/chat.html",
art: "chat",
haus: "crew",
};
await cdp.send("ServiceWorker.deliverPushMessage", {
origin: herkunft,
registrationId: nummer,
data: JSON.stringify(INHALT),
});
/* Dem Service Worker Zeit zum Aufwachen geben. Er wird dafuer vom
Browser frisch gestartet -- das ist der ganze Zweck der Uebung. */
await new Promise((r) => setTimeout(r, 2500));
/* ---- 5. Nachsehen, was dasteht --------------------------------------
Erst JETZT wieder eine Seite. Alles, was sie findet, ist entstanden,
waehrend nichts offen war. */
const neueSeite = await kontext.newPage();
await neueSeite.goto(BASIS + "/workspace/start.html", { waitUntil: "domcontentloaded" });
const gefunden = await neueSeite.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
const liste = await reg.getNotifications();
return liste.map((n) => ({
titel: n.title, text: n.body, marke: n.tag,
symbol: n.icon || "", abzeichen: n.badge || "",
ziel: (n.data && n.data.ziel) || "",
}));
});
ok(gefunden.length >= 1,
`nach dem Push steht ${gefunden.length} Meldung(en) da — bei geschlossener App`);
const m = gefunden[0] || {};
ok(m.titel === INHALT.titel, `mit dem richtigen Titel („${m.titel}")`);
ok(m.text === INHALT.text, ` und dem richtigen Text („${m.text}")`);
ok(m.ziel === INHALT.ziel, ` sie weiss, wohin sie fuehrt (${m.ziel})`);
/* DAS SYMBOL GEHOERT ZUM HAUS. Eine Crew-Meldung mit dem Gesicht der
Agentur waere die Trennung der zwei Haeuser, die in der Hosentasche
wieder zusammenfaellt. */
ok(/crew-192\.png$/.test(m.symbol),
` und traegt das Gesicht des richtigen Hauses (${m.symbol.split("/").pop()})`);
ok(/abzeichen-96\.png$/.test(m.abzeichen),
` mit dem Abzeichen fuer die Statusleiste (${m.abzeichen.split("/").pop()})`);
/* ---- 6. GEGENPROBE: auch ohne Daten darf er nicht schweigen ---------
Manche Dienste schicken im Zweifel eine leere Nachricht. Ein
Browser, der eine Push-Berechtigung hat, MUSS dann trotzdem etwas
anzeigen -- sonst entzieht er sie nach mehrmaligem Schweigen wieder,
und ab da kommt gar nichts mehr an. Das ist der Fehler, der sich
selbst verschlimmert. */
await neueSeite.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
for (const n of await reg.getNotifications()) n.close();
});
for (const s of kontext.pages()) {
if (s !== traeger) await s.close();
}
await cdp.send("ServiceWorker.deliverPushMessage", {
origin: herkunft, registrationId: nummer, data: "",
});
await new Promise((r) => setTimeout(r, 2500));
const zweiteSeite = await kontext.newPage();
await zweiteSeite.goto(BASIS + "/workspace/start.html", { waitUntil: "domcontentloaded" });
const leer = await zweiteSeite.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return (await reg.getNotifications()).map((n) => ({ titel: n.title, text: n.body }));
});
ok(leer.length >= 1,
`auch ein Push OHNE Daten zeigt etwas an (${leer.length}) — sonst entzieht`
+ " der Browser irgendwann die Berechtigung");
ok((leer[0]?.titel || "").length > 0 && (leer[0]?.text || "").length > 0,
` und zwar mit Vorgabetext („${leer[0]?.titel}" · „${leer[0]?.text}")`);
/* ---- 7. GEGENPROBE ZUR GEGENPROBE ----------------------------------
Kann diese Messung ueberhaupt „nichts da" sagen? Ohne diese Zeile
waere „es steht etwas da" auch dann gruen, wenn `getNotifications()`
immer dasselbe zurueckgaebe. */
await zweiteSeite.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
for (const n of await reg.getNotifications()) n.close();
});
await new Promise((r) => setTimeout(r, 400));
const nachAufraeumen = await zweiteSeite.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return (await reg.getNotifications()).length;
});
ok(nachAufraeumen === 0,
`Gegenprobe: weggeraeumt ist weggeraeumt (${nachAufraeumen}) — die Messung`
+ " kann also auch „nichts“ sagen");
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
process.exit(fehler ? 1 : 0);