diff --git a/server/index.js b/server/index.js index cba2fd90..63f9af33 100644 --- a/server/index.js +++ b/server/index.js @@ -53,6 +53,7 @@ import { checklisteRouter } from "./workspace-checkliste.js"; import { teamlageRouter } from "./workspace-teamlage.js"; import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter } from "./workspace-chat.js"; +import { anrufRouter } from "./workspace-anruf.js"; import { videoRouter } from "./workspace-video.js"; import { bewerbungRouter } from "./workspace-bewerbung.js"; import { leistungRouter } from "./workspace-leistung.js"; @@ -75,7 +76,27 @@ app.use((req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "SAMEORIGIN"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); - res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()"); + /* MIKROFON UND KAMERA SIND GESPERRT -- AUSSER IM CHAT (18.09.2026). + + Diese Sperre ist kein Versehen, sie ist der Schutz: Ohne sie + koennte jedes eingeschleuste Skript auf JEDER Seite das Mikrofon + anschalten. Sie bleibt deshalb ueberall stehen und wird an genau + EINER Stelle geoeffnet -- dort, wo telefoniert wird. + + GEFUNDEN HAT DAS DIE PRUEFUNG, nicht das Lesen: Der Anrufkasten + meldete "Der Browser laesst das Mikrofon nicht zu", und in der + Konsole stand "microphone is not allowed in this document". Ohne + diesen Lauf waere der Anruf live gegangen und haette bei jedem + versagt -- mit einer Meldung, die auf die falsche Faehrte fuehrt + (man sucht dann an den Browsereinstellungen). + + `(self)` und nicht `*`: Nur diese Seite selbst, keine + eingebetteten Fremdinhalte. */ + const chatSeite = /^\/workspace\/chat\.html/.test(req.path); + res.setHeader("Permissions-Policy", + chatSeite + ? "geolocation=(), microphone=(self), camera=(self), payment=()" + : "geolocation=(), microphone=(), camera=(), payment=()"); /* HSTS NUR ÜBER EINE GESICHERTE VERBINDUNG (Befund 04.09.2026, Audit). @@ -213,6 +234,9 @@ app.use(pushRouter); alle Chat-Wege still 404 und niemand sieht warum. Der zweite Fall ist der unangenehmere: Die Seite laedt, der Chat bleibt leer. */ app.use(chatRouter); +/* Der Anruf nutzt den Ereigniskanal des Chats -- deshalb direkt + dahinter, damit die Reihenfolge die Abhaengigkeit zeigt. */ +app.use(anrufRouter); /* DER VIDEO-WEG (17.09.2026), Stufe A des Video-Plans. Er liegt VOR dem Bereichs-Router: Beide horchen auf `/workspace/api/bereich/...`, und `/:bereich/:id` wuerde "video" sonst fuer eine Nummer halten -- diff --git a/server/kachel-nachbarn.json b/server/kachel-nachbarn.json new file mode 100644 index 00000000..1ce19001 --- /dev/null +++ b/server/kachel-nachbarn.json @@ -0,0 +1,509 @@ +{ + "gemessen": "2026-09-18", + "seiten": [ + "start.html", + "uebersicht.html" + ], + "paare": [ + [ + "Creator-Profile", + "Zahlen" + ], + [ + "LIVE-Analyse", + "Technik" + ], + [ + "Meldungen & Maßnahmen", + "Wunschliste" + ], + [ + "Creator-Profile", + "LIVE-Analyse" + ], + [ + "Entwicklung", + "Wer sieht was" + ], + [ + "Mitmachen", + "Regeln & Hilfe" + ], + [ + "Anschlagbrett", + "Der Treff" + ], + [ + "Community", + "Mein Steckbrief" + ], + [ + "Dateien", + "Kalender" + ], + [ + "Community", + "Reports & Review" + ], + [ + "Automationen", + "Ideen-Board" + ], + [ + "Anschlagbrett", + "Talente" + ], + [ + "LIVE-Analyse", + "Zahlen" + ], + [ + "Reports & Review", + "Start-Check" + ], + [ + "Mein Steckbrief", + "Technik" + ], + [ + "Aufgaben", + "Calls & Protokolle" + ], + [ + "Agentur", + "Start-Check" + ], + [ + "Dashboard", + "Dateien" + ], + [ + "Schutz & Regeln", + "Zahlen" + ], + [ + "Content-Ideen", + "Start-Check" + ], + [ + "Aufgaben", + "Dashboard" + ], + [ + "Personen & Zugänge", + "Wissens-Bibliothek" + ], + [ + "LIVE-Analyse", + "Mein Steckbrief" + ], + [ + "Content-Ideen", + "Mein Steckbrief" + ], + [ + "Highlights", + "Was ansteht" + ], + [ + "Creator-Profile", + "Technik" + ], + [ + "Creator-Profile", + "Schutz & Regeln" + ], + [ + "Personen & Zugänge", + "Scout-Pipeline" + ], + [ + "Mein Steckbrief", + "Start-Check" + ], + [ + "Agentur", + "Content-Ideen" + ], + [ + "Der Treff", + "Entwicklung" + ], + [ + "Automationen", + "Dein Team" + ], + [ + "Community", + "Start-Check" + ], + [ + "Agentur", + "LIVE-Analyse" + ], + [ + "Content-Ideen", + "Technik" + ], + [ + "Der Treff", + "Wunschliste" + ], + [ + "Community", + "LIVE-Analyse" + ], + [ + "Dashboard", + "Kalender" + ], + [ + "Calls & Protokolle", + "Dashboard" + ], + [ + "Agentur", + "Technik" + ], + [ + "Der Treff", + "Talente" + ], + [ + "Technik", + "Zahlen" + ], + [ + "Personen & Zugänge", + "Reports & Review" + ], + [ + "Content-Ideen", + "Reports & Review" + ], + [ + "Community", + "Technik" + ], + [ + "Aufgaben", + "Chat" + ], + [ + "Mitmachen", + "Wunschliste" + ], + [ + "Ideen-Board", + "Wer sieht was" + ], + [ + "Creator-Profile", + "Mein Steckbrief" + ], + [ + "Der Treff", + "Meldungen & Maßnahmen" + ], + [ + "Agentur", + "Wissens-Bibliothek" + ], + [ + "Rückmeldung", + "Wer sieht was" + ], + [ + "Chat", + "Dashboard" + ], + [ + "LIVE-Analyse", + "Start-Check" + ], + [ + "Creator-Profile", + "Start-Check" + ], + [ + "Community", + "Zahlen" + ], + [ + "Community", + "Content-Ideen" + ], + [ + "Agentur", + "Personen & Zugänge" + ], + [ + "Meldungen & Maßnahmen", + "Mitmachen" + ], + [ + "Anschlagbrett", + "Highlights" + ], + [ + "Content-Ideen", + "LIVE-Analyse" + ], + [ + "Mein Steckbrief", + "Zahlen" + ], + [ + "Anschlagbrett", + "Was ansteht" + ], + [ + "Anschlagbrett", + "Entwicklung" + ], + [ + "Start-Check", + "Technik" + ], + [ + "Agentur", + "Reports & Review" + ], + [ + "Community", + "Creator-Profile" + ], + [ + "Automationen", + "Personen & Zugänge" + ], + [ + "Aufgaben", + "Team-Lage" + ], + [ + "LIVE-Analyse", + "Schutz & Regeln" + ], + [ + "Reports & Review", + "Wissens-Bibliothek" + ], + [ + "Calls & Protokolle", + "Dateien" + ], + [ + "LIVE-Analyse", + "Reports & Review" + ], + [ + "Agentur", + "Community" + ], + [ + "Aufgaben", + "Dateien" + ], + [ + "Anschlagbrett", + "Wunschliste" + ], + [ + "Calls & Protokolle", + "Kalender" + ], + [ + "Start-Check", + "Zahlen" + ], + [ + "Creator-Profile", + "Dateien" + ], + [ + "Dein Team", + "Ideen-Board" + ], + [ + "Automationen", + "Scout-Pipeline" + ], + [ + "Chat", + "Kalender" + ], + [ + "Content-Ideen", + "Creator-Profile" + ], + [ + "Der Treff", + "Was ansteht" + ], + [ + "Talente", + "Wer sieht was" + ], + [ + "Reports & Review", + "Technik" + ], + [ + "Agentur", + "Schutz & Regeln" + ], + [ + "Dateien", + "Zahlen" + ], + [ + "Ideen-Board", + "Rückmeldung" + ], + [ + "Regeln & Hilfe", + "Was ansteht" + ], + [ + "Anschlagbrett", + "Meldungen & Maßnahmen" + ], + [ + "Automationen", + "Wissens-Bibliothek" + ], + [ + "Calls & Protokolle", + "Chat" + ], + [ + "Aufgaben", + "Kalender" + ], + [ + "Meldungen & Maßnahmen", + "Was ansteht" + ], + [ + "Regeln & Hilfe", + "Wunschliste" + ], + [ + "Mitmachen", + "Was ansteht" + ], + [ + "Chat", + "Dateien" + ], + [ + "Reports & Review", + "Scout-Pipeline" + ], + [ + "Meldungen & Maßnahmen", + "Regeln & Hilfe" + ], + [ + "Der Treff", + "Highlights" + ], + [ + "Agentur", + "Scout-Pipeline" + ], + [ + "Automationen", + "Rückmeldung" + ], + [ + "Scout-Pipeline", + "Wissens-Bibliothek" + ], + [ + "Was ansteht", + "Wunschliste" + ], + [ + "Der Treff", + "Mitmachen" + ], + [ + "Content-Ideen", + "Zahlen" + ], + [ + "Dashboard", + "Team-Lage" + ], + [ + "Schutz & Regeln", + "Start-Check" + ], + [ + "Dateien", + "Mein Steckbrief" + ], + [ + "Schutz & Regeln", + "Technik" + ], + [ + "Community", + "Schutz & Regeln" + ], + [ + "Anschlagbrett", + "Mitmachen" + ], + [ + "Entwicklung", + "Talente" + ], + [ + "Anschlagbrett", + "Regeln & Hilfe" + ], + [ + "Reports & Review", + "Schutz & Regeln" + ], + [ + "Mein Steckbrief", + "Schutz & Regeln" + ], + [ + "Der Treff", + "Regeln & Hilfe" + ], + [ + "Highlights", + "Regeln & Hilfe" + ], + [ + "Dein Team", + "Wer sieht was" + ], + [ + "Highlights", + "Mitmachen" + ], + [ + "Highlights", + "Meldungen & Maßnahmen" + ], + [ + "Content-Ideen", + "Schutz & Regeln" + ], + [ + "Highlights", + "Wunschliste" + ], + [ + "Dein Team", + "Rückmeldung" + ] + ] +} \ No newline at end of file diff --git a/server/pruef-anruf.mjs b/server/pruef-anruf.mjs new file mode 100644 index 00000000..a93dbe4e --- /dev/null +++ b/server/pruef-anruf.mjs @@ -0,0 +1,354 @@ +/* ===================================================================== + TELEFONIEREN IM CHAT -- die Riegel (18.09.2026) + + Filipe: „kann man machen dass die modis, rechte hand und ich auch + telefonieren können im chat?" + + DER WICHTIGSTE ABSCHNITT IST DER LETZTE. Ein Anruf ist der + vertraulichste Vorgang im ganzen Haus: Was gesagt wird, steht + nirgends, und wer mithört, hinterlässt keine Spur. Die Frage „kommt + jemand hinein, der nicht in den Raum gehört?" muss deshalb an JEDER + Route beantwortet sein und nicht an der Liste davor. + + Besonders die Signalisierung: Sie reicht beliebiges JSON an eine + Personennummer weiter. Ohne Prüfung wäre das ein Weg, jedem + Angemeldeten etwas zu schicken -- an jeder Chatberechtigung vorbei. + + Aufrufen mit: node server/pruef-anruf.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4196, "pruef-anruf"); +const ordner = mkdtempSync(join(tmpdir(), "ws-anruf-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +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"; + +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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(180_000, "pruef-anruf"); +await new Promise((r) => setTimeout(r, 800)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); + +let fehler = 0, geprueft = 0, offen = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +const CREW = "crew.dogfather-universe.com"; +const HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const anrufModul = await import("./workspace-anruf.js"); + +/* ---- Leute anlegen ---- */ +const d = new DatabaseSync(process.env.WORKSPACE_DB); +function person(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, 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(name, rolle, hash, salt, 32768, new Date().toISOString()); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = person("Filipe", "admin", "CODE-DOGI-0001"); +const idHand = person("Rieke", "hand", "CODE-HAND-0001"); +const idModi = person("Kessi", "modi", "CODE-MODI-0001"); +const idFremd = person("Tili", "creator", "CODE-TILI-0001"); +d.close(); + +function roh(pfad, host, kopf = {}, koerper = null, methode = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: methode || (koerper ? "POST" : "GET"), + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, + }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code, host) { + const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); +const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); +const kModi = await anmelden("modi", "CODE-MODI-0001", CREW); +const kFremd = await anmelden("creator", "CODE-TILI-0001", HAUS); +ok(!!kDogi && !!kHand && !!kModi && !!kFremd, "vier Leute sind angemeldet"); + +/* ======================================================================= + 1. DER KATALOG + ======================================================================= */ +melde(""); +melde("=== 1. Was fest steht ==="); +ok(anrufModul.GRUPPE_MAX >= 2 && anrufModul.GRUPPE_MAX <= 8, + `die Gruppengrenze steht als Zahl im Code (${anrufModul.GRUPPE_MAX})`); + +/* ======================================================================= + 2. EIN GESPRAECH UND EIN ANRUF + ======================================================================= */ +melde(""); +melde("=== 2. Anrufen ==="); + +/* Ein Zweier-Gespraech zwischen DogFather und der rechten Hand. */ +let raumId = null; +{ + /* Die Route heisst `direkt`, nicht `mit` -- nachgesehen statt + geraten. */ + const r = await roh("/workspace/api/chat/direkt", HAUS, { cookie: kDogi }, + { mit: idHand }); + raumId = json(r).raum?.id || json(r).raum_id || json(r).id; + ok(!!raumId, `ein Gespraech entsteht (${r.code}, Raum ${raumId})`); +} + +{ + const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }); + ok(r.code === 200 && json(r).laeuft === false, + `am Anfang laeuft kein Anruf (${json(r).laeuft})`); +} + +{ + const r = await roh(`/workspace/api/anruf/${raumId}/start`, HAUS, + { cookie: kDogi }, { video: false }); + ok(r.code === 200, `anrufen geht (${r.code})`); + const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi })); + ok(l.laeuft === true, "und danach laeuft einer"); + ok(l.wer?.length === 1 && l.wer[0].id === idDogi, + `mit genau einem Teilnehmer (${l.wer?.map((w) => w.name).join(", ")})`); +} + +{ + const r = await roh(`/workspace/api/anruf/${raumId}/dabei`, CREW, { cookie: kHand }, {}, "POST"); + ok(r.code === 200, `die rechte Hand geht ran (${r.code})`); + const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kHand })); + ok(l.wer?.length === 2, `jetzt sind es zwei (${l.wer?.map((w) => w.name).join(", ")})`); + ok(l.ich_dabei === true, "und sie weiss, dass sie dabei ist"); +} + +/* ======================================================================= + 3. DIE SIGNALISIERUNG + + Sie reicht beliebiges JSON an eine Personennummer weiter. Ohne + Pruefung waere das ein Weg, jedem Angemeldeten etwas zu schicken -- + an jeder Chatberechtigung vorbei. + ======================================================================= */ +melde(""); +melde("=== 3. Die Signalisierung ==="); +{ + const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi }, + { an: idHand, inhalt: { angebot: { type: "offer", sdp: "v=0" } } }); + ok(r.code === 200, `ein Angebot geht durch (${r.code})`); +} +{ + const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi }, + { an: idFremd, inhalt: { angebot: {} } }); + ok(r.code === 404, + `an jemanden AUSSERHALB des Raums geht nichts (${r.code}) -- sonst waere das ` + + "ein Weg, jedem Angemeldeten JSON zu schicken"); +} +{ + const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi }, + { an: idHand }); + ok(r.code === 400, `ohne Inhalt wird abgewiesen (${r.code})`); +} + +/* ======================================================================= + 4. WER NICHT HINEINGEHOERT + + Der wichtigste Abschnitt. Ein Anruf hinterlaesst keine Spur -- wer + mithoert, faellt nicht auf. Die Frage muss an JEDER Route + beantwortet sein. + ======================================================================= */ +melde(""); +melde("=== 4. Wer nicht hineingehoert ==="); +for (const [pfad, methode, koerper] of [ + ["", "GET", null], + ["/start", "POST", { video: false }], + ["/dabei", "POST", {}], + ["/ende", "POST", {}], + ["/signal", "POST", { an: idDogi, inhalt: { weg: {} } }], +]) { + const r = await roh(`/workspace/api/anruf/${raumId}${pfad}`, HAUS, + { cookie: kFremd }, koerper, methode); + ok(r.code === 404, + `${(pfad || "/lage").padEnd(8)} ist fuer Fremde zu -- und zwar 404, ` + + `nicht „kein Zugriff" (${r.code})`); +} +{ + const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, {}); + ok(r.code === 401, `ohne Anmeldung gar nichts (${r.code})`); +} +{ + const r = await roh("/workspace/api/anruf/999999/start", HAUS, + { cookie: kDogi }, { video: false }); + ok(r.code === 404, `ein erfundener Raum wird abgewiesen (${r.code})`); +} + +/* ======================================================================= + 5. AUFLEGEN + ======================================================================= */ +melde(""); +melde("=== 5. Auflegen ==="); +{ + const r = await roh(`/workspace/api/anruf/${raumId}/ende`, CREW, { cookie: kHand }, {}, "POST"); + ok(r.code === 200, `die rechte Hand legt auf (${r.code})`); + const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi })); + ok(l.laeuft === true && l.wer?.length === 1, + `der Anruf laeuft weiter, solange noch jemand drin ist (${l.wer?.length})`); + await roh(`/workspace/api/anruf/${raumId}/ende`, HAUS, { cookie: kDogi }, {}, "POST"); + const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi })); + ok(l2.laeuft === false, "wenn der Letzte geht, ist er vorbei"); +} + +/* ======================================================================= + 6. DIE ADRESSEN + + Sie enthalten Zugangsdaten (TURN braucht Benutzer und Passwort). + Deshalb darf nur DogFather sie setzen. + ======================================================================= */ +melde(""); +melde("=== 6. Die Verbindungsadressen ==="); +{ + const r = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi })); + ok(Array.isArray(r.adressen), `die Liste kommt (${r.adressen?.length} Eintraege)`); + ok(r.nur_gleiches_netz === true, + "und sie sagt ehrlich, dass es ohne Eintrag nur im selben Netz geht"); +} +{ + const r = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kHand }, + { adressen: [{ urls: "stun:beispiel.de:3478" }] }, "PUT"); + ok(r.code === 404, `die rechte Hand darf sie nicht setzen (${r.code})`); +} +{ + const r = await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, { + adressen: [ + { urls: "stun:turn.dogfather-universe.com:3478" }, + { urls: "turn:turn.dogfather-universe.com:3478", username: "a", credential: "b" }, + /* DAS HIER MUSS RAUSFALLEN: kein STUN/TURN, sondern irgendetwas. */ + { urls: "https://beispiel.de/boese" }, + ], + }, "PUT"); + ok(r.code === 200 && json(r).anzahl === 2, + `DogFather setzt sie, und nur echte Adressen bleiben (${json(r).anzahl} von 3)`); + const l = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi })); + ok(l.adressen?.length === 2, `alle bekommen sie zu sehen (${l.adressen?.length})`); + ok(l.nur_gleiches_netz === false, "und der Hinweis verschwindet"); +} + +/* ======================================================================= + 7. IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 7. Im Browser ==="); +{ + let chromium; + try { + ({ chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs")); + } catch { chromium = null; } + + if (!chromium) { + unklar("kein Playwright gefunden -- die Oberflaeche wurde nicht angesehen"); + } else { + const browser = await chromium.launch({ + /* Mikrofon ohne Rueckfrage und mit einem Testton statt echter + Hardware -- sonst haengt getUserMedia im Prueflauf. */ + args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"], + }); + try { + const sicht = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + permissions: ["microphone", "camera"], + }); + await sicht.addCookies(kDogi.split("; ").map((c) => { + const [name, ...rest] = c.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await sicht.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`, + { waitUntil: "domcontentloaded" }); + await seite.waitForTimeout(1800); + + ok(await seite.evaluate(() => typeof window.anrufStarten === "function"), + "der Anruf ist geladen"); + + /* Ein Gespraech oeffnen -- erst dann gibt es Knoepfe. */ + const raeume = await seite.locator("#raeume li").count(); + ok(raeume > 0, `${raeume} Gespraech(e) in der Liste`); + await seite.locator("#raeume li").first().click(); + await seite.waitForTimeout(1200); + + const knoepfe = await seite.evaluate(() => ({ + ton: !!document.getElementById("anruf-ton-start"), + video: !!document.getElementById("anruf-video-start"), + sichtbar: document.getElementById("anruf-ton-start")?.offsetHeight > 0, + })); + ok(knoepfe.ton && knoepfe.video, "beide Anrufknoepfe stehen im Kopf"); + ok(knoepfe.sichtbar, "und man sieht sie"); + + /* ANRUFEN -- und zwar wirklich. Ein Test, der nur prueft, dass + ein Knopf da ist, prueft, dass jemand einen Knopf gebaut hat. */ + await seite.click("#anruf-ton-start"); + await seite.waitForTimeout(2500); + const kasten = await seite.evaluate(() => { + const k = document.getElementById("anruf"); + return { + da: !!k && !k.hidden, + lage: document.getElementById("anruf-lage")?.textContent || "", + zeit: document.getElementById("anruf-zeit")?.textContent || "", + }; + }); + ok(kasten.da, `der Anrufkasten erscheint ("${kasten.lage}")`); + ok(/klingelt|Verbind/i.test(kasten.lage), + `und sagt, was gerade passiert ("${kasten.lage}")`); + ok(/^\d\d:\d\d$/.test(kasten.zeit), `die Uhr laeuft (${kasten.zeit})`); + + /* Und der Server weiss davon. */ + const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi })); + ok(l.laeuft === true, "der Server kennt den Anruf"); + + await seite.screenshot({ path: "server/pruef-anruf.png", fullPage: false }); + melde(" -- Bildschirmfoto: server/pruef-anruf.png"); + + await seite.click("#anruf-auflegen"); + await seite.waitForTimeout(900); + ok(await seite.evaluate(() => document.getElementById("anruf")?.hidden === true), + "nach dem Auflegen ist der Kasten weg"); + const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi })); + ok(l2.laeuft === false, "und der Server auch"); + + ok(konsole.length === 0, + konsole.length ? `Konsolenfehler: ${konsole[0]}` : "kein Konsolenfehler"); + } finally { + await browser.close(); + } + } +} + +/* ------------------------------------------------------------------- */ +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler` + + (offen ? `, ${offen} nicht nachsehbar` : "")); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-anruf.js b/server/workspace-anruf.js new file mode 100644 index 00000000..bf587379 --- /dev/null +++ b/server/workspace-anruf.js @@ -0,0 +1,393 @@ +/* ===================================================================== + TELEFONIEREN IM CHAT (18.09.2026) + + Filipe: „kann man machen dass die modis, rechte hand und ich auch + telefonieren können im chat?" -- und nach dem Plan: „ich will dass du + das nimmst was man selbst in die app reinsetzten kann, nicht meinen + pc belastet und trotzdem vielleicht in gruppe, mit video oder + einzelnd, alles was möglich ist." + + --------------------------------------------------------------------- + WAS HIER PASSIERT -- UND WAS AUSDRÜCKLICH NICHT + + Der Ton geht NICHT über diesen Server. Er geht direkt von einem + Browser zum anderen (WebRTC). Dieser Server tut nur das, was die + beiden allein nicht können: Er reicht die Verbindungsdaten weiter, + damit sie sich finden. Das sind ein paar Kilobyte je Anruf -- + danach ist er aus dem Spiel. + + DAS IST DIE ANTWORT AUF „NICHT MEINEN PC BELASTEN": + Bei zwei Teilnehmern kodiert jedes Gerät genau einen Strom und + dekodiert einen. Das ist die Last eines gewöhnlichen Videoanrufs. + Erst bei GRUPPEN wächst sie -- dazu unten mehr. + + --------------------------------------------------------------------- + WARUM ES KEINEN NEUEN DIENST BRAUCHT + + Der Chat hat bereits alles, was die Signalisierung verlangt: + + `chatEreignis()` schickt an alle offenen Verbindungen eines Raums + (SSE) -- der Hinweg für Angebot, Antwort und + Verbindungswege. + Push weckt die, die gerade nicht zusehen -- das + Klingeln. + `chat_raeume` kennt Räume mit beliebig vielen Teilnehmern -- + Einzelgespräch ist der Sonderfall „genau zwei". + + Ein Anruf ist damit kein neues Konzept, sondern ein Ereignis mehr in + einem Kanal, den es gibt. Ein eigener WebSocket-Dienst daneben wäre + eine zweite Verbindung, die dieselbe Frage beantwortet -- und die + zweite ist die, die beim nächsten Umbau vergessen wird. + + --------------------------------------------------------------------- + DIE GRENZEN, EHRLICH AUFGESCHRIEBEN + + 1. ZU ZWEIT ist die Last minimal und es funktioniert überall. + + 2. IN DER GRUPPE verbindet sich jeder mit jedem. Bei drei Leuten + sind das zwei Ströme hin und zwei zurück, bei vier je drei. Ab + etwa vier Teilnehmern wird das auf schwächeren Geräten spürbar. + Die Grenze steht deshalb als Zahl im Code (GRUPPE_MAX) und nicht + im Gedächtnis -- wer sie erhöht, soll sehen, was daran hängt. + + Der saubere Weg darüber hinaus wäre ein Medienserver (SFU), der + die Ströme verteilt: Dann sendet jedes Gerät nur EINMAL. Der + läuft auf unserem Server, nicht auf Filipes Rechner -- aber er + ist ein eigener Dienst mit eigener Wartung. Das ist der nächste + Schritt, nicht dieser. + + 3. AUF DEM IPHONE hört der Ton auf, sobald der Bildschirm ausgeht + oder Safari in den Hintergrund rutscht. Das ist eine Regel von + Apple für alle Web-Anwendungen und lässt sich nicht umgehen. Es + klingelt zuverlässig -- wer rangeht, muss die Seite offen lassen. + + 4. OHNE EIGENEN TURN-SERVER kommen etwa 15 bis 25 Prozent der + Verbindungen nicht zustande (strenge Firmennetze, manche + Mobilfunknetze). Die Adressen stehen in den Einstellungen und + lassen sich nachtragen, ohne eine Zeile Code zu ändern. + ===================================================================== */ +import express from "express"; +import { + db, sitzungLesen, protokolliere, echteIp, einstellung, einstellungSetzen, + istDogFather, +} from "./workspace.js"; +import { chatEreignis, istDrinFuerAnruf, teilnehmerFuerAnruf } from "./workspace-chat.js"; + +export const anrufRouter = express.Router(); + +/** Wie viele gleichzeitig -- und warum diese Zahl. + * + * Jeder verbindet sich mit jedem: Bei N Teilnehmern sendet jedes + * Gerät N-1 Ströme und empfängt N-1. Bei vier sind das je drei, und + * drei Videoströme zu kodieren merkt ein älteres Handy deutlich. + * + * VIER IST DIE GRENZE, AN DER ES NOCH ANGENEHM IST. Wer sie erhöht, + * erhöht die Last quadratisch -- deshalb steht sie hier und nicht + * verstreut in drei Abfragen. */ +export const GRUPPE_MAX = 4; + +/** Wie lange ein Anruf im Raum „klingelt", bevor er von selbst verfällt. + * + * Ohne diese Grenze bliebe ein Anruf, bei dem der Anrufer die Seite + * schließt, für immer offen stehen -- und der Angerufene sähe eine + * Einladung zu einem Gespräch, an dem niemand mehr teilnimmt. */ +const KLINGELT_SEKUNDEN = 45; + +/* Wer gerade in welchem Raum telefoniert. + IM SPEICHER, NICHT IN DER DATENBANK: Ein laufender Anruf ist ein + Zustand dieses Prozesses, kein Datum. Startet der Server neu, ist + jeder Anruf ohnehin zu Ende -- ein Eintrag in der Datenbank würde + danach eine Verbindung behaupten, die es nicht mehr gibt. */ +const laufend = new Map(); // raumId -> { seit, wer: Map(personId -> {name, seit}) } + +/** Räumt ab, was abgelaufen ist. Läuft bei jeder Anfrage mit, statt + * als Zeitgeber: Ein Zeitgeber, der alle zehn Sekunden über eine + * leere Liste läuft, ist Arbeit ohne Anlass. */ +function aufraeumen() { + const jetzt = Date.now(); + for (const [raumId, a] of laufend) { + if (!a.wer.size) { laufend.delete(raumId); continue; } + /* Ein Anruf, bei dem seit KLINGELT_SEKUNDEN nur EINER drin ist, + ist keiner mehr -- der Angerufene ist nicht rangegangen. */ + if (a.wer.size === 1 && jetzt - a.seit > KLINGELT_SEKUNDEN * 1000) { + laufend.delete(raumId); + } + } +} + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +/* GLEICHE HERKUNFT. Ein Anruf ist ein schreibender Vorgang -- er + klingelt bei jemandem. Dieselbe Pruefung wie ueberall sonst. */ +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + try { + if (new URL(herkunft).host === req.get("host")) return next(); + } catch { /* unlesbar -> abweisen */ } + return res.status(403).json({ fehler: "fremde_herkunft" }); +} + +anrufRouter.use("/workspace/api/anruf", angemeldet); + +const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; }; + +/* ===================================================================== + DIE VERBINDUNGSADRESSEN + + STUN sagt einem Browser, wie er von aussen aussieht. TURN leitet den + Ton weiter, wenn eine direkte Verbindung nicht zustande kommt. + + SIE STEHEN IN DEN EINSTELLUNGEN, NICHT IM CODE. Wer einen eigenen + TURN-Server einrichtet, traegt ihn dort ein -- ohne dass jemand eine + Zeile aendert und ohne Neustart. Genau daran ist im Haus schon + einmal etwas gescheitert: Eine Adresse im Quelltext ist eine, die + beim naechsten Umzug niemand findet. + + DER STANDARD IST BEWUSST LEER, was STUN angeht: Ein oeffentlicher + STUN-Dienst (etwa der von Google) bekommt bei jedem Anruf die + IP-Adresse beider Teilnehmer zu sehen. Das ist fuer ein Team, das + ueber Moderation und Vorfaelle spricht, keine Kleinigkeit -- und es + waere eine Entscheidung, die ich nicht still treffen darf. Ohne + Eintrag funktioniert der Anruf im selben Netz; sobald coturn auf dem + eigenen Server steht, funktioniert er ueberall. + ===================================================================== */ +anrufRouter.get("/workspace/api/anruf/adressen", (req, res) => { + try { + let liste = []; + try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; } + res.json({ + adressen: Array.isArray(liste) ? liste : [], + gruppe_max: GRUPPE_MAX, + /* WAS DIE OBERFLAECHE WISSEN MUSS, um ehrlich zu sein: Ohne + Adressen klappt der Anruf nur im selben Netz. Das gehoert + DAVOR gesagt, nicht als Fehlermeldung danach. */ + nur_gleiches_netz: !Array.isArray(liste) || liste.length === 0, + }); + } catch (f) { + console.error("[anruf] Adressen:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Die Adressen eintragen -- nur DogFather. + * + * Es sind Zugangsdaten zu einem Dienst (TURN braucht Benutzer und + * Passwort). Die gehoeren nicht in eine Oberflaeche, die jeder sieht. */ +anrufRouter.put("/workspace/api/anruf/adressen", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + if (!istDogFather(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + const liste = Array.isArray(req.body?.adressen) ? req.body.adressen : null; + if (!liste) return res.status(400).json({ fehler: "Erwartet wird eine Liste." }); + /* Nur die Felder, die WebRTC kennt -- alles andere waere ein + offenes Tor, um beliebiges JSON in die Einstellungen zu legen. */ + const sauber = liste.slice(0, 8).map((e) => ({ + urls: String(e?.urls || "").slice(0, 200), + ...(e?.username ? { username: String(e.username).slice(0, 100) } : {}), + ...(e?.credential ? { credential: String(e.credential).slice(0, 200) } : {}), + })).filter((e) => /^(stun|turn|turns):/.test(e.urls)); + einstellungSetzen("anruf_adressen", JSON.stringify(sauber)); + protokolliere("anruf_adressen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${sauber.length} Adresse(n)`, + }); + res.json({ ok: true, anzahl: sauber.length }); + } catch (f) { + console.error("[anruf] Adressen setzen:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ===================================================================== + ANRUFEN + ===================================================================== */ +anrufRouter.post("/workspace/api/anruf/:raum/start", gleicheHerkunft, + express.json({ limit: "4kb" }), (req, res) => { + try { + aufraeumen(); + const raumId = nummer(req.params.raum); + if (!raumId || !istDrinFuerAnruf(raumId, req.person)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const mitVideo = req.body?.video === true; + + const leute = teilnehmerFuerAnruf(raumId); + if (leute.length > GRUPPE_MAX) { + return res.status(409).json({ + fehler: `In diesem Gespräch sind ${leute.length} Leute. ` + + `Telefonieren geht bis ${GRUPPE_MAX} – darüber wird es für die ` + + `Geräte zu viel.`, + }); + } + + const schon = laufend.get(raumId); + if (schon && !schon.wer.has(req.person.id)) { + /* Es laeuft schon einer -- dann tritt man bei, statt einen + zweiten zu starten. Zwei Anrufe im selben Raum waeren zwei + Gespraeche, von denen jeder das andere nicht hoert. */ + schon.wer.set(req.person.id, { name: req.person.name, seit: Date.now() }); + chatEreignis(raumId, { + raum_id: raumId, art: "dabei", + person: { id: req.person.id, name: req.person.name }, + wer: [...schon.wer.entries()].map(([id, w]) => ({ id, name: w.name })), + }, [], "anruf"); + return res.json({ ok: true, beigetreten: true, video: schon.video }); + } + + laufend.set(raumId, { + seit: Date.now(), video: mitVideo, + wer: new Map([[req.person.id, { name: req.person.name, seit: Date.now() }]]), + }); + + /* DAS KLINGELN. `chatEreignis` schickt an alle offenen + Verbindungen des Raums UND weckt per Push, wer nicht zusieht -- + genau die zwei Wege, die ein Anruf braucht. Der dritte + Parameter sind die, die geweckt werden sollen: alle ausser dem + Anrufer. */ + const wecken = leute.map((t) => t.id).filter((id) => id !== req.person.id); + chatEreignis(raumId, { + raum_id: raumId, art: "klingelt", video: mitVideo, + von: { id: req.person.id, name: req.person.name }, + laeuft_bis: Date.now() + KLINGELT_SEKUNDEN * 1000, + }, wecken, "anruf"); + + protokolliere("anruf_start", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Raum ${raumId}${mitVideo ? " mit Video" : ""}`, + }); + res.json({ ok: true, video: mitVideo, wer: [{ id: req.person.id, name: req.person.name }] }); + } catch (f) { + console.error("[anruf] Start:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Annehmen. */ +anrufRouter.post("/workspace/api/anruf/:raum/dabei", gleicheHerkunft, (req, res) => { + try { + aufraeumen(); + const raumId = nummer(req.params.raum); + if (!raumId || !istDrinFuerAnruf(raumId, req.person)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const a = laufend.get(raumId); + if (!a) return res.status(409).json({ fehler: "Der Anruf ist schon vorbei." }); + if (a.wer.size >= GRUPPE_MAX) { + return res.status(409).json({ fehler: `Es telefonieren schon ${GRUPPE_MAX}.` }); + } + a.wer.set(req.person.id, { name: req.person.name, seit: Date.now() }); + chatEreignis(raumId, { + raum_id: raumId, art: "dabei", + person: { id: req.person.id, name: req.person.name }, + wer: [...a.wer.entries()].map(([id, w]) => ({ id, name: w.name })), + }, [], "anruf"); + res.json({ ok: true, video: a.video, + wer: [...a.wer.entries()].map(([id, w]) => ({ id, name: w.name })) }); + } catch (f) { + console.error("[anruf] Dabei:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Auflegen -- oder ablehnen. Derselbe Weg. + * + * Wer ablehnt, war nie dabei; wer auflegt, geht raus. Beides endet in + * „diese Person nimmt nicht (mehr) teil", und zwei getrennte Wege + * waeren zwei Gelegenheiten, den Zustand verschieden zu fuehren. */ +anrufRouter.post("/workspace/api/anruf/:raum/ende", gleicheHerkunft, (req, res) => { + try { + const raumId = nummer(req.params.raum); + if (!raumId || !istDrinFuerAnruf(raumId, req.person)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const a = laufend.get(raumId); + if (a) { + const war = a.wer.get(req.person.id); + a.wer.delete(req.person.id); + const dauer = war ? Math.round((Date.now() - war.seit) / 1000) : 0; + if (!a.wer.size) laufend.delete(raumId); + chatEreignis(raumId, { + raum_id: raumId, art: a.wer.size ? "weg" : "ende", + person: { id: req.person.id, name: req.person.name }, + wer: [...a.wer.entries()].map(([id, w]) => ({ id, name: w.name })), + dauer, + }, [], "anruf"); + if (war && dauer > 2) { + protokolliere("anruf_ende", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Raum ${raumId}, ${dauer} s`, + }); + } + } + res.json({ ok: true }); + } catch (f) { + console.error("[anruf] Ende:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + DIE SIGNALISIERUNG + + Angebot, Antwort und Verbindungswege wandern hier durch -- und NUR + sie. Der Server liest sie nicht, er reicht sie weiter. + + AN EINE BESTIMMTE PERSON, nicht an den ganzen Raum: Bei drei + Teilnehmern baut jeder mit jedem eine eigene Verbindung auf, und ein + Angebot, das an alle geht, würde von zweien beantwortet. + ===================================================================== */ +anrufRouter.post("/workspace/api/anruf/:raum/signal", gleicheHerkunft, + express.json({ limit: "64kb" }), (req, res) => { + try { + const raumId = nummer(req.params.raum); + if (!raumId || !istDrinFuerAnruf(raumId, req.person)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const an = nummer(req.body?.an); + const inhalt = req.body?.inhalt; + if (!an || !inhalt || typeof inhalt !== "object") { + return res.status(400).json({ fehler: "unvollstaendig" }); + } + /* DER EMPFAENGER MUSS IM RAUM SEIN. Ohne diese Zeile waere das + hier ein Weg, jedem Angemeldeten beliebiges JSON zu schicken -- + an jeder Chatberechtigung vorbei. */ + if (!teilnehmerFuerAnruf(raumId).some((t) => t.id === an)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + chatEreignis(raumId, { + raum_id: raumId, art: "signal", + von: req.person.id, an, inhalt, + }, [], "anruf"); + res.json({ ok: true }); + } catch (f) { + console.error("[anruf] Signal:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Läuft hier gerade ein Anruf? Für den, der die Seite frisch öffnet. */ +anrufRouter.get("/workspace/api/anruf/:raum", (req, res) => { + try { + aufraeumen(); + const raumId = nummer(req.params.raum); + if (!raumId || !istDrinFuerAnruf(raumId, req.person)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const a = laufend.get(raumId); + res.json({ + laeuft: !!a, + video: a?.video || false, + wer: a ? [...a.wer.entries()].map(([id, w]) => ({ id, name: w.name })) : [], + ich_dabei: !!a?.wer.has(req.person.id), + }); + } catch (f) { + console.error("[anruf] Lage:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 8111798a..c428c0a6 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -1938,6 +1938,35 @@ export function nachrichtSchicken(person, anId, text) { return { id, raum_id: raumId }; } +/* ===================================================================== + WAS DER ANRUF BRAUCHT (18.09.2026) + + Drei Dinge, und alle drei gibt es hier schon: den Ereigniskanal, die + Zugangspruefung und die Teilnehmerliste. Sie werden ausdruecklich + hier freigegeben und nicht drueben nachgebaut -- eine zweite Fassung + von „wer darf in diesen Raum" waere die, die beim naechsten Umbau + nicht mitgeaendert wird, und genau daran haengt, wer mithoeren kann. + + `istDrinFuerAnruf` und `teilnehmerFuerAnruf` heissen nicht wie die + inneren Funktionen, die sie weiterreichen: Die Namen sagen, WOFUER + sie freigegeben sind. Wer sie spaeter aendert, sieht am Namen, dass + noch jemand daran haengt. + ===================================================================== */ + +/** Darf diese Person in diesem Raum telefonieren? + * Dieselbe Regel wie fuers Mitlesen -- es ist dieselbe Frage. */ +export function istDrinFuerAnruf(raumId, person) { + return istDrin(raumId, person); +} + +/** Wer ist in diesem Raum? Fuer die Frage „wie viele koennen mitmachen". */ +export function teilnehmerFuerAnruf(raumId) { + return teilnehmerVon(raumId); +} + +/** Der Ereigniskanal -- der Hinweg fuer Klingeln und Verbindungsdaten. */ +export { chatEreignis }; + export function siehtZu(personId) { return (zuschauer.get(Number(personId)) || []).size > 0; } diff --git a/tools/kachel-farben.mjs b/tools/kachel-farben.mjs index a8069e3e..c1b2d4f7 100644 --- a/tools/kachel-farben.mjs +++ b/tools/kachel-farben.mjs @@ -225,24 +225,133 @@ console.log(""); /* ---------- Zuteilung: welche Farbe an welche Nummer? ---------------- - Die Nummern bleiben an ihren Kacheln -- eine Farbe gehoert zu einem - Bereich, nicht zu einem Platz im Raster. Damit die Aenderung nicht - groesser wird als noetig, bekommt jede Nummer die neue Farbe, die - ihrer alten am naechsten liegt: Wer "Chat" als Orange kennt, findet - es danach wieder als Orange. */ + HIER LAG DER EIGENTLICHE FEHLER (18.09.2026). + Filipe, nachdem die Palette schon neu gerechnet war: "es gibt noch + mehr die aehnlich aussehen von den farben also leg los alle die sich + aehnlich sind von den farben wechseln." + + Er hatte wieder recht, und diesmal lag es nicht an den Farben, + sondern an ihrer VERTEILUNG. Gemessen im Browser -- 125 Paare, die + wirklich nebeneinander stehen: + + Creator-Profile / Zahlen 0,1019 beide rosa-rot + LIVE-Analyse / Technik 0,1030 beide orange + Wunschliste / Meldungen 0,1032 beide gelbgruen + Wer sieht was / Entwicklung 0,1039 beide cyan + Regeln & Hilfe / Mitmachen 0,1047 beide gruen + Der Treff / Anschlagbrett 0,1070 beide rosa + ... 15 Paare unter 0,15 + + DER DENKFEHLER: Der kleinste Abstand ueber ALLE Paare wurde maximiert + -- und der liegt bei 37 Farben zwangslaeufig bei 0,10. Das ist die + Packungsgrenze, kein Versaeumnis. Nur sieht niemand alle 37 + nebeneinander. Man sieht NACHBARN. Zwei aehnliche Farben an + entgegengesetzten Enden der Seite fallen nie auf; zwei + nebeneinander sofort. + + Die Farben bleiben also, wie sie sind -- ihre ZUTEILUNG aendert + sich. Wer nebeneinander steht, bekommt Farben, die weit + auseinanderliegen. Das kostet nichts und ist der ganze Unterschied. + + WOHER DIE NACHBARSCHAFT KOMMT: aus dem Browser, gemessen an der + tatsaechlichen Lage auf dem Schirm (server/kachel-nachbarn.json). + Nicht aus der Struktur im Quelltext -- die sagt, was + zusammengehoert, nicht was zusammen zu sehen ist. */ + +/* Welcher Name traegt welche Tonnummer? */ +const NAME_ZU_TON = new Map(); +for (const q of ["server/workspace.js", "workspace/assets/js/bereiche.js"]) { + let text; + try { text = readFileSync(q, "utf8"); } catch { continue; } + const zeilen = text.split("\n"); + for (let i = 0; i < zeilen.length; i++) { + const m = zeilen[i].match(/ton:\s*(\d+)/); + if (!m) continue; + for (let k = 0; k <= 3; k++) { + const n = (zeilen[i - k] || "").match(/name:\s*"([^"]+)"/); + if (n) { NAME_ZU_TON.set(n[1], Number(m[1])); break; } + } + } +} + +let NACHBARN = []; +try { + const roh = JSON.parse(readFileSync("server/kachel-nachbarn.json", "utf8")); + NACHBARN = roh.paare + .map(([a, b]) => [NAME_ZU_TON.get(a), NAME_ZU_TON.get(b)]) + .filter(([a, b]) => a && b && a !== b); + console.log(`${NACHBARN.length} Nachbarpaare geladen ` + + `(gemessen am ${roh.gemessen}).`); +} catch { + console.log("Keine Nachbarschaftsdatei -- es wird nur global optimiert."); +} + +/* Die Startzuteilung: jede Nummer bekommt die neue Farbe, die ihrer + alten am naechsten liegt. Wer "Chat" als Orange kennt, findet es + danach wieder als Orange -- solange die Nachbarschaft das zulaesst. */ const frei = beste.slice(); const zuteilung = new Map(); for (const nr of NUMMERN) { - const alt = VORHER.get(nr); + const alt2 = VORHER.get(nr); let bester = 0, bestAbw = Infinity; for (let i = 0; i < frei.length; i++) { - const d = abstand(frei[i].hex, alt); + const d = abstand(frei[i].hex, alt2); if (d < bestAbw) { bestAbw = d; bester = i; } } zuteilung.set(nr, frei.splice(bester, 1)[0]); } +/* Und jetzt tauschen, solange die NACHBARN davon profitieren. */ +if (NACHBARN.length) { + const nachbarAbstand = (z) => { + let m = Infinity; + for (const [a, b] of NACHBARN) { + const fa = z.get(a), fb = z.get(b); + if (!fa || !fb) continue; + const d = abstand(fa.hex, fb.hex); + if (d < m) m = d; + } + return m; + }; + /* Und als zweites Ziel: die SUMME der kleinen Abstaende. Zwei + Zuteilungen mit gleichem Minimum sind nicht gleich gut -- die mit + weniger knappen Paaren ist besser. */ + const knappe = (z, grenze) => { + let n = 0; + for (const [a, b] of NACHBARN) { + const fa = z.get(a), fb = z.get(b); + if (fa && fb && abstand(fa.hex, fb.hex) < grenze) n++; + } + return n; + }; + + const vorher = nachbarAbstand(zuteilung); + const vorherKnapp = knappe(zuteilung, 0.15); + let lauf = true, runden = 0; + while (lauf && runden < 60) { + lauf = false; runden++; + for (let i = 0; i < NUMMERN.length; i++) { + for (let j = i + 1; j < NUMMERN.length; j++) { + const a = NUMMERN[i], b = NUMMERN[j]; + const jetztMin = nachbarAbstand(zuteilung); + const jetztKnapp = knappe(zuteilung, 0.15); + const fa = zuteilung.get(a), fb = zuteilung.get(b); + zuteilung.set(a, fb); zuteilung.set(b, fa); + const neuMin = nachbarAbstand(zuteilung); + const neuKnapp = knappe(zuteilung, 0.15); + const besser = neuMin > jetztMin + 1e-9 + || (Math.abs(neuMin - jetztMin) < 1e-9 && neuKnapp < jetztKnapp); + if (besser) { lauf = true; } else { zuteilung.set(a, fa); zuteilung.set(b, fb); } + } + } + } + console.log(`Nachbarn: kleinster Abstand ${vorher.toFixed(4)} -> ` + + `${nachbarAbstand(zuteilung).toFixed(4)} ` + + `· Paare unter 0,15: ${vorherKnapp} -> ${knappe(zuteilung, 0.15)} ` + + `(${runden} Runden)`); +} + /* ---------- Bericht -------------------------------------------------- */ const altListe = NUMMERN.map((n) => ({ hex: VORHER.get(n), lab: oklab(VORHER.get(n)) })); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 87f0f929..6db5aaf7 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609180254", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609181009", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609180254", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609181009", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609180254", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609181009", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609180254", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609181009", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index 9caca9af..34a94278 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -1512,3 +1512,153 @@ @media (prefers-reduced-motion: reduce) { .chat-pin__auf, .chat-pin__los, .neu__art button { transition: none; } } + +/* ===================================================================== + TELEFONIEREN (18.09.2026) + + Filipe: „kann man machen dass die modis, rechte hand und ich auch + telefonieren können im chat?" + + DER KASTEN SCHWEBT UNTEN RECHTS und nimmt dem Gespräch nichts weg. + Ein Anruf ersetzt den Chat nicht -- man schreibt oft WÄHREND man + spricht („schick mir mal den Link"). Ein Vollbild-Anruf hätte genau + das unmöglich gemacht. + + Am Handy geht er über die volle Breite: Dort ist daneben ohnehin + kein Platz, und ein halber Kasten wäre nur im Weg. + ===================================================================== */ +.anruf { + position: fixed; + right: 18px; bottom: 18px; + z-index: 60; + width: min(340px, calc(100vw - 36px)); + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--gut, #79d1a2) 40%, var(--rand)); + border-radius: 14px; + /* Undurchsichtig gemischt: Ein Anrufkasten, durch den das + Bühnenbild scheint, ist genau dann schlecht lesbar, wenn es + schnell gehen muss. */ + background: color-mix(in srgb, var(--tinte) 92%, #ffffff 8%); + box-shadow: 0 18px 44px -20px rgba(0, 0, 0, .9); +} + +.anruf__kopf { + display: flex; align-items: baseline; gap: 10px; + margin-bottom: 10px; +} +.anruf__lage { + font-size: .86rem; font-weight: 600; + color: color-mix(in srgb, var(--gut, #79d1a2) 30%, #ffffff); +} +.anruf__zeit { + margin-inline-start: auto; + font-size: .82rem; color: var(--text-still); + /* Gleiche Ziffernbreite -- sonst zappelt die Uhr bei jedem Wechsel. */ + font-variant-numeric: tabular-nums; +} + +.anruf__leute { + display: flex; flex-wrap: wrap; gap: 8px; + margin-bottom: 12px; +} +.anruf__bild { + width: 100%; max-width: 150px; aspect-ratio: 4 / 3; + border-radius: 10px; + background: #05070e; + object-fit: cover; +} +/* Das eigene Bild kleiner -- man schaut die anderen an, nicht sich. */ +.anruf__bild--ich { max-width: 88px; } +.anruf__name { font-size: .8rem; color: var(--text-leise); } + +.anruf__knoepfe { display: flex; flex-wrap: wrap; gap: 8px; } +.anruf__knopf { + min-height: 40px; + padding: 8px 14px; + border: 1px solid var(--rand-hell); + border-radius: 999px; + background: color-mix(in srgb, var(--tinte) 88%, #ffffff 12%); + color: var(--text); + font: inherit; font-size: .84rem; font-weight: 600; + cursor: pointer; +} +.anruf__knopf:hover { border-color: var(--akzent); } +.anruf__knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +/* Aus heißt aus -- und zwar sichtbar. Ein stummes Mikrofon, das + aussieht wie ein offenes, ist der Grund, warum Leute in Konferenzen + ins Leere reden. */ +.anruf__knopf[aria-pressed="false"] { + border-style: dashed; + color: var(--text-still); +} +.anruf__knopf--auf { + margin-inline-start: auto; + border-color: color-mix(in srgb, var(--warn, #ff9aa2) 55%, var(--rand)); + color: color-mix(in srgb, var(--warn, #ff9aa2) 40%, #ffffff); +} +.anruf__knopf--auf:hover { border-color: var(--warn, #ff9aa2); } + +/* --------------------------------------------------------------------- + ES KLINGELT + + MITTIG UND ÜBER ALLEM. Ein Anruf, den man übersieht, ist ein + verpasster -- das ist der eine Fall, in dem etwas den Bildschirm + unterbrechen darf. + --------------------------------------------------------------------- */ +.anruf-klingelt { + position: fixed; + left: 50%; top: 24px; + transform: translateX(-50%); + z-index: 70; + width: min(380px, calc(100vw - 32px)); + padding: 16px 18px; + border: 1px solid color-mix(in srgb, var(--akzent) 50%, var(--rand)); + border-radius: 14px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + box-shadow: 0 22px 50px -18px rgba(0, 0, 0, .95); + text-align: center; +} +.anruf-klingelt__wer { + margin: 0 0 12px; + font-size: .96rem; font-weight: 700; +} +.anruf-klingelt__knoepfe { display: flex; gap: 10px; justify-content: center; } +.anruf-klingelt__ja, .anruf-klingelt__nein { + min-height: 44px; + padding: 10px 20px; + border-radius: 999px; + font: inherit; font-size: .88rem; font-weight: 700; + cursor: pointer; +} +.anruf-klingelt__ja { + border: 1px solid color-mix(in srgb, var(--gut, #79d1a2) 60%, transparent); + background: color-mix(in srgb, var(--gut, #79d1a2) 18%, var(--tinte)); + color: #ffffff; +} +.anruf-klingelt__nein { + border: 1px solid var(--rand-hell); + background: none; + color: var(--text-leise); +} +.anruf-klingelt__ja:focus-visible, +.anruf-klingelt__nein:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } + +/* Die zwei Knöpfe im Kopf des Gesprächs. Gebaut wie die Nachbarn + daneben, damit die Zeile eine Sprache spricht. */ +.chat__anruf svg { + width: 18px; height: 18px; + fill: none; stroke: currentColor; stroke-width: 1.7; + stroke-linecap: round; stroke-linejoin: round; +} +.chat__anruf:hover { color: color-mix(in srgb, var(--gut, #79d1a2) 40%, #ffffff); } + +@media (max-width: 560px) { + .anruf { + right: 8px; left: 8px; bottom: 8px; + width: auto; + } +} +@media (prefers-reduced-motion: reduce) { + .anruf, .anruf-klingelt { transition: none; } +} + diff --git a/workspace/assets/js/anruf.js b/workspace/assets/js/anruf.js new file mode 100644 index 00000000..a52022f3 --- /dev/null +++ b/workspace/assets/js/anruf.js @@ -0,0 +1,443 @@ +/* ===================================================================== + TELEFONIEREN IM CHAT (18.09.2026) + + Filipe: „ich will dass du das nimmst was man selbst in die app + reinsetzten kann, nicht meinen pc belastet und trotzdem vielleicht + in gruppe, mit video oder einzelnd." + + DER TON GEHT NICHT ÜBER DEN SERVER. Er geht direkt von Browser zu + Browser. Der Server reicht nur die Verbindungsdaten weiter -- ein + paar Kilobyte je Anruf, danach ist er aus dem Spiel. Das ist die + Antwort auf „nicht meinen PC belasten": Bei zwei Teilnehmern kodiert + jedes Gerät genau einen Strom und dekodiert einen, wie bei jedem + gewöhnlichen Videoanruf. + + --------------------------------------------------------------------- + WARUM EINE VERBINDUNG JE GEGENÜBER + + Bei drei Leuten baut jeder mit jedem eine eigene Verbindung auf -- + `verbindungen` ist deshalb eine Karte von Personennummer auf + RTCPeerConnection und keine einzelne Variable. Mit einer einzelnen + ginge zu zweit alles gut, und beim dritten Teilnehmer wäre die erste + Verbindung stillschweigend überschrieben: Man sieht sich zu dritt im + Kasten und hört nur einen. + + --------------------------------------------------------------------- + WER RUFT AN, WER ANTWORTET + + Bei WebRTC muss genau EINER das Angebot machen, sonst reden beide + gleichzeitig („glare"): Zwei Angebote heben sich auf, und die + Verbindung kommt nie zustande. + + DIE REGEL IST DIE KLEINERE NUMMER. Wer die niedrigere Personennummer + hat, macht das Angebot; der andere wartet. Das ist willkürlich, aber + es ist auf beiden Seiten OHNE ABSPRACHE dieselbe Entscheidung -- und + genau das braucht man hier. Eine Absprache darüber wäre wieder eine + Nachricht, die verlorengehen kann. + ===================================================================== */ +(() => { + 'use strict'; + if (!window.RTCPeerConnection) return; // sehr alter Browser + + const $ = (id) => document.getElementById(id); + + /* Was der Server über die Verbindungsadressen sagt. */ + let adressen = []; + let nurGleichesNetz = false; + let gruppeMax = 4; + + /* Der laufende Anruf. `null`, wenn keiner läuft. */ + let anruf = null; // { raumId, video, verbindungen: Map, stroeme: Map, eigener } + + let ichId = null; + + const el = (art, klasse, text) => { + const k = document.createElement(art); + if (klasse) k.className = klasse; + if (text != null) k.textContent = text; + return k; + }; + + /* ---------- Der Kasten ------------------------------------------------ */ + + function kastenBauen() { + let k = $('anruf'); + if (k) return k; + k = el('div', 'anruf'); + k.id = 'anruf'; + k.hidden = true; + k.setAttribute('role', 'dialog'); + k.setAttribute('aria-label', 'Anruf'); + k.innerHTML = ` +
+ Verbindung … + +
+
+
+ + + +
`; + document.body.append(k); + $('anruf-ton').addEventListener('click', tonSchalten); + $('anruf-video').addEventListener('click', videoSchalten); + $('anruf-auflegen').addEventListener('click', () => auflegen(true)); + return k; + } + + const lage = (text) => { const l = $('anruf-lage'); if (l) l.textContent = text; }; + + /* ---------- Die Gegenstelle ------------------------------------------- */ + + function verbindungFuer(wer) { + if (anruf.verbindungen.has(wer)) return anruf.verbindungen.get(wer); + + const v = new RTCPeerConnection({ iceServers: adressen }); + anruf.verbindungen.set(wer, v); + + /* Den eigenen Ton (und Bild) hineingeben. */ + for (const spur of anruf.eigener.getTracks()) v.addTrack(spur, anruf.eigener); + + /* DIE VERBINDUNGSWEGE. Jeder gefundene Weg wird einzeln geschickt, + sobald er da ist -- nicht gesammelt am Ende. Wer wartet, bis alle + gefunden sind, wartet auf den langsamsten, und das sind mehrere + Sekunden Stille am Anfang jedes Gesprächs. */ + v.onicecandidate = (e) => { + if (e.candidate) schicken(wer, { weg: e.candidate }); + }; + + v.ontrack = (e) => { + anruf.stroeme.set(wer, e.streams[0]); + leuteZeichnen(); + }; + + v.onconnectionstatechange = () => { + if (v.connectionState === 'connected') lage('Verbunden'); + /* `failed` heisst: Es kommt nichts mehr. Das gehoert gesagt -- + ein Kasten, der ewig „Verbindung …" zeigt, laesst einen warten, + wo nichts mehr kommt. */ + if (v.connectionState === 'failed') { + lage(nurGleichesNetz + ? 'Keine Verbindung – dafür fehlt noch ein Vermittlungsserver.' + : 'Keine Verbindung.'); + } + }; + return v; + } + + async function angebotMachen(wer) { + const v = verbindungFuer(wer); + const angebot = await v.createOffer(); + await v.setLocalDescription(angebot); + schicken(wer, { angebot: v.localDescription }); + } + + async function signalVerarbeiten(von, inhalt) { + if (!anruf) return; + const v = verbindungFuer(von); + try { + if (inhalt.angebot) { + await v.setRemoteDescription(inhalt.angebot); + const antwort = await v.createAnswer(); + await v.setLocalDescription(antwort); + schicken(von, { antwort: v.localDescription }); + } else if (inhalt.antwort) { + await v.setRemoteDescription(inhalt.antwort); + } else if (inhalt.weg) { + /* Ein Weg kann eintreffen, bevor die Beschreibung da ist -- + dann wirft addIceCandidate. Das ist kein Fehler, sondern + Reihenfolge; er wird beim nächsten Versuch ohnehin wieder + angeboten. */ + await v.addIceCandidate(inhalt.weg).catch(() => {}); + } + } catch (f) { + console.warn('[anruf] Signal:', f?.message); + } + } + + const schicken = (an, inhalt) => + fetch(`/workspace/api/anruf/${anruf.raumId}/signal`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ an, inhalt }), + }).catch(() => {}); + + /* ---------- Anfangen und Aufhören -------------------------------------- */ + + async function eigenenStromHolen(mitVideo) { + try { + return await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true }, + video: mitVideo ? { width: { ideal: 640 }, height: { ideal: 480 } } : false, + }); + } catch (f) { + /* DER HÄUFIGSTE FALL IST NICHT „KAPUTT", SONDERN „ABGELEHNT". + Ein „ging nicht" wäre hier die unbrauchbarste Auskunft von + allen -- man sucht dann am Gerät statt an der Berechtigung. */ + if (f?.name === 'NotAllowedError') { + throw new Error('Der Browser lässt das Mikrofon nicht zu. ' + + 'Im Schloss-Symbol neben der Adresse freigeben.'); + } + if (f?.name === 'NotFoundError') { + throw new Error('Kein Mikrofon gefunden.'); + } + throw new Error('Das Mikrofon ließ sich nicht öffnen.'); + } + } + + async function starten(raumId, mitVideo) { + if (anruf) return; + kastenBauen(); + $('anruf').hidden = false; + lage('Mikrofon …'); + let eigener; + try { + eigener = await eigenenStromHolen(mitVideo); + } catch (f) { + lage(f.message); + setTimeout(() => { if (!anruf) $('anruf').hidden = true; }, 6000); + return; + } + anruf = { raumId, video: mitVideo, verbindungen: new Map(), + stroeme: new Map(), eigener, beginn: Date.now() }; + $('anruf-video').hidden = !mitVideo; + if (mitVideo) $('anruf-video').setAttribute('aria-pressed', 'true'); + lage('Es klingelt …'); + uhrLaufen(); + + const a = await fetch(`/workspace/api/anruf/${raumId}/start`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ video: mitVideo }), + }).catch(() => null); + if (!a || !a.ok) { + const d = a ? await a.json().catch(() => ({})) : {}; + lage(d.fehler || 'Der Anruf ließ sich nicht starten.'); + setTimeout(() => auflegen(false), 5000); + return; + } + leuteZeichnen(); + } + + async function annehmen(raumId) { + const a = await fetch(`/workspace/api/anruf/${raumId}/dabei`, { + method: 'POST', credentials: 'same-origin', + }).catch(() => null); + if (!a || !a.ok) { + const d = a ? await a.json().catch(() => ({})) : {}; + window.anrufMeldung?.(d.fehler || 'Der Anruf ist schon vorbei.'); + klingelnAus(); + return; + } + const d = await a.json(); + kastenBauen(); + $('anruf').hidden = false; + let eigener; + try { + eigener = await eigenenStromHolen(d.video); + } catch (f) { + lage(f.message); + await fetch(`/workspace/api/anruf/${raumId}/ende`, + { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + setTimeout(() => { $('anruf').hidden = true; }, 6000); + return; + } + anruf = { raumId, video: d.video, verbindungen: new Map(), + stroeme: new Map(), eigener, beginn: Date.now() }; + $('anruf-video').hidden = !d.video; + if (d.video) $('anruf-video').setAttribute('aria-pressed', 'true'); + klingelnAus(); + lage('Verbindung …'); + uhrLaufen(); + + /* Mit allen, die schon dabei sind, eine Verbindung aufbauen -- + aber das ANGEBOT macht nur, wer die kleinere Nummer hat. */ + for (const p of d.wer || []) { + if (p.id === ichId) continue; + if (ichId < p.id) angebotMachen(p.id); + else verbindungFuer(p.id); + } + leuteZeichnen(); + } + + async function auflegen(melden) { + klingelnAus(); + if (!anruf) { const k = $('anruf'); if (k) k.hidden = true; return; } + const raumId = anruf.raumId; + for (const v of anruf.verbindungen.values()) { try { v.close(); } catch { /* egal */ } } + for (const s of anruf.eigener.getTracks()) s.stop(); + anruf = null; + const k = $('anruf'); + if (k) k.hidden = true; + uhrStoppen(); + if (melden) { + await fetch(`/workspace/api/anruf/${raumId}/ende`, + { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + } + } + + /* ---------- Was man sieht --------------------------------------------- */ + + function leuteZeichnen() { + const ziel = $('anruf-leute'); + if (!ziel || !anruf) return; + ziel.textContent = ''; + /* Das eigene Bild zuerst und stumm -- sich selbst zu hören ist ein + Echo, das jedes Gespräch unbrauchbar macht. */ + if (anruf.video) { + const eigen = el('video', 'anruf__bild anruf__bild--ich'); + eigen.autoplay = true; eigen.playsInline = true; eigen.muted = true; + eigen.srcObject = anruf.eigener; + ziel.append(eigen); + } + for (const [wer, strom] of anruf.stroeme) { + if (anruf.video) { + const v = el('video', 'anruf__bild'); + v.autoplay = true; v.playsInline = true; + v.srcObject = strom; + ziel.append(v); + } else { + const t = el('audio'); + t.autoplay = true; + t.srcObject = strom; + ziel.append(t); + ziel.append(el('span', 'anruf__name', 'Verbunden')); + } + void wer; + } + } + + let uhr = null; + function uhrLaufen() { + uhrStoppen(); + uhr = setInterval(() => { + if (!anruf) return uhrStoppen(); + const s = Math.floor((Date.now() - anruf.beginn) / 1000); + const z = $('anruf-zeit'); + if (z) z.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:` + + `${String(s % 60).padStart(2, '0')}`; + }, 1000); + } + function uhrStoppen() { if (uhr) { clearInterval(uhr); uhr = null; } } + + function tonSchalten() { + if (!anruf) return; + const an = anruf.eigener.getAudioTracks().some((s) => s.enabled); + for (const s of anruf.eigener.getAudioTracks()) s.enabled = !an; + const k = $('anruf-ton'); + k.textContent = an ? 'Mikro aus' : 'Mikro an'; + k.setAttribute('aria-pressed', String(!an)); + } + + function videoSchalten() { + if (!anruf) return; + const an = anruf.eigener.getVideoTracks().some((s) => s.enabled); + for (const s of anruf.eigener.getVideoTracks()) s.enabled = !an; + const k = $('anruf-video'); + k.textContent = an ? 'Kamera aus' : 'Kamera an'; + k.setAttribute('aria-pressed', String(!an)); + } + + /* ---------- Es klingelt ------------------------------------------------ */ + + function klingelnZeigen(raumId, von, mitVideo) { + klingelnAus(); + const k = el('div', 'anruf-klingelt'); + k.id = 'anruf-klingelt'; + k.setAttribute('role', 'alertdialog'); + k.append(el('p', 'anruf-klingelt__wer', + `${von?.name || 'Jemand'} ruft an${mitVideo ? ' – mit Video' : ''}`)); + const reihe = el('div', 'anruf-klingelt__knoepfe'); + const ja = el('button', 'anruf-klingelt__ja', 'Rangehen'); + ja.type = 'button'; + ja.addEventListener('click', () => annehmen(raumId)); + const nein = el('button', 'anruf-klingelt__nein', 'Ablehnen'); + nein.type = 'button'; + nein.addEventListener('click', async () => { + klingelnAus(); + await fetch(`/workspace/api/anruf/${raumId}/ende`, + { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + }); + reihe.append(ja, nein); + k.append(reihe); + document.body.append(k); + /* Nach der Klingelzeit von selbst weg -- sonst steht die Einladung + zu einem Gespräch da, an dem längst niemand mehr teilnimmt. */ + setTimeout(klingelnAus, 46000); + } + function klingelnAus() { $('anruf-klingelt')?.remove(); } + + /* ---------- Was vom Server kommt --------------------------------------- */ + + async function ereignis(d) { + if (!d || typeof d !== 'object') return; + if (d.art === 'klingelt') { + if (d.von?.id === ichId) return; // der eigene Anruf + if (anruf) return; // telefoniert schon + klingelnZeigen(d.raum_id, d.von, d.video); + return; + } + if (d.art === 'dabei') { + if (!anruf || d.raum_id !== anruf.raumId) return; + if (d.person?.id === ichId) return; + lage('Verbunden'); + /* Der Neue: Angebot macht, wer die kleinere Nummer hat. */ + if (ichId < d.person.id) angebotMachen(d.person.id); + else verbindungFuer(d.person.id); + return; + } + if (d.art === 'signal') { + if (!anruf || d.raum_id !== anruf.raumId) return; + if (d.an !== ichId) return; + await signalVerarbeiten(d.von, d.inhalt); + return; + } + if (d.art === 'weg' || d.art === 'ende') { + if (!anruf || d.raum_id !== anruf.raumId) { + if (d.art === 'ende') klingelnAus(); + return; + } + if (d.person?.id === ichId) return; + const v = anruf.verbindungen.get(d.person?.id); + if (v) { try { v.close(); } catch { /* egal */ } } + anruf.verbindungen.delete(d.person?.id); + anruf.stroeme.delete(d.person?.id); + leuteZeichnen(); + if (d.art === 'ende' || !anruf.stroeme.size) { + lage('Gespräch beendet'); + setTimeout(() => auflegen(false), 1200); + } + } + } + + /* ---------- Nach außen -------------------------------------------------- */ + + window.anrufStarten = starten; + window.anrufEreignis = ereignis; + window.anrufLaeuft = () => !!anruf; + window.anrufBereit = () => adressen.length > 0 || nurGleichesNetz; + + (async () => { + try { + const a = await fetch('/workspace/api/anruf/adressen', { credentials: 'same-origin' }); + if (a.ok) { + const d = await a.json(); + adressen = d.adressen || []; + nurGleichesNetz = !!d.nur_gleiches_netz; + gruppeMax = d.gruppe_max || 4; + } + } catch { /* dann eben ohne -- im selben Netz geht es trotzdem */ } + try { + const i = await fetch('/workspace/api/ich', { credentials: 'same-origin' }); + if (i.ok) ichId = (await i.json())?.id ?? null; + } catch { /* der Chat holt es ohnehin auch */ } + void gruppeMax; + })(); + + /* Wer die Seite verlässt, legt auf. Ohne das bleibt er für die + anderen „dabei", und sie warten auf jemanden, der weg ist. */ + window.addEventListener('pagehide', () => { if (anruf) auflegen(true); }); +})(); diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index f3b23229..1ab62acd 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -105,6 +105,53 @@ zeigen hiesse, dass man Rot irgendwann nicht mehr ernst nimmt -- und Rot ist hier das Einzige, was Aufmerksamkeit verlangen darf. Die Farbe entscheidet das CSS über data-art, nicht diese Zeile. */ + /* ---- ANRUFEN (18.09.2026) ------------------------------------------ + + Die zwei Knöpfe stehen im Kopf des Gesprächs, neben den anderen. + Sie erscheinen NUR, wenn ein Gespräch offen ist -- ein Anrufknopf + ohne Gegenüber wäre ein Angebot ohne Gegenstand, derselbe Gedanke + wie beim Löschen-Knopf daneben. */ + function anrufKnoepfeSetzen() { + const ziel = document.querySelector('.chat__kopf-knoepfe'); + if (!ziel || !window.anrufStarten) return; + let ton = document.getElementById('anruf-ton-start'); + let video = document.getElementById('anruf-video-start'); + if (!ton) { + ton = document.createElement('button'); + ton.type = 'button'; + ton.id = 'anruf-ton-start'; + ton.className = 'chat__weg chat__anruf'; + ton.title = 'Anrufen'; + ton.setAttribute('aria-label', 'Anrufen'); + ton.innerHTML = ''; + ton.addEventListener('click', () => { + if (!offen) return; + window.anrufStarten(offen.id, false); + }); + video = document.createElement('button'); + video.type = 'button'; + video.id = 'anruf-video-start'; + video.className = 'chat__weg chat__anruf'; + video.title = 'Mit Video anrufen'; + video.setAttribute('aria-label', 'Mit Video anrufen'); + video.innerHTML = ''; + video.addEventListener('click', () => { + if (!offen) return; + window.anrufStarten(offen.id, true); + }); + ziel.prepend(video); + ziel.prepend(ton); + } + const da = !!offen; + ton.hidden = !da; + video.hidden = !da; + } + window.anrufMeldung = (text) => melde(text); + const melde = (t, art = 'fehler') => { const z = $('fehler'); z.textContent = t || ''; @@ -304,6 +351,10 @@ if (!a.ok) { melde('Dieses Gespräch gibt es nicht mehr.'); return; } const daten = await a.json(); offen = daten.raum; + /* Die Anrufknoepfe erscheinen mit dem Gespraech -- und + verschwinden mit ihm. Ein Anrufknopf ohne Gegenueber waere ein + Angebot ohne Gegenstand. */ + anrufKnoepfeSetzen(); nachrichten = daten.nachrichten; angeheftet = daten.angeheftet || []; darfAnheftenHier = !!daten.darf_anheften; @@ -1216,6 +1267,22 @@ if (strom) strom.close(); strom = new EventSource('/workspace/api/chat/strom'); + /* ANRUFE KOMMEN ÜBER DENSELBEN KANAL (18.09.2026). + + Klingeln, Angebot, Antwort und Verbindungswege sind Ereignisse + wie jede Nachricht -- nur mit `event: anruf`. Ein eigener + WebSocket daneben wäre eine zweite Verbindung, die dieselbe + Frage beantwortet, und die zweite ist die, die beim nächsten + Umbau vergessen wird. + + Der Chat versteht den Inhalt nicht; er reicht ihn weiter. Wer + ihn versteht, steht in anruf.js. */ + strom.addEventListener('anruf', (e) => { + let d; + try { d = JSON.parse(e.data); } catch { return; } + window.anrufEreignis?.(d); + }); + strom.addEventListener('neu', (e) => { let n; try { n = JSON.parse(e.data); } catch { return; } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 54c4b7df..e91681a6 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -291,10 +291,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 4fce7693..98174440 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index e3ff2c4e..6734beec 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -105,9 +105,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index a9d5ae15..83c20139 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,24 +12,24 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - - + + - + @@ -243,14 +243,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/bewerben.html b/workspace/bewerben.html index 4cfa54f8..7633901a 100644 --- a/workspace/bewerben.html +++ b/workspace/bewerben.html @@ -7,17 +7,17 @@ - - - - - - - - - + + + + + + + + + - + @@ -107,10 +107,10 @@ - - - - - + + + + + diff --git a/workspace/bewerbungen.html b/workspace/bewerbungen.html index 5109c6b3..809284c5 100644 --- a/workspace/bewerbungen.html +++ b/workspace/bewerbungen.html @@ -7,16 +7,16 @@ - - - - - - - - + + + + + + + + - + @@ -80,10 +80,10 @@ - - - - - + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index f4a53088..49ef8205 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 147ba06a..1e241ff4 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,15 @@ - - - - - + + + + + + + diff --git a/workspace/content.html b/workspace/content.html index 19e9362b..aabc2174 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 5496adfc..f9b0c0f7 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 3b736bc1..2b0fefe3 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -22,25 +22,25 @@ }, "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609180254", + "src": "/assets/img/app-symbole/crew-192.png?v=202609181009", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609180254", + "src": "/assets/img/app-symbole/crew-512.png?v=202609181009", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609180254", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609181009", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609180254", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609181009", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index f1a80f27..02b84f25 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 864910c1..a7926ea7 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 2ae3d6be..4cb99825 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 164ed2df..e20567c8 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 47fe5a24..8003e458 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -444,10 +444,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index c687ceb4..8d6c1685 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index ace936cf..e0cbbc87 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 02768c54..0636787a 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 2989fa9d..aacf4397 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 3211bcd2..e3fd10ef 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 8fb2dbb5..a96d9e50 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index f9cc16ef..bbfacd0d 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index a612408b..92b5e059 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index fe8b95e9..ee189887 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,18 +7,18 @@ - - - - - - + + + + + + - - - + + + @@ -111,9 +111,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 2795481d..6dd93c9d 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 58303ea2..7b4687c4 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/teilen.html b/workspace/teilen.html index 8818971e..c3d03802 100644 --- a/workspace/teilen.html +++ b/workspace/teilen.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -112,9 +112,9 @@ - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index c97cbae3..96e1a01b 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -126,9 +126,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index df9d685c..23695410 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -211,10 +211,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index aa3896de..c03cfb13 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 817c15aa..5d717c82 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +