/** * bug-report-api — Bildaufnahme und Versand des Fehler-melden-Knopfs * (quick-260914-m97). * * `captureScreenshot` rastert `document.body` ueber `html-to-image` * (SVG `foreignObject`, der Browser zeichnet selbst — deshalb stimmen die * OKLCH-Farben von Tailwind 4, an denen `html2canvas` scheitert). Die * Bibliothek wird erst beim Klick dynamisch geladen. Die laengste Kante * ist auf 1600 px begrenzt (`computeCaptureSize`), `pixelRatio: 1`, keine * Schrift-Einbettung (`skipFonts` — das Projekt hat keinen Webfont). * * `sendBugReport` schickt `multipart/form-data` an `POST /bug-reports` * — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie * (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die * Multipart-Grenze setzt der Browser selbst. * * Seit quick-260918-gza traegt die Nutzlast zusaetzlich vier Herkunftsfelder * (`clientKind`, `clientOs`, `clientVersion`, `clientCommit`) — im Browser * `clientKind=browser` mit leeren uebrigen Feldern, in der Desktop-App aus * `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts). Die API * leitet daraus das Betreff-Kuerzel und die Zeile `Herkunft:` ab. */ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; /** * Zielmass fuer die Aufnahme: laengste Kante hoechstens `maxEdge`, * Seitenverhaeltnis bleibt, nie kleiner als 1x1 (kein 0-Canvas). * Reine Funktion, direkt getestet. */ export function computeCaptureSize( width: number, height: number, maxEdge = 1600, ): { width: number; height: number } { const w = Math.max(1, Math.round(width || 0)); const h = Math.max(1, Math.round(height || 0)); const scale = Math.min(1, maxEdge / Math.max(w, h)); return { width: Math.max(1, Math.round(w * scale)), height: Math.max(1, Math.round(h * scale)), }; } /** 1x1 transparentes PNG — Ersatz fuer Bilder, die sich nicht laden lassen. */ const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; function isIgnoredNode(n: Node): boolean { return n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'; } /** * Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu * werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne. * * `html-to-image` laedt jedes `` per `fetch` nach. Ein einziges Bild * von fremder Adresse ohne CORS (z. B. ein direkt von der Website geholtes * Favoriten-Symbol) liess bisher die GANZE Aufnahme scheitern — im Dialog * blieb das Haekchen „Bildschirmfoto beifuegen“ dann gesperrt. Deshalb: * nicht ladbare Bilder werden zum transparenten Pixel, und scheitert die * Aufnahme trotzdem, folgt ein zweiter Versuch ganz ohne Bilder und Rahmen. */ export async function captureScreenshot(): Promise { try { const { toPng } = await import('html-to-image'); const node = document.body; const size = computeCaptureSize(node.scrollWidth, node.scrollHeight); const base = { pixelRatio: 1, skipFonts: true, cacheBust: true, canvasWidth: size.width, canvasHeight: size.height, imagePlaceholder: TRANSPARENT_PIXEL, onImageErrorHandler: () => undefined, }; try { return await toPng(node, { ...base, filter: (n: Node) => !isIgnoredNode(n) }); } catch { return await toPng(node, { ...base, filter: (n: Node) => !isIgnoredNode(n) && !(n instanceof HTMLImageElement) && !(n instanceof HTMLIFrameElement), }); } } catch { return null; } } /** Base64-Teil einer Data-URL -> PNG-Blob (per `atob`, kein fetch). */ export function dataUrlToBlob(dataUrl: string): Blob { const comma = dataUrl.indexOf(','); const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new Blob([bytes], { type: 'image/png' }); } export interface BugReportPayload { description: string; page: string; webVersion: string; webChannel: string; webCommit: string; userAgent: string; viewport: string; clientTime: string; errors: string[]; screenshot: Blob | null; clientKind: 'desktop' | 'browser'; clientOs: string; clientVersion: string; clientCommit: string; } export type BugReportResult = { ok: true } | { ok: false; status: number }; export async function sendBugReport(p: BugReportPayload): Promise { const body = new FormData(); body.append('description', p.description); body.append('page', p.page); body.append('webVersion', p.webVersion); body.append('webChannel', p.webChannel); body.append('webCommit', p.webCommit); body.append('userAgent', p.userAgent); body.append('viewport', p.viewport); body.append('clientTime', p.clientTime); body.append('clientKind', p.clientKind); body.append('clientOs', p.clientOs); body.append('clientVersion', p.clientVersion); body.append('clientCommit', p.clientCommit); for (const line of p.errors) body.append('errors', line); if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png'); try { const res = await fetch(`${API_URL}/bug-reports`, { method: 'POST', credentials: 'include', body, }); return res.ok ? { ok: true } : { ok: false, status: res.status }; } catch { return { ok: false, status: 0 }; } }