diff --git a/assets/img/abzeichen-96.png b/assets/img/abzeichen-96.png new file mode 100644 index 00000000..010d39d2 Binary files /dev/null and b/assets/img/abzeichen-96.png differ diff --git a/server/helfer-png-alpha.mjs b/server/helfer-png-alpha.mjs new file mode 100644 index 00000000..d5d3b258 --- /dev/null +++ b/server/helfer-png-alpha.mjs @@ -0,0 +1,157 @@ +/* ===================================================================== + helfer-png-alpha.mjs — wie viel von einem PNG ist überhaupt da? + + --------------------------------------------------------------------- + WOFÜR + + Das Abzeichen einer Benachrichtigung (`badge`) wird von Android + AUSSCHLIESSLICH über seinen Alphakanal gelesen und einfarbig weiß + gemalt. Alles Bunte daran geht verloren. + + Bis zum 29.09.2026 stand dort ein vollflächiges App-Symbol ohne + Transparenz. Dessen Alphakanal ist überall voll — in der + Statusleiste erschien deshalb ein ausgefüllter Klotz. Es stürzt + nichts ab, es sieht nur billig aus, und genau deshalb fällt so + etwas niemandem als Fehler auf. + + Eine Prüfung, die nur nachsieht, ob die DATEI existiert, hätte den + Klotz nie gefunden — sie existierte ja. Gefragt ist der Inhalt: + Deckt das Bild 12 Prozent seiner Fläche (ein Zeichen) oder 100 + (ein Klotz)? + + --------------------------------------------------------------------- + WARUM SELBST GERECHNET + + Im Haus gibt es keine Bildbibliothek für Node, und eine dafür zu + installieren wäre eine Abhängigkeit für vierzig Zeilen. PNG ist an + dieser Stelle einfach: Kopf lesen, die Datenblöcke + zusammenhängen, auspacken, die Zeilenfilter rückgängig machen. + `zlib` liegt in Node bei. + + --------------------------------------------------------------------- + DREI AUSGÄNGE, NICHT ZWEI + + Diese Datei kann ein PNG auch NICHT lesen können — ein anderes + Farbformat, 16 Bit je Kanal, ein verschachteltes Bild. Dann sagt + sie das (`lesbar: false, grund: "…"`) und behauptet nicht, die + Deckung sei null. Eine Null sähe aus wie ein Befund und wäre eine + Ausrede. + ===================================================================== */ + +import { readFileSync } from "node:fs"; +import { inflateSync } from "node:zlib"; + +const MAGIE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** Wie viel eines PNG ist sichtbar? + * + * @returns {{lesbar: boolean, grund?: string, breite?: number, + * hoch?: number, deckung?: number, hatAlpha?: boolean}} + * `deckung` ist der Anteil der Bildpunkte mit Alpha über 127. + */ +export function pngDeckung(pfad) { + let roh; + try { + roh = readFileSync(pfad); + } catch (f) { + return { lesbar: false, grund: `nicht zu öffnen: ${f?.code || f?.message}` }; + } + if (roh.length < 8 || !roh.subarray(0, 8).equals(MAGIE)) { + return { lesbar: false, grund: "kein PNG" }; + } + + /* ---- Die Blöcke durchgehen ---- */ + let stelle = 8; + let kopf = null; + const daten = []; + while (stelle + 8 <= roh.length) { + const laenge = roh.readUInt32BE(stelle); + const art = roh.toString("ascii", stelle + 4, stelle + 8); + const inhalt = roh.subarray(stelle + 8, stelle + 8 + laenge); + if (art === "IHDR") { + kopf = { + breite: inhalt.readUInt32BE(0), + hoch: inhalt.readUInt32BE(4), + tiefe: inhalt[8], + farbe: inhalt[9], + verschachtelt: inhalt[12], + }; + } else if (art === "IDAT") { + daten.push(inhalt); + } else if (art === "IEND") { + break; + } + stelle += 12 + laenge; // Länge + Art + Inhalt + Prüfsumme + } + if (!kopf) return { lesbar: false, grund: "kein Kopf (IHDR)" }; + + /* ---- Was diese Datei kann, und was nicht ---- */ + const hatAlpha = kopf.farbe === 6 || kopf.farbe === 4; + if (!hatAlpha) { + /* KEIN ALPHAKANAL heißt: das Bild ist überall voll. Das ist eine + Antwort und kein Unvermögen -- genau dieser Fall war der Klotz + in der Statusleiste. */ + return { lesbar: true, hatAlpha: false, deckung: 1, + breite: kopf.breite, hoch: kopf.hoch }; + } + if (kopf.tiefe !== 8) { + return { lesbar: false, grund: `${kopf.tiefe} Bit je Kanal, ich kann nur 8` }; + } + if (kopf.verschachtelt) { + return { lesbar: false, grund: "verschachtelt (Adam7)" }; + } + if (!daten.length) return { lesbar: false, grund: "keine Bilddaten" }; + + let aus; + try { + aus = inflateSync(Buffer.concat(daten)); + } catch (f) { + return { lesbar: false, grund: `nicht auszupacken: ${f?.message}` }; + } + + /* ---- Die Zeilenfilter rückgängig machen ---- + PNG speichert jede Zeile mit einem Filterbyte davor. Ohne das + Rückrechnen stünden dort Differenzen und keine Farbwerte -- und + eine Deckung, die aus Differenzen gerechnet ist, wäre eine Zahl + ohne Bedeutung, die trotzdem plausibel aussieht. */ + const kanaele = kopf.farbe === 6 ? 4 : 2; // RGBA oder Grau+Alpha + const zeileBytes = kopf.breite * kanaele; + const noetig = (zeileBytes + 1) * kopf.hoch; + if (aus.length < noetig) { + return { lesbar: false, grund: `zu wenig Daten (${aus.length} statt ${noetig})` }; + } + + let voll = 0; + let vorige = Buffer.alloc(zeileBytes); + let p = 0; + for (let y = 0; y < kopf.hoch; y++) { + const filter = aus[p++]; + const zeile = Buffer.from(aus.subarray(p, p + zeileBytes)); + p += zeileBytes; + for (let i = 0; i < zeileBytes; i++) { + const a = i >= kanaele ? zeile[i - kanaele] : 0; // links + const b = vorige[i]; // oben + const c = i >= kanaele ? vorige[i - kanaele] : 0; // oben links + let w = zeile[i]; + if (filter === 1) w += a; + else if (filter === 2) w += b; + else if (filter === 3) w += (a + b) >> 1; + else if (filter === 4) { + const s = a + b - c; + const da = Math.abs(s - a), dbb = Math.abs(s - b), dc = Math.abs(s - c); + w += (da <= dbb && da <= dc) ? a : (dbb <= dc ? b : c); + } + zeile[i] = w & 0xff; + } + for (let x = kanaele - 1; x < zeileBytes; x += kanaele) { + if (zeile[x] > 127) voll++; + } + vorige = zeile; + } + + return { + lesbar: true, hatAlpha: true, + breite: kopf.breite, hoch: kopf.hoch, + deckung: voll / (kopf.breite * kopf.hoch), + }; +} diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs index 0ff25e9f..c8da797e 100644 --- a/server/mess-reaktion.mjs +++ b/server/mess-reaktion.mjs @@ -393,6 +393,150 @@ if (stehen) { ausgang = 1; } +/* ===================================================================== + HAENGT DAS VIDEO? -- der Host darf sich nicht selbst anhalten + ===================================================================== */ +console.log(""); +console.log("=== Der Gleichlauf des Videos ==="); +{ + /* Wie ein Pufferer aussieht, wenn er gemeldet wird: `laeuft:false` + bei einem Spieler, der in Wahrheit laeuft. Genau das schickt + `hostMelden()` im YouTube-Zustand 3. */ + const pufferer = (seite) => seite.evaluate(async () => { + const stelle = document.getElementById("video-platz")?.dataset.spieler; + const a = await fetch("/workspace/api/reaktion/video", { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ laeuft: false, sekunde: 7 }), + }); + return { vorher: stelle, code: a.status }; + }); + + const stand = (seite) => seite.evaluate( + () => document.getElementById("video-platz")?.dataset.spieler || "(nichts)"); + + /* Erst sicherstellen, dass ueberhaupt etwas laeuft -- sonst misst + die Pruefung ein stehendes Video und nennt das Erfolg. */ + const vorHost = await stand(host.seite); + const vorZu = await stand(zuschauer.seite); + console.log(` Vorher: Host=${vorHost} Zuschauerin=${vorZu}`); + const laeuftVorher = (w) => w === "laeuft" || w === "puffert"; + if (!laeuftVorher(vorHost) || !laeuftVorher(vorZu)) { + console.log(" ACHTUNG: Vor der Messung laeuft schon nichts -- " + + "die Frage laesst sich so nicht beantworten."); + ausgang = 1; + } else { + const p = await pufferer(host.seite); + console.log(` Ein Pufferer wird gemeldet: ${p.code}`); + await host.seite.waitForTimeout(2000); + await zuschauer.seite.waitForTimeout(500); + + const nachHost = await stand(host.seite); + const nachZu = await stand(zuschauer.seite); + console.log(` Danach: Host=${nachHost} Zuschauerin=${nachZu}`); + + /* DER BEFUND. Der Host hat nichts angehalten -- er hat nur + gemeldet, dass gerade Daten fehlen. Steht sein Video danach, + hat sein eigenes Echo es angehalten. */ + if (!laeuftVorher(nachHost)) { + console.log(" ACHTUNG: Der Host haelt sich selbst an. Ein kurzer " + + "Pufferer meldet 'laeuft nicht', das Echo kommt zurueck und " + + "drueckt Pause -- deshalb haengt das Video."); + ausgang = 1; + } + + /* ---- DIE GEGENPROBE: ein ECHTES Anhalten wirkt weiter ------ + Ohne sie waere „der Host folgt sich nicht selbst" womoeglich + mit einem kaputten Gleichlauf fuer alle bezahlt. + + GEDRUECKT WIRD DER KNOPF, nicht eine Meldung gefaelscht. Bei + einem echten Anhalten meldet der Host durchgehend „steht" -- + die Antwort haengt damit nicht am Zufall seines + Fuenf-Sekunden-Taktes. Die erste Fassung dieser Gegenprobe hat + genau daran Alarm geschlagen: Sie hielt das Selbstheilen des + Systems fuer einen Fehler. */ + await host.seite.evaluate(() => document.getElementById("v-spiel")?.click()); + await host.seite.waitForTimeout(1800); + const haltHost = await stand(host.seite); + const haltZu = await stand(zuschauer.seite); + console.log(` Host haelt wirklich an: Host=${haltHost} Zuschauerin=${haltZu}`); + if (haltHost === "laeuft" || haltZu === "laeuft") { + console.log(" ACHTUNG: Ein echtes Anhalten des Hosts kommt nicht an -- " + + "dann laeuft niemand mehr synchron."); + ausgang = 1; + } + + /* Und wieder los, mit demselben Knopf. */ + await host.seite.evaluate(() => document.getElementById("v-spiel")?.click()); + await host.seite.waitForTimeout(2000); + const weiterHost = await stand(host.seite); + const weiterZu = await stand(zuschauer.seite); + console.log(` Und wieder los: Host=${weiterHost} Zuschauerin=${weiterZu}`); + if (!laeuftVorher(weiterHost) || !laeuftVorher(weiterZu)) { + console.log(" ACHTUNG: Nach dem Anhalten laeuft es nicht wieder an."); + ausgang = 1; + } + + /* ---- DER KERN: was meldet der Host, WAEHREND er puffert? ---- + Die beiden Teile darueber pruefen die Folge. Sie blieben auch + gruen, wenn der Host beim Puffern weiter „steht" meldete -- + die Zuschauer ruckelten dann bei jedem Pufferer, und niemand + saehe es hier. + + Puffern wird hergestellt: weit nach vorn springen, wohin noch + nichts geladen ist. Ob das GELINGT, ist nicht sicher -- auf + einer schnellen Leitung kann der Sprung fertig sein, bevor + jemand hinsieht. Deshalb der dritte Ausgang. */ + let gesehen = null; + for (let i = 0; i < 8 && !gesehen; i++) { + await host.seite.evaluate(() => { + for (let n = 0; n < 12; n++) document.getElementById("v-vor")?.click(); + }); + for (let t = 0; t < 14 && !gesehen; t++) { + const w = await host.seite.evaluate(() => ({ + spieler: document.getElementById("video-platz")?.dataset.spieler, + })); + if (w.spieler === "puffert") { + /* SOFORT NACHSEHEN, was der Server jetzt weiss. Eine + Sekunde spaeter ist der Pufferer vorbei und die Antwort + nichts mehr wert. */ + gesehen = await host.seite.evaluate(async () => { + const a = await fetch("/workspace/api/reaktion", { credentials: "same-origin" }); + const j = await a.json(); + return { laeuft: j.laeuft, stand: document.getElementById("video-platz")?.dataset.spieler }; + }); + } + await host.seite.waitForTimeout(120); + } + } + if (!gesehen) { + console.log(" HINWEIS: Ein Pufferer war hier nicht herzustellen " + + "(schnelle Leitung) -- was der Host dabei meldet, konnte ich " + + "nicht nachsehen."); + offenePunkte.push("Was der Host beim Puffern meldet, war in diesem " + + "Lauf nicht messbar -- der Sprung war zu schnell geladen."); + } else { + console.log(` Beim Puffern meldet der Server: laeuft=${gesehen.laeuft} ` + + `(Spieler: ${gesehen.stand})`); + if (!gesehen.laeuft) { + console.log(" ACHTUNG: Der Host meldet beim Puffern 'laeuft nicht'. " + + "Dann bekommt jeder Zuschauer bei jedem Stocken der Hostleitung " + + "ein Pause-Play-Paar -- das ist das Ruckeln."); + ausgang = 1; + } + } + + /* Und wieder anlaufen lassen, damit die folgenden Messungen ein + laufendes Video vorfinden. */ + await host.seite.evaluate(() => fetch("/workspace/api/reaktion/video", { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ laeuft: true, sekunde: 7 }), + })); + await host.seite.waitForTimeout(1200); + } +} + /* ---- Die Regie --------------------------------------------------- NICHT UMSCHALTEN, SONDERN HERSTELLEN. Ein Klick auf „Griff" heisst „das Gegenteil von dem, was gerade ist" -- und setzt damit voraus, diff --git a/server/pruef-push-ziel.mjs b/server/pruef-push-ziel.mjs index 2292181a..a38e8572 100644 --- a/server/pruef-push-ziel.mjs +++ b/server/pruef-push-ziel.mjs @@ -48,6 +48,27 @@ process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; +/* ---- DIE RUHEZEIT WIRD HERGESTELLT, NICHT ABGEWARTET ------------- + + Ab dem 29.09.2026 traegt jede Art ihre eigene Ruheregel. Um zu + pruefen, dass sie wirkt, muss Ruhezeit sein -- und darauf bis 22 + Uhr zu warten waere keine Pruefung, sondern ein Kalendereintrag. + + PUSH_RUHE_AB = 0 „gesperrt ab Stunde 0“ -> „immer“ ist IMMER + Ruhezeit, zu jeder Tageszeit. + PUSH_SPAET_AB = 24 „gesperrt ab Stunde 24" -- die es nicht gibt + -> „spaet“ ist NIE gesperrt. + + Beides zusammen macht den Unterschied zwischen den Regeln + unabhaengig von der Uhr sichtbar. Ohne das waere diese Pruefung + eine Zeitbombe: gruen am Nachmittag, rot um halb drei nachts, und + beides ohne Aussage ueber die Software. + + DIE VORHANDENEN ELF PRUEFUNGEN BLEIBEN UNBERUEHRT -- sie benutzen + alle die Art „test“, und die traegt `ruhe: "nie"`. */ +process.env.PUSH_RUHE_AB = "0"; +process.env.PUSH_SPAET_AB = "24"; + 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 }); }; @@ -213,6 +234,186 @@ melde("=== 4. Der Weg ==="); } /* ------------------------------------------------------------------- */ +/* ======================================================================= + DAS GESICHT DES HAUSES (29.09.2026) + ======================================================================= + + Im Service Worker stand fest `workspace-192.png` -- das Symbol der + Agentur. Gemessen am Live-Bestand gehoeren fuenf von acht Menschen + mit angemeldetem Geraet ins Crew-Haus; die sahen auf JEDER + Benachrichtigung das Symbol des Hauses, in dem sie nicht arbeiten. + + Entschieden wird es auf dem Server und nicht im Browser: Dort + stuende sonst die verborgene Crew-Adresse in einer Datei, die ohne + Anmeldung abrufbar ist -- genau der Befund vom 21.09. bei + crew-haus.css. + ======================================================================= */ +melde(""); +melde("=== Das Gesicht des Hauses ==="); +{ + const m = await push.benachrichtige(idModi, "test", + { titel: "x", text: "y", ziel: "/workspace/start.html" }); + ok(m.haus === "crew", `ein Modi bekommt das Crew-Gesicht (${m.haus})`); + + const g = await push.benachrichtige(idMana, "test", + { titel: "x", text: "y", ziel: "/workspace/start.html" }); + ok(g.haus === "agentur", `eine Managerin das der Agentur (${g.haus})`); + + /* Dass die beiden VERSCHIEDEN sind, ist die eigentliche Aussage -- + eine Funktion, die immer „agentur“ sagt, haette die zweite Zeile + bestanden und die erste nicht. */ + ok(m.haus !== g.haus, "die beiden Haeuser bekommen nicht dasselbe Gesicht"); + + /* DIE SEITE SCHLAEGT DIE ROLLE. DogFather arbeitet in beiden + Haeusern; eine Einladung ins Crew-Kino traegt trotzdem das + Crew-Gesicht, weil `/workspace/reaktion.html` in genau ein Haus + gehoert (GEHOERT_ZU_ADRESSE). */ + const r = await push.benachrichtige(idDogi, "test", + { titel: "x", text: "y", ziel: "/workspace/reaktion.html" }); + ok(r.haus === "crew", + `eine Einladung zur Reaction traegt das Crew-Gesicht (${r.haus})`); + + /* Gegenprobe zur Zeile darueber: Dieselbe Person, ein neutrales + Ziel -- die Seite entscheidet also wirklich, nicht die Person + allein. Waere es umgekehrt, stuende hier zweimal dasselbe. */ + const n = await push.benachrichtige(idMana, "test", + { titel: "x", text: "y", ziel: "/workspace/reaktion.html" }); + ok(n.haus === "crew", + `auch fuer die Agentur, wenn das ZIEL im Crew-Haus liegt (${n.haus})`); +} + +/* ======================================================================= + DAS ABZEICHEN -- ein Zeichen oder ein Klotz? + ======================================================================= + + Das `badge` ist das winzige Zeichen oben in der Statusleiste. Android + liest davon AUSSCHLIESSLICH den Alphakanal und malt ihn einfarbig + weiss. + + Bis zum 29.09.2026 stand dort dasselbe `workspace-192.png` wie beim + Symbol -- ein vollflaechiges Quadrat OHNE Alphakanal. In der + Statusleiste erschien deshalb ein ausgefuellter Klotz. Es stuerzt + nichts ab, es sieht nur billig aus, und genau deshalb faellt so + etwas niemandem als Fehler auf. + + EINE PRUEFUNG AUF „DIE DATEI GIBT ES“ HAETTE DAS NIE GEFUNDEN -- die + Datei gab es ja. Gefragt ist der INHALT. + ======================================================================= */ +melde(""); +melde("=== Das Abzeichen ==="); +{ + const { readFileSync } = await import("node:fs"); + const { dirname, join: fuege } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const { pngDeckung } = await import("./helfer-png-alpha.mjs"); + const HIER = dirname(fileURLToPath(import.meta.url)); + const REPO = fuege(HIER, ".."); + const sw = readFileSync(fuege(REPO, "workspace", "sw.js"), "utf8"); + + /* ABGELESEN, NICHT ABGESCHRIEBEN: Der Pfad kommt aus dem Service + Worker selbst. Stuende hier der Name noch einmal, pruefte die + Zeile ihre eigene Kopie. */ + const wo = /badge:\s*"([^"]+)"/.exec(sw); + ok(!!wo, `der Service Worker nennt ein Abzeichen (${wo?.[1] || "keins"})`); + + const datei = fuege(REPO, (wo?.[1] || "").replace(/^\//, "")); + const e = pngDeckung(datei); + ok(e.lesbar, `es liegt als lesbares PNG vor (${e.grund || `${e.breite}x${e.hoch}`})`); + ok(e.hatAlpha === true, "es hat einen Alphakanal -- sonst ist es ein Klotz"); + /* ZWISCHEN STAUBKORN UND KLOTZ. Unter 10 % ist in der Statusleiste + nichts mehr zu sehen, ueber 80 % ist es wieder die Flaeche, die + wir gerade abgeschafft haben. */ + ok(e.deckung > 0.10 && e.deckung < 0.80, + `und deckt ${(e.deckung * 100).toFixed(0)} % seiner Flaeche (10-80 erwartet)`); + + /* ---- DIE GEGENPROBE, und sie ist der alte Zustand selbst ------- + Dieselbe Rechnung auf das App-Symbol, das bis zum 29.09. als + Abzeichen diente. Meldet sie dort nicht „kein Alphakanal, 100 %", + kann sie den Unterschied nicht sehen -- und alles darueber waere + wertlos. */ + const alt = pngDeckung(fuege(REPO, "assets/img/app-symbole/workspace-192.png")); + ok(alt.lesbar && alt.hatAlpha === false && alt.deckung === 1, + `Gegenprobe: das alte Abzeichen wird als Klotz erkannt ` + + `(Alpha ${alt.hatAlpha}, Deckung ${alt.deckung})`); + + /* Und die zwei Gesichter muss es wirklich geben -- ein Symbol, das + 404 antwortet, zeigt Android gar nicht an. */ + for (const n of ["crew-192.png", "workspace-192.png"]) { + const g = pngDeckung(fuege(REPO, "assets/img/app-symbole", n)); + ok(g.lesbar, `${n} liegt vor (${g.grund || `${g.breite}x${g.hoch}`})`); + ok(sw.includes(n), `und der Service Worker nennt es`); + } +} + +/* ======================================================================= + DARF DAS NACHTS KOMMEN? -- drei Antworten statt zwei + ======================================================================= + + Bis zum 29.09.2026 stand die Antwort als fest verdrahtete + Aufzaehlung in `benachrichtige`, 230 Zeilen von der Artenliste + entfernt: + + const dringend = art === "test" || art === "anruf"; + + Am 18.09. hat genau das eine Nacht lang alle Anrufe verschluckt. + Behoben wurde damals dieser eine Fall; die Frage dahinter blieb + stehen, und `dogfather_live` fiel bis heute in dieselbe Falle. + ======================================================================= */ +melde(""); +melde("=== Darf das nachts kommen? ==="); +{ + /* FESTE STUNDEN ALS EINGABE, nicht als Annahme ueber „jetzt". Ein + Datum, das der Kalender einholt, waere eine Zeitbombe -- so wie + die Weckerpruefung am 07.09. */ + const um23 = new Date(2026, 8, 29, 23, 0, 0); + const um3 = new Date(2026, 8, 30, 3, 0, 0); + const um15 = new Date(2026, 8, 29, 15, 0, 0); + /* Die Umgebung ist oben umgestellt; fuer die reine Rechnung wird + hier die Vorgabe gefragt -- die drei Regeln, nicht die Zahlen. */ + ok(push.ruheRegel("aufgabe_faellig") === "immer", + "eine Erinnerung traegt „immer“"); + ok(push.ruheRegel("anruf") === "nie", "ein Anruf traegt „nie“"); + ok(push.ruheRegel("dogfather_live") === "spaet", + "„DogFather ist live“ traegt „spaet“"); + ok(push.ruheRegel("reaktion_live") === "spaet", + "die Reaction-Einladung auch"); + ok(push.ruheRegel("gibtsnicht") === "immer", + "eine unbekannte Art bekommt die strengste Regel"); + + /* Die Rechnung selbst -- mit den Grenzen dieses Laufs (ab 0 bzw. + nie). Dass die Vorgaben 22 und 1 sind, steht in der Artenliste; + hier wird geprueft, dass die REGELN verschieden wirken. */ + ok(push.istRuhezeit(um23, "immer") === true, "um 23 Uhr ist „immer“ gesperrt"); + ok(push.istRuhezeit(um23, "nie") === false, "„nie“ nicht"); + ok(push.istRuhezeit(um15, "immer") === true, + "in diesem Lauf ist „immer“ rund um die Uhr gesperrt (hergestellt)"); + ok(push.istRuhezeit(um3, "spaet") === false, + "und „spaet“ nie (hergestellt)"); + + /* ---- UND JETZT DAS VERHALTEN, nicht die Rechnung -------------- + Eine Prüfung der Formel allein sagt nichts darueber, ob + `benachrichtige` sie auch BENUTZT. Genau diese Luecke war der + Fehler: Die Formel war richtig, die Aufzaehlung daneben nicht. */ + const erinnerung = await push.benachrichtige(idHand, "aufgabe_faellig", + { titel: "x", text: "y", ziel: "/workspace/aufgaben.html" }); + ok(erinnerung.grund === "ruhezeit", + `eine Erinnerung wird nachts zurueckgehalten (${erinnerung.grund})`); + + const live = await push.benachrichtige(idHand, "dogfather_live", + { titel: "x", text: "y", ziel: "/workspace/unsere-seiten.html" }); + ok(live.verschickt === 1, + `„DogFather ist live“ geht trotzdem raus (${live.verschickt}, ${live.grund})`); + + const kino = await push.benachrichtige(idHand, "reaktion_live", + { titel: "x", text: "y", ziel: "/workspace/reaktion.html" }); + ok(kino.verschickt === 1, + `die Einladung zur Reaction auch (${kino.verschickt}, ${kino.grund})`); + + const ruf = await push.benachrichtige(idHand, "anruf", + { titel: "x", text: "y", ziel: "/workspace/chat.html" }); + ok(ruf.verschickt === 1, `und ein Anruf erst recht (${ruf.verschickt})`); +} + melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs index f978f45d..1d887a93 100644 --- a/server/pruef-reaktion.mjs +++ b/server/pruef-reaktion.mjs @@ -45,6 +45,19 @@ process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; den niemand prueft, weil das Pruefen zu lange dauert, ist ein ungeprueftes Verhalten. */ process.env.REAKTION_LISTE_MAX = "3"; +/* DIE EINLADUNG DARF NICHT AN DER UHR SCHEITERN. + + `reaktion_live` traegt die Ruheregel „spaet" -- gesperrt ist damit + nur zwischen 1 und 7 Uhr. Liefe diese Pruefung um halb drei nachts, + waere sie rot, ohne dass an der Software etwas kaputt ist: genau die + Zeitbombe, die in den Hausregeln steht und die am 07.09. schon die + Weckerpruefung getroffen hat. + + `PUSH_SPAET_AB = 24` heisst: „gesperrt ab Stunde 24" -- die es nicht + gibt. Damit ist „spaet" hier NIE gesperrt, zu jeder Tageszeit. Die + Grenze selbst wird in pruef-push-ziel mit festen Stunden geprueft, + wo sie hingehoert: als EINGABE, nicht als Annahme ueber „jetzt". */ +process.env.PUSH_SPAET_AB = "24"; const express = (await import("express")).default; const ec = express.response.cookie; @@ -2424,6 +2437,128 @@ melde("=== 21. Alles zuruecksetzen ==="); }); } +/* ======================================================================= + DIE EINLADUNG -- geht sie raus, und an die Richtigen? + ======================================================================= + + Filipe, 29.09.2026: „dogfather und alle anderen sollen die + benachrichtigungen perfekt von dieser seite hier bekommen." + + Bis zu diesem Tag verliess die Reaction das Haus nie. Sie rief beim + Einschalten `melden("reaktion", ...)`, und das laeuft ueber den + Ereignisstrom -- also NUR zu Leuten, die die Seite ohnehin offen + haben. Wer nicht hinschaut, erfuhr nichts. + + GEPRUEFT WIRD DAS VERHALTEN, NICHT DER QUELLTEXT. Eine Textsuche + nach „benachrichtige" waere gruen gewesen, sobald das Wort irgendwo + steht -- auch in einem Zweig, den nichts aufruft. Hier geht die + Sendung wirklich auf Sendung, und danach wird nachgesehen, was in + `push_verschickt` steht. + ======================================================================= */ +melde(""); +melde("=== Die Einladung ==="); +{ + const { createServer } = await import("node:http"); + const { createECDH } = await import("node:crypto"); + + /* Ein Push-Dienst, der alles annimmt. Er muss nichts entschluesseln + -- gefragt ist, WER eine bekommt. Dass der Inhalt unterwegs heil + bleibt, beweist pruef-push-weg mit einem nachgebauten Browser. */ + const dienstPort = await eigenerPort(import.meta, "pruef-reaktion (Push-Dienst)", 1); + const dienst = createServer((q, a) => { + q.on("data", () => {}); + q.on("end", () => { a.writeHead(201); a.end(); }); + }); + await new Promise((r) => dienst.listen(dienstPort, "127.0.0.1", r)); + + /* Erst zu -- sonst waere der naechste Schritt kein UEBERGANG nach + live, und eingeladen wird nur beim Uebergang. */ + await roh(CREW, "/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "zu" } }); + + const db2 = new DatabaseSync(process.env.WORKSPACE_DB); + /* Je ein Geraet fuer JEDEN -- auch fuer die, die keine Einladung + bekommen sollen. Ohne ihr Geraet waere „hat nichts bekommen" die + Antwort auf die falsche Frage: Man wuesste nicht, ob die Auswahl + greift oder ob nur kein Telefon da war. */ + for (const rolle of ["admin", "hand", "linke", "modi", "gast", "gast2", "creator"]) { + const kk = createECDH("prime256v1"); + kk.generateKeys(); + db2.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt) + VALUES (?,?,?,?,?)`) + .run(IDS[rolle], `http://127.0.0.1:${dienstPort}/push/${rolle}`, + kk.getPublicKey().toString("base64url"), + randomBytes(16).toString("base64url"), nun); + } + db2.close(); + + const anSendung = await roh(CREW, "/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); + ok(anSendung.code === 200, `auf Sendung (${anSendung.code})`); + + /* Die Einladung wird losgeschickt und nicht abgewartet -- der Knopf + „Auf Sendung" soll sofort reagieren. Also hier warten, wo Warten + nichts kostet. */ + await new Promise((r) => setTimeout(r, 2500)); + + const lies = () => { + const db3 = new DatabaseSync(process.env.WORKSPACE_DB); + const z = db3.prepare( + `SELECT person_id FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).all(); + db3.close(); + return new Set(z.map((x) => x.person_id)); + }; + const bekommen = lies(); + + const sollen = [["hand", "die rechte Hand"], ["linke", "die linke Hand"], + ["modi", "ein Modi"], ["gast", "die Community"], ["gast2", "noch jemand aus der Community"]]; + for (const [rolle, wort] of sollen) { + ok(bekommen.has(IDS[rolle]), `${wort} wird eingeladen`); + } + + /* WER EINGESCHALTET HAT, BEKOMMT KEINE. Er hat gerade den Knopf + gedrueckt; eine Einladung zu etwas, das man selbst aufgemacht + hat, ist Rauschen. */ + ok(!bekommen.has(IDS.admin), + "wer eingeschaltet hat, bekommt keine Einladung"); + + /* DAS ANDERE HAUS BLEIBT AUSSEN VOR. Fuer eine Creatorin gibt es + `reaktion.html` nicht -- sie bekaeme eine Meldung, die sie auf + ihre Startseite wirft. Eine Benachrichtigung, die nirgendwohin + fuehrt, ist schlimmer als keine. */ + ok(!bekommen.has(IDS.creator), + "die Agentur bekommt keine Einladung ins Crew-Kino"); + + ok(bekommen.size === 5, + `genau fuenf Einladungen, nicht mehr (${bekommen.size})`); + + /* ---- Die Bremse gegen den Neustart --------------------------- + Wer aus Versehen auf „Vorbereitung" und gleich wieder auf „Auf + Sendung" drueckt, laedt sonst zweimal ein. Fuenf Menschen + bekaemen zwei Meldungen fuer dieselbe Sendung -- der schnellste + Weg, dass jemand die Art abschaltet, und dann fehlt auch die + naechste. */ + const db4 = new DatabaseSync(process.env.WORKSPACE_DB); + const vorher = db4.prepare( + `SELECT COUNT(*) AS n FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).get().n; + db4.close(); + + await roh(CREW, "/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } }); + await roh(CREW, "/workspace/api/reaktion/stand", + { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); + await new Promise((r) => setTimeout(r, 2000)); + + const db5 = new DatabaseSync(process.env.WORKSPACE_DB); + const nachher = db5.prepare( + `SELECT COUNT(*) AS n FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).get().n; + db5.close(); + ok(nachher === vorher, + `ein Neustart laedt nicht noch einmal ein (${vorher} -> ${nachher})`); + + await new Promise((r) => dienst.close(r)); +} + melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG"); diff --git a/server/workspace-push.js b/server/workspace-push.js index c64388d4..7b906240 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -58,6 +58,10 @@ import { gehoertAufDieseAdresse, TEAM_DOGI_ROLLEN, } from "./workspace.js"; import { schluesselErzeugen, schicken } from "./workspace-push-krypto.js"; +/* Die Hausteilung kommt aus der Datei, in der sie steht -- nicht aus + einer zweiten Fassung hier. `crew-adresse.js` haengt von nichts ab + (nur von node:fs/path), ein Kreis entsteht also nicht. */ +import { AGENTUR_ROLLEN, GEHOERT_ZU_ADRESSE, istCrewAdresse } from "./crew-adresse.js"; import { rhythmus as befindenRhythmus } from "./workspace-befinden.js"; import { SEITEN } from "./rechte.js"; @@ -70,6 +74,41 @@ const jetzt = () => new Date().toISOString(); eine bewusste Auswahl, keine Nutzdaten. Wer eine neue Art braucht, traegt sie hier ein und sie erscheint von selbst in den Einstellungen. + + --------------------------------------------------------------------- + `ruhe` -- DARF DAS NACHTS KOMMEN? (29.09.2026) + + Bis hierher stand das als fest verdrahtete Aufzaehlung 230 Zeilen + weiter unten in `benachrichtige`: + + const dringend = art === "test" || art === "anruf"; + + Wer eine neue Art eintrug, sah sie nicht -- und diese Liste sagte + ueber die Ruhezeit kein Wort, obwohl sie darueber entscheidet, ob + eine Meldung jemals ankommt. Am 18.09. hat genau das eine Nacht + lang alle Anrufe verschluckt; damals wurde EINE Art ausgenommen, + statt die Frage zu stellen. + + DREI ANTWORTEN, WEIL ES DREI FAELLE GIBT: + + "immer" (Vorgabe) 22-7 gesperrt. + Erinnerungen. Der Server schickt sie von sich aus, weil + eine Frist naeher rueckt -- die kann bis morgen warten. + + "spaet" nur 1-7 gesperrt. + Etwas LAEUFT GERADE und ist in zwei Stunden vorbei. Eine + Einladung um 22:30 will man haben; um vier Uhr nachts + ist die Sendung laengst aus, und die Meldung waere nur + noch eine Stoerung. + + "nie" immer durch. + Ein Mensch drueckt gerade auf den Hoerer und wartet. + Ein Telefon, das nachts stumm bleibt, ist kein Telefon. + + GEMESSEN, WARUM "spaet" gebraucht wird: An allen sieben Abenden vom + 22. bis 28.09.2026 ging der Live-Ruf zwischen 20:28 und 21:06 raus. + Jedes Mal knapp vor der Sperre. Geht Filipe einmal um 22:05 live, + bekommt niemand etwas -- und niemand erfaehrt, warum. ------------------------------------------------------------------ */ export const ARTEN = [ { schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true }, @@ -166,7 +205,41 @@ export const ARTEN = [ Eine eigene Art und nicht "termin_gleich": Das eine ist ein Kalendereintrag, das andere ein Stream, der JETZT laeuft. Wer das eine stumm stellt, meint selten das andere. */ - { schluessel: "dogfather_live", name: "DogFather ist live", vorgabe: true }, + { schluessel: "dogfather_live", name: "DogFather ist live", vorgabe: true, + /* „spaet", nicht „immer": Ein Stream, der um 22:05 anfaengt, ist um + 22:05 interessant. Mit der alten Regel waere er stumm geblieben, + und die Meldung, die Filipe ausdruecklich bestellt hat („die + leute sollen auch automatisch eine benarichtigung bekommen"), + haette an genau den Abenden nicht funktioniert, an denen es + spaeter wurde. */ + ruhe: "spaet" }, + + /* ---- DIE REACTION LAEDT EIN (29.09.2026) ------------------------ + + Filipe: „ich will dass du die benarichtigungen perfektionierst. + dogfather und alle anderen sollen die benachrichtigungen perfekt + von dieser seite hier bekommen." + + NACHGEMESSEN WAR DIE REACTION STUMM. `workspace-reaktion.js` + enthielt kein einziges `benachrichtige`. Geht die Sendung auf + Sendung, ruft sie `melden("reaktion", ...)` -- und das geht ueber + den Ereignisstrom, also NUR an Leute, die die Seite ohnehin schon + offen haben. Das Kino macht auf, und die Einladung verlaesst das + Haus nie. + + Dasselbe Muster wie bei den Bewerbungen am 23.09.: beide Wege + gebaut, beide stumm. + + EIGENE ART UND NICHT `dogfather_live`. Das eine ist sein Stream + auf TikTok (gemessen ueber `liveStatusHolen` beim Postfach), das + andere das Kino hier im Haus. Wer abends nicht auf TikTok geht, + aber zur Reaction kommt, will genau das eine und nicht das + andere -- und umgekehrt. + + `ruhe: "spaet"` aus demselben Grund wie oben: Eine Reaction um + 22:30 ist der Normalfall, keine Ausnahme. */ + { schluessel: "reaktion_live", + name: "Die Reaction geht auf Sendung", vorgabe: true, ruhe: "spaet" }, /* ---- ANRUFE SIND EINE EIGENE ART (18.09.2026) ------------------ Filipe: „sie bekommt nur eine benarichtigung das eine neue @@ -191,7 +264,7 @@ export const ARTEN = [ ist kein Telefon. Wer nachts nicht gestoert werden will, schaltet jetzt diese Art ab -- eine Entscheidung, die man selbst trifft, statt einer Regel, die man nicht kennt. */ - { schluessel: "anruf", name: "Jemand ruft an", vorgabe: true }, + { schluessel: "anruf", name: "Jemand ruft an", vorgabe: true, ruhe: "nie" }, /* DER TAGESRUF (08.09.2026, screen10). Filipe: "eine taetige meldung einmal am tag zu aktivieren wenn noch aufgaben auf sind." @@ -232,7 +305,9 @@ export const ARTEN = [ IM TEXT STEHT NIE, WORUM ES GEHT. Eine Benachrichtigung erscheint auf einem Sperrbildschirm, den auch andere sehen. */ { schluessel: "hilfe", name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true }, - { schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true }, + /* Die Probe beantwortet „kommt ueberhaupt etwas an?" -- und die + stellt man sich auch um 23 Uhr. */ + { schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true, ruhe: "nie" }, ]; const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel)); @@ -274,8 +349,32 @@ export function vorlauf(minuten) { eine Umgebungsvariable und kein Programmierauftrag. */ const RUHE_AB = Number(process.env.PUSH_RUHE_AB ?? 22); const RUHE_BIS = Number(process.env.PUSH_RUHE_BIS ?? 7); -export function istRuhezeit(d = new Date()) { +/* Wann eine „spaet"-Meldung aufhoert. Ein Uhr und nicht zwei: Was um + ein Uhr anfaengt, ist um drei vorbei -- eine Einladung dazu erreicht + niemanden mehr, sie weckt nur. */ +const SPAET_AB = Number(process.env.PUSH_SPAET_AB ?? 1); + +/** Welche Ruheregel gilt fuer diese Art? + * + * ABGELEITET AUS DER ARTENLISTE, nicht danebengeschrieben. Eine + * zweite Aufzaehlung waere genau die, die beim naechsten Eintrag + * vergessen wird -- so ist es am 18.09. beim Anruf gewesen. */ +export function ruheRegel(art) { + return ARTEN.find((a) => a.schluessel === art)?.ruhe || "immer"; +} + +/** Ist gerade Ruhezeit -- fuer diese Regel? + * + * `regel` steht hinten und hat eine Vorgabe, damit vorhandene + * Aufrufer (und die Pruefungen) unveraendert weiterlaufen. */ +export function istRuhezeit(d = new Date(), regel = "immer") { + if (regel === "nie") return false; const s = d.getHours(); + /* „spaet" ist EIN Bereich und nicht zwei -- SPAET_AB liegt vor + RUHE_BIS. Die Rechnung von „immer" (zwei Bereiche um Mitternacht + herum) gilt hier also nicht, und sie hier mitzubenutzen waere ein + Fehler, den man erst um ein Uhr nachts sieht. */ + if (regel === "spaet") return s >= SPAET_AB && s < RUHE_BIS; return s >= RUHE_AB || s < RUHE_BIS; } @@ -420,25 +519,67 @@ function zielFuer(personId, ziel) { } } +/** Welches Haus traegt diese Meldung? -> "crew" oder "agentur" + * + * Nur fuer das GESICHT der Benachrichtigung (Symbol und Abzeichen). + * Ueber Rechte entscheidet das hier nichts -- das tut `zielFuer` eine + * Funktion weiter oben, und zwar unveraendert. + * + * ZWEISTUFIG, UND BEIDE STUFEN GIBT ES SCHON: + * + * 1. Die Zielseite, wenn sie in genau ein Haus gehoert. Damit + * traegt die Einladung zur Reaction das Crew-Gesicht -- auch + * fuer DogFather, der in beiden Haeusern arbeitet und sonst + * ueberall dasselbe Symbol saehe. + * + * 2. Sonst das Haus der Person. `AGENTUR_ROLLEN` ist die + * vorhandene Menge; wer nicht darin steht, arbeitet im + * Crew-Haus. Wortgleich zu `nurAnderesHaus()`. + * + * IM ZWEIFEL "agentur" -- das ist der Stand von vor dem 29.09.2026. + * Ein Fehler hier darf das Gesicht aendern, nie die Zustellung. */ +export function hausFuerMeldung(personId, ziel) { + try { + const pfad = String(ziel || "").split("?")[0].split("#")[0]; + const eintrag = GEHOERT_ZU_ADRESSE.find((e) => e.datei === pfad); + if (eintrag) return eintrag.zuhause === istCrewAdresse ? "crew" : "agentur"; + const p = db().prepare("SELECT rolle FROM personen WHERE id = ?").get(personId); + if (!p) return "agentur"; + return AGENTUR_ROLLEN.has(p.rolle) ? "agentur" : "crew"; + } catch (f) { + console.error("[push] Haus bestimmen:", f?.message); + return "agentur"; + } +} + export async function benachrichtige(personId, art, { titel, text, ziel, merkmal }) { if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" }; if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" }; - /* Die Probe darf immer durch -- sie beantwortet die Frage "kommt - ueberhaupt etwas an?", und die stellt man sich auch um 23 Uhr. + /* WELCHE RUHEREGEL GILT, STEHT AN DER ART (29.09.2026). Hier stand + bis dahin `art === "test" || art === "anruf"` -- eine Aufzaehlung + 230 Zeilen von der Artenliste entfernt, die niemand sieht, der + eine Art eintraegt. - UND EIN ANRUF AUCH (18.09.2026). Die Ruhezeit ist gegen - Erinnerungen gebaut: Dinge, die der Server von sich aus schickt, - weil eine Frist naeher rueckt. Ein Anruf ist das Gegenteil -- ein - Mensch drueckt gerade auf den Hoerer und wartet. - - Gefunden hat das Filipes Satz „sie bekommt nur eine + Gefunden hat den Fall Filipes Satz „sie bekommt nur eine benarichtigung ... irgendwas laeuft da gewaltig schief", um 23:44 Uhr. Serverseitig war alles in Ordnung; der Anruf wurde nur nie - ausgeliefert, weil es nach 22 Uhr war. Wer nachts seine Ruhe will, - schaltet die Art „Jemand ruft an" ab. */ - const dringend = art === "test" || art === "anruf"; - if (!dringend && istRuhezeit()) return { verschickt: 0, grund: "ruhezeit" }; + ausgeliefert, weil es nach 22 Uhr war. Behoben wurde damals genau + dieser eine Fall -- die Frage dahinter blieb stehen, und + `dogfather_live` faellt bis heute in dieselbe Falle. + + WER NACHTS SEINE RUHE WILL, schaltet die Art ab. Das ist eine + Entscheidung, die man selbst trifft, statt einer Regel, die man + nicht kennt. */ + const regel = ruheRegel(art); + if (istRuhezeit(new Date(), regel)) return { verschickt: 0, grund: "ruhezeit" }; + + /* DRAENGELN IST ETWAS ANDERES ALS NACHTS STOEREN. Der Push-Dienst + kennt eine Dringlichkeit, die darueber entscheidet, ob ein + schlafendes Geraet dafuer aufwacht. Das gilt fuer den Anruf und + die Probe -- nicht fuer eine Einladung: Die soll ankommen, wenn + das Geraet ohnehin wach wird, und keinen Akku dafuer verbrennen. */ + const dringend = regel === "nie"; /* Schon einmal geschickt? */ if (merkmal) { @@ -456,7 +597,13 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal nachsehen kann, wohin wirklich geschickt wurde, ohne die Nachricht aufmachen zu muessen. */ const zielEcht = zielFuer(personId, ziel); - const inhalt = JSON.stringify({ titel, text, art, ziel: zielEcht }); + /* DAS HAUS WIRD AUS DEM ECHTEN ZIEL BESTIMMT, nicht aus dem + gewuenschten. Wird jemand von `zielFuer` auf die Startseite + umgeleitet, weil die Seite fuer ihn nicht existiert, soll auch das + Gesicht dazu passen -- sonst steht das Crew-Symbol ueber einer + Meldung, die auf der Agenturstartseite landet. */ + const haus = hausFuerMeldung(personId, zielEcht); + const inhalt = JSON.stringify({ titel, text, art, ziel: zielEcht, haus }); let raus = 0; for (const a of anmeldungen) { @@ -487,7 +634,12 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal db().prepare("INSERT OR REPLACE INTO push_verschickt (person_id, merkmal, zeit) VALUES (?,?,?)") .run(personId, merkmal, jetzt()); } - return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt", ziel: zielEcht }; + /* `haus` kommt mit zurueck, damit eine Pruefung nachsehen kann, + welches Gesicht verschickt wurde, ohne die verschluesselte + Nachricht aufmachen zu muessen -- derselbe Gedanke wie beim + Ziel. */ + return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt", + ziel: zielEcht, haus }; } /* --------------------------------------------------------------------- diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js index d5998515..ec69db4a 100644 --- a/server/workspace-reaktion.js +++ b/server/workspace-reaktion.js @@ -66,6 +66,10 @@ import { Sendung weiterschreibt -- genau die Luecke, die man erst bemerkt, wenn sie benutzt wird. */ import { treffSperreLesen } from "./treff-tabellen.js"; +/* Wer zum Crew-Haus gehoert, steht in `crew-adresse.js` -- die + Einladung liest dieselbe Menge wie die Hausweiche, statt eine + zweite danebenzustellen. */ +import { AGENTUR_ROLLEN } from "./crew-adresse.js"; export const reaktionRouter = express.Router(); @@ -677,6 +681,13 @@ reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ lim }); const l = lage(req.person); melden("reaktion", { was: "stand", lage: oeffentlich(l) }); + + /* ERST JETZT EINLADEN, nach dem Schreiben und nach `melden`. + Wer die Meldung antippt, landet auf einer Seite, die den + neuen Stand schon kennt -- sonst sieht der Schnellste einen + Saal, der noch zu ist. */ + if (neu === "live" && s.stand !== "live") einladen(sendung(), req.person.id); + res.json(l); } catch (fehler) { console.error("[reaktion] Stand:", fehler?.message); @@ -1495,6 +1506,105 @@ reaktionRouter.post("/workspace/api/reaktion/werbung", nurHost, } }); +/* ===================================================================== + DIE EINLADUNG -- wenn die Sendung aufgeht (29.09.2026) + + Filipe: „ich will dass du die benarichtigungen perfektionierst. + dogfather und alle anderen sollen die benachrichtigungen perfekt + von dieser seite hier bekommen." + + NACHGEMESSEN WAR DIESE DATEI STUMM: kein einziges `benachrichtige`. + Geht die Sendung auf Sendung, ruft sie `melden("reaktion", ...)` -- + und das laeuft ueber den Ereignisstrom, also NUR zu Leuten, die die + Seite ohnehin schon offen haben. Wer nicht hinschaut, erfaehrt + nichts. Das Kino macht auf, und die Einladung verlaesst das Haus + nie. + + Genau dasselbe Muster wie bei den Bewerbungen am 23.09.: beide Wege + gebaut, beide stumm. + ===================================================================== */ + +/** Wer wird eingeladen? + * + * Alle mit einem angemeldeten Geraet, deren Rolle NICHT der Agentur + * gehoert -- das ist woertlich die Hausteilung aus `crew-adresse.js` + * und keine neue Liste. Ein Scout bekaeme sonst eine Einladung in + * ein Kino, dessen Tuer fuer ihn nicht existiert: `zielFuer` schickt + * ihn auf die Startseite, und er stuende vor einer Meldung, die + * nirgendwohin fuehrt. + * + * OHNE DEN, DER EINGESCHALTET HAT. Er hat gerade den Knopf + * gedrueckt. */ +function eingeladene(ausser) { + return db().prepare(`SELECT DISTINCT a.person_id AS id, p.rolle + FROM push_anmeldungen a JOIN personen p ON p.id = a.person_id`).all() + .filter((z) => z.id !== ausser && !AGENTUR_ROLLEN.has(z.rolle)) + .map((z) => z.id); +} + +/** Lief eben schon eine Einladung raus? + * + * DIE BREMSE GEGEN DEN NEUSTART. Das Merkmal allein reicht nicht: + * Es haengt an `gestartet_am`, und das wird bei jedem Wechsel nach + * „live" neu gesetzt. Wer aus Versehen auf „Vorbereitung" und gleich + * wieder auf „Auf Sendung" drueckt -- oder wem der Rechner + * abstuerzt --, laedt sonst zweimal ein. Acht Menschen bekommen dann + * zwei Meldungen fuer dieselbe Sendung, und das ist der schnellste + * Weg, dass jemand die Art abschaltet. + * + * ZWEI STUNDEN, weil eine Reaction etwa so lange dauert. Wer danach + * eine zweite, wirklich neue Sendung aufmacht, laedt zu Recht neu + * ein. + * + * Gefragt wird `push_verschickt` -- also das, was WIRKLICH rausging. + * Wurde die erste Einladung verschluckt (Ruhezeit, kein Geraet), + * steht dort nichts, und der zweite Versuch darf. */ +function ebenSchonEingeladen() { + const z = db().prepare( + `SELECT MAX(zeit) AS zuletzt FROM push_verschickt + WHERE merkmal LIKE 'reaktion_live:%'`).get(); + if (!z?.zuletzt) return false; + return (Date.now() - Date.parse(z.zuletzt)) < 2 * 60 * 60 * 1000; +} + +/** Die Einladung verschicken -- ohne die Antwort aufzuhalten. + * + * Der Knopf „Auf Sendung" muss sofort reagieren. Acht + * Verschluesselungen und acht Netzwege dauern laenger als die + * Antwort dauern darf, und wenn ein Push-Dienst gerade klemmt, + * haenge die ganze Sendung daran. Deshalb losgeschickt und nicht + * abgewartet -- ein Fehler dabei steht im Protokoll, nicht im Weg. */ +function einladen(sendungJetzt, hostId) { + (async () => { + try { + if (ebenSchonEingeladen()) { + console.log("[reaktion] Einladung uebersprungen -- vor weniger als " + + "zwei Stunden lief schon eine."); + return; + } + const { benachrichtige } = await import("./workspace-push.js"); + const wer = eingeladene(hostId); + const titel = String(sendungJetzt.video_titel || sendungJetzt.titel || "").trim(); + let raus = 0; + for (const id of wer) { + const e = await benachrichtige(id, "reaktion_live", { + titel: "Die Reaction läuft", + /* WAS GESCHAUT WIRD, STEHT DRIN -- das ist der Unterschied + zwischen „komm rein" und „komm rein, wir schauen X". Ist + kein Titel da, wird keiner erfunden. */ + text: titel ? `Wir schauen: ${titel}` : "DogFather hat die Sendung aufgemacht.", + ziel: "/workspace/reaktion.html", + merkmal: `reaktion_live:${sendungJetzt.gestartet_am || jetzt()}`, + }); + if (e?.verschickt) raus++; + } + console.log(`[reaktion] Einladung an ${raus} von ${wer.length} verschickt.`); + } catch (f) { + console.error("[reaktion] Einladung:", f?.message); + } + })(); +} + /* Der Stand der Einblendung -- an EINER Stelle gerechnet. Drei Aufrufer (setzen, anlegen, aendern) brauchen dasselbe Ergebnis; dreimal abgeschrieben waere die Gelegenheit, dass einer davon diff --git a/tools/abzeichen-bauen.mjs b/tools/abzeichen-bauen.mjs new file mode 100644 index 00000000..1f6a94af --- /dev/null +++ b/tools/abzeichen-bauen.mjs @@ -0,0 +1,172 @@ +/* ===================================================================== + tools/abzeichen-bauen.mjs — das Abzeichen der Benachrichtigungen. + + WAS EIN ABZEICHEN IST, UND WAS NICHT + + Eine Benachrichtigung hat unter Android ZWEI Bilder: + + `icon` — das bunte Symbol im aufgeklappten Meldungskasten. + `badge` — das winzige Zeichen oben in der Statusleiste, 24 dp. + + Vom `badge` benutzt das System AUSSCHLIESSLICH den Alphakanal und + malt ihn einfarbig weiß. Alles Bunte daran geht verloren. + + Bis zum 29.09.2026 stand dort `workspace-192.png` — ein + vollflächiges, quadratisches Bild OHNE Transparenz. Dessen + Alphakanal ist überall voll. In der Statusleiste erschien deshalb + ein ausgefüllter Klotz und kein Zeichen. Das fällt nicht als Fehler + auf, weil nichts abstürzt; es sieht nur billig aus. + + --------------------------------------------------------------------- + WARUM EINE FLÄCHE UND KEIN STRICHBILD + + Drei Fassungen gebaut und bei echter Größe verglichen, statt zu + raten: + + A) die Tinte des Logos (Strichbild) — bei 24 px ein Gekrissel + B) dieselbe, verdickt — besser, immer noch unruhig + C) der geschlossene Umriss, gefüllt — trägt + + Die Ohrkerbe des Huskys ist bei 512 px zwei Prozent der Breite. Bei + 24 px ist sie ein halber Pixel und wird zu Grau. Eine Fläche + überlebt das Verkleinern, ein Strich nicht. + + --------------------------------------------------------------------- + EIN ABZEICHEN FÜR BEIDE HÄUSER — UND ZWAR ABSICHTLICH + + Crew und Agentur sind getrennt, und das gilt auch hier: Das bunte + `icon` ist je Haus ein anderes (crew-192 / workspace-192). Das + Abzeichen ist es NICHT, und das ist keine vergessene Trennung. + + Es ist 24 Pixel groß und einfarbig. Der Unterschied zwischen den + beiden Symbolen ist die Chili der Agentur — bei 24 px ein Fleck von + zwei Pixeln, der nicht als Chili lesbar ist, sondern als Schmutz + neben dem Kopf. Der Husky ist die Marke beider Häuser; ihn zu + teilen verrät nichts über das jeweils andere. + + --------------------------------------------------------------------- + WARUM DAS EIN SKRIPT IST UND KEIN EINMALIGER HANDGRIFF + + Ändert sich das Logo, wird das Abzeichen mit einem Befehl neu + gebaut. Ein von Hand zugeschnittenes PNG wäre in dem Moment eine + Kopie, die niemand mehr pflegt — dieselbe Falle wie eine + abgeschriebene Liste. + + node tools/abzeichen-bauen.mjs + ===================================================================== */ + +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync, statSync } from "node:fs"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const REPO = join(HIER, ".."); +const QUELLE = join(REPO, "assets", "img", "marke", "logo-kopf-frei.png"); +const ZIEL = join(REPO, "assets", "img", "abzeichen-96.png"); + +/* Die Rechnung steht in Python, weil dort Pillow liegt — Node hat im + Haus keine Bildbibliothek, und eine dafür zu installieren wäre eine + Abhängigkeit für 40 Zeilen. */ +const RECHNUNG = String.raw` +import sys +from collections import deque +from PIL import Image +import numpy as np + +quelle, ziel = sys.argv[1], sys.argv[2] +i = Image.open(quelle).convert("RGBA") + +# DIE ZEICHNUNG STECKT IM ALPHAKANAL. Gemessen: RGB ist in dieser +# Datei ueberall schwarz, das weisse Fell ist DURCHSICHTIG, nicht +# weiss. Wer hier auf Helligkeit schwellt, bekommt ein leeres Bild. +tinte = np.array(i.split()[3]) > 90 +h, w = tinte.shape +if not tinte.any(): + sys.exit("Die Quelle hat keine Zeichnung im Alphakanal.") + +# ---- Von aussen fluten: was der Rand erreicht, ist aussen ---------- +aussen = np.zeros((h, w), bool) +st = deque() +for x in range(w): + for y in (0, h - 1): + if not tinte[y, x] and not aussen[y, x]: + aussen[y, x] = True; st.append((y, x)) +for y in range(h): + for x in (0, w - 1): + if not tinte[y, x] and not aussen[y, x]: + aussen[y, x] = True; st.append((y, x)) +while st: + y, x = st.pop() + for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): + ny, nx = y + dy, x + dx + if 0 <= ny < h and 0 <= nx < w and not tinte[ny, nx] and not aussen[ny, nx]: + aussen[ny, nx] = True; st.append((ny, nx)) +voll = ~aussen + +# ---- Nur die groesste zusammenhaengende Flaeche ------------------- +# Im Logo liegen unter dem Kinn 18 lose Sprenkel. Bei 512 px sind sie +# Beiwerk, bei 24 px sind sie Schmutz neben dem Zeichen. Gezaehlt +# statt weggefiltert: Ein Filter muesste raten, wie dick ein Sprenkel +# hoechstens ist. +marke = np.zeros((h, w), np.int32) +nr, gr = 0, {} +for sy in range(h): + for sx in range(w): + if voll[sy, sx] and marke[sy, sx] == 0: + nr += 1; n = 0 + s2 = deque([(sy, sx)]); marke[sy, sx] = nr + while s2: + y, x = s2.pop(); n += 1 + for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): + ny, nx = y + dy, x + dx + if 0 <= ny < h and 0 <= nx < w and voll[ny, nx] and marke[ny, nx] == 0: + marke[ny, nx] = nr; s2.append((ny, nx)) + gr[nr] = n +best = max(gr, key=gr.get) +kopf = Image.fromarray(((marke == best) * 255).astype(np.uint8), "L") + +# ---- Zuschneiden, mittig, etwas Luft ------------------------------ +k = kopf.getbbox() +aus = kopf.crop(k) +s = max(aus.size); rand = int(s * 0.06) +feld = Image.new("L", (s + 2 * rand, s + 2 * rand), 0) +feld.paste(aus, (rand + (s - aus.width) // 2, rand + (s - aus.height) // 2)) + +# ---- 96 px, weiss, Form im Alphakanal ----------------------------- +maske = feld.resize((96, 96), Image.LANCZOS) +bild = Image.new("RGBA", (96, 96), (255, 255, 255, 255)) +bild.putalpha(maske) +bild.save(ziel, optimize=True) + +deckung = (np.array(maske) > 127).sum() / (96 * 96) +print(" %d Flaechen, %d Sprenkel verworfen, Deckung %.0f %%" + % (nr, nr - 1, 100 * deckung)) +# DIE ZAHL GEHOERT IN DIE BEDINGUNG. Ein Abzeichen, das 2 % deckt, ist +# ein Staubkorn; eines mit 90 % ist der Klotz, den wir gerade +# abschaffen. Beides faellt hier auf, nicht erst auf dem Telefon. +if not (0.15 <= deckung <= 0.75): + sys.exit("Deckung %.0f %% liegt ausserhalb des Sinnvollen." % (100 * deckung)) +`; + +if (!existsSync(QUELLE)) { + console.error(`Die Quelle fehlt: ${QUELLE}`); + process.exit(2); +} + +const lauf = spawnSync("python", ["-c", RECHNUNG, QUELLE, ZIEL], { encoding: "utf8" }); +if (lauf.error?.code === "ENOENT") { + /* DER DRITTE AUSGANG. „Python ist nicht da" ist etwas anderes als + „das Abzeichen ist falsch" — und darf nicht wie ein Befund + aussehen. */ + console.error("Python ist auf diesem Rechner nicht im Pfad — das Abzeichen " + + "wurde NICHT neu gebaut. Die vorhandene Datei bleibt, wie sie ist."); + process.exit(2); +} +if (lauf.stdout) process.stdout.write(lauf.stdout); +if (lauf.status !== 0) { + console.error(lauf.stderr?.trim() || "Der Bau ist gescheitert."); + process.exit(1); +} +const gross = statSync(ZIEL).size; +console.log(` ${ZIEL.replace(REPO, "").replace(/\\/g, "/")} — ${gross} Bytes`); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 7eb492d3..4e7fda57 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +