bf4384ad73
html-to-image laedt jedes <img> per fetch nach; ein einziges Bild ohne CORS (z. B. ein direkt von der Website geholtes Favoriten-Symbol) liess die ganze Aufnahme scheitern, im Dialog blieb das Haekchen "Bildschirmfoto beifuegen" gesperrt. Nicht ladbare Bilder werden jetzt zum transparenten Pixel; scheitert die Aufnahme trotzdem, folgt ein zweiter Versuch ohne Bilder und Rahmen. Im Linux-Client 1.3.1 nachgestellt und nach dem Fix gegengeprueft. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
151 lines
5.4 KiB
TypeScript
151 lines
5.4 KiB
TypeScript
/**
|
|
* 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 `<img>` 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<string | null> {
|
|
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<BugReportResult> {
|
|
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 };
|
|
}
|
|
}
|