import type { EventKind } from "@a4anthony/proctorkit-types"; import { EventEvidenceUploadError } from "./event-evidence-uploader.js"; export type EventEvidenceSource = "screen-share"; export type EventEvidenceSyncQuality = "exact"; export interface CapturedEventEvidence { blob: Blob; capturedAt: number; width: number; height: number; source: EventEvidenceSource; syncQuality: EventEvidenceSyncQuality; } export interface EvidenceEvent { id: string; kind: EventKind; timestamp: number; } export interface EventEvidenceCaptureDeps { captureScreen: () => Promise; uploadAvailable: (input: { eventId: string; eventTimestamp: number; evidence: CapturedEventEvidence; }) => Promise; uploadUnavailable: (input: { eventId: string; eventTimestamp: number; source: "screen-share"; reason: string; }) => Promise; onFailure?: (failure: EventEvidenceFailure) => void; } export type EventEvidenceFailureCode = | "evidence-image-upload-failed" | "evidence-failure-record-upload-failed"; export interface EventEvidenceFailure { eventId: string; eventTimestamp: number; code: EventEvidenceFailureCode; stage: "image-upload" | "failure-record-upload"; source: EventEvidenceSource; error: unknown; details?: Record; } export const EVENT_EVIDENCE_KINDS = new Set([ "screenshot.attempted", "clipboard.copy", "clipboard.cut", "clipboard.paste", "contextmenu.opened", "focus.lost", "tab.hidden", ]); export class EventEvidenceCapture { private readonly pending = new Set>(); private readonly captured = new Set(); constructor(private readonly deps: EventEvidenceCaptureDeps) {} capture(event: EvidenceEvent): void { if (!EVENT_EVIDENCE_KINDS.has(event.kind) || this.captured.has(event.id)) { return; } this.captured.add(event.id); const task = this.captureInternal(event).finally(() => { this.pending.delete(task); }); this.pending.add(task); } async drain(): Promise { while (this.pending.size > 0) { await Promise.allSettled([...this.pending]); } } private async captureInternal(event: EvidenceEvent): Promise { let evidence: CapturedEventEvidence | null = null; try { evidence = await this.deps.captureScreen(); } catch { // Screen-share observer health is reported by its own telemetry. DOM // replay is linked later from the event's shared capture timestamp, so // the candidate browser does not need to render a second bitmap. } if (!evidence) return; try { await this.deps.uploadAvailable({ eventId: event.id, eventTimestamp: event.timestamp, evidence, }); } catch (error) { this.reportFailure(event, { code: "evidence-image-upload-failed", stage: "image-upload", source: evidence.source, error, details: uploadFailureDetails(error), }); await this.recordUnavailable(event, "evidence-image-upload-failed"); } } private async recordUnavailable( event: EvidenceEvent, reason: EventEvidenceFailureCode, ): Promise { try { await this.deps.uploadUnavailable({ eventId: event.id, eventTimestamp: event.timestamp, source: "screen-share", reason, }); } catch (error) { this.reportFailure(event, { code: "evidence-failure-record-upload-failed", stage: "failure-record-upload", source: "screen-share", error, details: uploadFailureDetails(error), }); } } private reportFailure( event: EvidenceEvent, failure: Omit, ): void { this.deps.onFailure?.({ eventId: event.id, eventTimestamp: event.timestamp, ...failure, }); } } function uploadFailureDetails(error: unknown): Record | undefined { if (!(error instanceof EventEvidenceUploadError)) return undefined; return { uploadAttempts: error.diagnostic.attempts, uploadStatus: error.diagnostic.status, uploadRetryable: error.diagnostic.retryable, }; }