diff --git a/server/pruef-installieren.mjs b/server/pruef-installieren.mjs new file mode 100644 index 00000000..145d4378 --- /dev/null +++ b/server/pruef-installieren.mjs @@ -0,0 +1,206 @@ +/* ===================================================================== + pruef-installieren.mjs — Die Seite als App auf dem Gerät + + Filipe, 20.09.2026: „kannst du da dazwischen in der starseite oben, + ein button reinsetzen damit die leute die seite installieren koennen + auf dem handy oder pc. das soll auch verschwinden wenn die leute es + installiert haben." + + --------------------------------------------------------------------- + WARUM DAS MEHR IST ALS EIN KNOPF + + Ohne installierte App gibt es auf dem Handy keine verlässlichen + Benachrichtigungen — daran hing im September das Telefonieren. Das + Manifest war die ganze Zeit vollständig, die App ließ sich + installieren. Nur bot das ausschließlich der Browser an, versteckt + in seinem Dreipunktemenü. + + DREI ZUSTÄNDE, DREI ANTWORTEN — und jede wird hier geprüft: + + 1. schon installiert -> gar kein Knopf + 2. Browser bietet an -> Knopf fragt ihn + 3. Safari/iPhone -> Knopf erklärt den Weg (dort gibt es + `beforeinstallprompt` nicht und wird es + nicht geben) + + Der gefährlichste Fall ist der dritte: Ein Knopf, der auf dem iPhone + nichts tut, ist schlimmer als keiner — man drückt ihn und sucht den + Fehler bei sich. + + AUFRUF: node server/pruef-installieren.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; + +const PORT = await portMussFreiSein(4435, "pruef-installieren"); +const ordner = mkdtempSync(join(tmpdir(), "ws-install-")); +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 }); }; +await import("./index.js"); +notbremse(220_000, "pruef-installieren"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +console.log("\n=== Die Seite als App installieren ===\n"); + +/* ---- 1. Das Manifest, ohne Browser -------------------------------- */ +{ + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const { dirname } = await import("node:path"); + const HIER = dirname(fileURLToPath(import.meta.url)); + const m = JSON.parse(readFileSync(join(HIER, "..", "workspace", "crew.webmanifest"), "utf8")); + /* Ohne diese vier Angaben bietet kein Browser das Installieren an -- + der schoenste Knopf haette dann nichts zu fragen. */ + ok(!!m.name && !!m.short_name, `Name und Kurzname stehen (${m.short_name})`); + ok(m.display === "standalone", `display ist "${m.display}"`); + ok(!!m.start_url && m.start_url.startsWith("/workspace/"), + `Startadresse zeigt in den Workspace (${m.start_url})`); + const gross = (m.icons || []).filter((i) => /512|384/.test(i.sizes || "")); + ok(gross.length >= 1, `mindestens ein grosses Symbol (${gross.length})`); +} + +/* ---- 2. Am echten Bildschirm -------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); +d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run("Filipe", "admin", hash, salt, 32768, new Date().toISOString()); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +try { + async function seiteAuf(extra = {}) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 860 }, ...extra }); + const s = await ctx.newPage(); + await s.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await s.click('.rolle[data-rolle="admin"]'); + await s.fill("#code", "CODE-DOGI-0001"); + await s.click("button[type=submit]"); + await s.waitForURL("**/start.html", { timeout: 25000 }); + await s.waitForTimeout(1300); + return { ctx, s }; + } + + /* ---- Zustand 2: der Browser bietet es an ----------------------- */ + const a = await seiteAuf(); + /* Chromium loest `beforeinstallprompt` in dieser Umgebung nicht von + selbst aus (kein echtes Profil, kein Nutzerverhalten). Das + Ereignis wird deshalb nachgestellt -- geprueft wird, was DIE SEITE + damit macht, und genau das ist hier die Frage. */ + const vorher = await a.s.locator(".install-knopf").count(); + const sichtbarVorher = await a.s.evaluate(() => + !!document.querySelector(".install-knopf:not([hidden])")); + ok(vorher === 1, `der Knopf steht im Dokument (${vorher})`); + ok(sichtbarVorher === false, + "bleibt aber versteckt, solange der Browser nichts anbietet — kein Knopf ohne Wirkung"); + + await a.s.evaluate(() => { + const e = new Event("beforeinstallprompt"); + e.prompt = () => {}; + e.userChoice = Promise.resolve({ outcome: "accepted" }); + window.dispatchEvent(e); + }); + await a.s.waitForTimeout(300); + const nachher = await a.s.evaluate(() => { + const k = document.querySelector(".install-knopf"); + if (!k) return null; + const r = k.getBoundingClientRect(); + return { sichtbar: !k.hidden, text: k.textContent.trim(), + satz: k.getAttribute("aria-label"), + breit: Math.round(r.width), hoch: Math.round(r.height) }; + }); + ok(nachher?.sichtbar === true, `sobald der Browser anbietet, steht er da ("${nachher?.text}")`); + ok((nachher?.hoch || 0) >= 44, ` und ist am Finger treffbar (${nachher?.hoch} px)`); + ok(/installieren/i.test(nachher?.satz || ""), + ` mit einem ganzen Satz fuer Vorleseprogramme ("${nachher?.satz}")`); + + /* Und ein Druck macht ihn fertig. */ + await a.s.click(".install-knopf"); + await a.s.waitForTimeout(500); + const weg = await a.s.locator(".install-knopf").count(); + ok(weg === 0, `nach dem Installieren ist er weg (${weg})`); + + /* ---- Zustand 1: schon installiert ------------------------------ */ + const b = await browser.newContext({ viewport: { width: 1280, height: 860 } }); + const bs = await b.newPage(); + /* `display-mode: standalone` laesst sich nicht direkt setzen -- + `matchMedia` wird deshalb vor dem Laden ueberschrieben. Damit + antwortet die Seite genau so, wie sie es in einer installierten + App taete. */ + await bs.addInitScript(() => { + const echt = window.matchMedia.bind(window); + window.matchMedia = (q) => (q.includes("display-mode: standalone") + ? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} } + : echt(q)); + }); + await bs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await bs.click('.rolle[data-rolle="admin"]'); + await bs.fill("#code", "CODE-DOGI-0001"); + await bs.click("button[type=submit]"); + await bs.waitForURL("**/start.html", { timeout: 25000 }); + await bs.waitForTimeout(1300); + const inApp = await bs.locator(".install-knopf").count(); + ok(inApp === 0, + `Gegenprobe: in der installierten App gibt es den Knopf gar nicht erst (${inApp})`); + + /* ---- Zustand 3: Safari auf dem iPhone -------------------------- */ + const c = await browser.newContext({ + viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + }); + const cs = await c.newPage(); + await cs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await cs.click('.rolle[data-rolle="admin"]'); + await cs.fill("#code", "CODE-DOGI-0001"); + await cs.click("button[type=submit]"); + await cs.waitForURL("**/start.html", { timeout: 25000 }); + await cs.waitForTimeout(1300); + const beiApple = await cs.evaluate(() => { + const k = document.querySelector(".install-knopf"); + return k ? { sichtbar: !k.hidden, breit: Math.round(k.getBoundingClientRect().width) } : null; + }); + /* DER WICHTIGSTE FALL: Dort gibt es `beforeinstallprompt` nicht -- + der Knopf muss trotzdem dastehen und erklaeren. */ + ok(beiApple?.sichtbar === true, + "auf dem iPhone steht er von Anfang an da (dort fragt der Browser nie)"); + ok((beiApple?.breit || 0) >= 44, + ` und ist auch dort treffbar (${beiApple?.breit} px breit)`); + + await cs.click(".install-knopf"); + await cs.waitForTimeout(700); + const erklaerung = await cs.evaluate(() => { + const d2 = document.querySelector("dialog.nachfrage[open]"); + return d2 ? d2.textContent.replace(/\s+/g, " ").trim() : null; + }); + ok(/Teilen|Home-Bildschirm/i.test(erklaerung || ""), + ` und erklaert den Weg statt es zu versuchen ("${(erklaerung || "").slice(0, 70)}…")`); + + await a.ctx.close(); await b.close(); await c.close(); +} finally { + await browser.close(); +} + +console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 95e1a868..dd4df60a 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +