From de162b4293568035dbb16178baec6e904b89d756 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 17 May 2026 15:12:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(01-09):=20GREEN=20=E2=80=94=20displaySurfa?= =?UTF-8?q?ce:'monitor'=20constraint=20+=20post-grant=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 01-09 Task 2 GREEN — flips Task 1 tests 1, 2, 4 to GREEN: 1. CaptureErrorCode union extended with 'wrong-display-surface'. 2. classifyCaptureError branch matches 'wrong-display-surface' prefix. 3. getDisplayMedia call carries {video:{displaySurface:'monitor', cursor:'always'},audio:false} (Plan 01-09 D-15-display-surface + Phase 5 cursor:'always' opportunistic lift). 4. Post-grant validation block reads track.getSettings().displaySurface; on non-monitor pick: tears down stream, nulls mediaStream, throws wrong-display-surface Error which routes through the existing classifyCaptureError + RECORDING_ERROR broadcast path. Type note: lib.dom.d.ts MediaTrackConstraints omits 'cursor' — used explicit type-widening cast (NOT 'as any') to add the field without suppressing other type checking. Tests: 4/4 GREEN; full suite 15 files / 68 tests / GREEN. tsc --noEmit exit 0. npm run build exit 0. --- src/offscreen/recorder.ts | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) 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-аудио-трек не оставил нас в // несинхронизованном состоянии. Регистрация — один раз на поток;