feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)

- the start dialog is the consent: level (Részletes/Teljes), what is recorded,
  retention and the cap (now served as diagnostics.session_minutes)
- the pill docks under the toasts: elapsed time, Step (an optional label,
  its own Escape layer), Screenshot (tab capture where the browser has it),
  Stop; compact on a phone
- while recording, lib/log tags every event and every request path (req,
  beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics
  raises the level for the session's run and never lowers a raised one, and
  lapses at whichever ends first
- Stop sends what is waiting before the server closes the session; the cap
  says so and opens the composer
- Settings → Diagnosztika and the command palette start/step/stop it
- the evidence leaves the log upload's own request lines out
- the step shortcut is deliberately palette-only: the keymap's D1 rule bars a
  new modifier chord, and a bare letter collides with the players'
This commit is contained in:
2026-09-19 22:08:10 +02:00
parent 2d9595b0e5
commit 46fce4a767
26 changed files with 1347 additions and 89 deletions
+62
View File
@@ -0,0 +1,62 @@
// One screenshot of this tab, for a debug session (E16 D5).
//
// Through the Screen Capture API with the current tab preferred: the browser asks every time (there
// is no silent capture on the web, by design), the user can pick something else or cancel, and one
// frame is taken before the track is stopped at once — nothing keeps recording.
//
// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the
// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same
// name Chrome gives a cancelled picker.
//
// The frame is read through a <video> and a canvas rather than `ImageCapture.grabFrame`, which
// Firefox and Safari lack.
/** Whether this browser can capture a tab at all (iOS Safari cannot — the button is not shown). */
export function canCaptureTab(): boolean {
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia;
}
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. */
export async function captureTab(): Promise<Blob | null> {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "browser" },
audio: false,
// Chromium hints: offer THIS tab first, and nothing that would record the whole screen.
preferCurrentTab: true,
selfBrowserSurface: "include",
surfaceSwitching: "exclude",
monitorTypeSurfaces: "exclude",
} as DisplayMediaStreamOptions);
} catch (err) {
if (
err instanceof DOMException &&
(err.name === "NotAllowedError" || err.name === "AbortError")
)
return null;
throw err;
}
try {
return await firstFrame(stream);
} finally {
for (const track of stream.getTracks()) track.stop();
}
}
async function firstFrame(stream: MediaStream): Promise<Blob | null> {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.srcObject = stream;
await video.play();
// The first frame can arrive a beat after `play` resolves; wait until it has a size.
for (let i = 0; i < 20 && !video.videoWidth; i++) await new Promise((r) => setTimeout(r, 50));
if (!video.videoWidth) return null;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
video.srcObject = null;
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}