diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts index 4202511..0568a5b 100644 --- a/src/offscreen/recorder.ts +++ b/src/offscreen/recorder.ts @@ -161,10 +161,21 @@ export type CaptureErrorCode = | 'codec-unsupported' | 'no-source-selected' | 'capture-failed' + | 'wrong-display-surface' // Plan 01-09 D-15-display-surface | 'unknown'; export function classifyCaptureError(error: unknown): CaptureErrorCode { if (error instanceof Error) { + // Plan 01-09 D-15-display-surface: post-grant validation throws + // `Error('wrong-display-surface: got ""…')` when the + // operator overrode the `displaySurface: 'monitor'` HINT (the spec + // does not treat it as a hard constraint — see Task 2 block comment + // near the getDisplayMedia call site for the constraint-hint-vs- + // enforcement rationale). Prefix-matching is safe: we control the + // message string entirely; it never wraps a DOMException. + if (error.message.startsWith('wrong-display-surface')) { + return 'wrong-display-surface'; + } // Our own assertion is the cleanest signal — it never wraps a // DOMException, so prefix-matching is safe and stable. if (error.message.startsWith('vp9 unsupported')) { @@ -210,11 +221,52 @@ async function startRecording(): Promise { } try { assertCodecSupported(); // throws if vp9 missing — no fallback + // Plan 01-09 D-15-display-surface (UAT Test 3 "Share this tab" + // footgun retirement, disambiguated from the historical + // "D-15: operator switching tabs" decision per B-02 fix): + // • `displaySurface: 'monitor'` hints Chrome's picker to default + // to entire-screen selection instead of the tab list. + // • `cursor: 'always'` opportunistically lifts the Phase 5 + // cursor-visibility refinement (we want the operator's pointer + // captured for debugging context). + // Both constraints are HINTS per the W3C Screen Capture spec — the + // operator can still override via the picker UI. The post-grant + // validation block immediately below is the actual enforcement. + // Type note: lib.dom.d.ts MediaTrackConstraints omits `cursor` + // (the Screen Capture spec defines it but TypeScript's bundled + // types lag — https://developer.mozilla.org/docs/Web/API/MediaTrackConstraints/cursor). + // Use a typed widening through `DisplayMediaStreamOptions & + // {video: Record<...>}` instead of `as any` to stay explicit + // about the precise field we're adding. const stream = await navigator.mediaDevices.getDisplayMedia({ - video: true, + video: { displaySurface: 'monitor', cursor: 'always' }, audio: false, // SPEC §9 — Phase 2 / CAP-01 territory + } as DisplayMediaStreamOptions & { + video: { displaySurface: 'monitor'; cursor: 'always' }; }); mediaStream = stream; + // Post-grant validation — the constraint-hint-vs-enforcement gap. + // getDisplayMedia's `displaySurface` is a HINT, not a hard + // constraint: the operator may pick a tab/window from the picker + // UI regardless of what we requested. We enforce monitor-only + // here by inspecting the actual track settings. On mismatch we + // tear down the stream and throw a routable error — the catch + // block below classifies it via classifyCaptureError, broadcasts + // RECORDING_ERROR{code: 'wrong-display-surface'}, and re-throws. + const videoTrack = stream.getVideoTracks()[0]; + if (videoTrack !== undefined) { + const observed = videoTrack.getSettings().displaySurface; + if (observed !== 'monitor') { + stream.getTracks().forEach((t) => { + try { t.stop(); } catch (_e) { /* defensive — stop may throw on + already-stopped tracks; ignore. */ } + }); + mediaStream = null; + throw new Error( + `wrong-display-surface: got "${observed}", expected "monitor"`, + ); + } + } // Track end detection — RESEARCH.md Example F. Attach to ALL tracks // (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в // несинхронизованном состоянии. Регистрация — один раз на поток;