Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -161,10 +161,21 @@ export type CaptureErrorCode =
|
|||||||
| 'codec-unsupported'
|
| 'codec-unsupported'
|
||||||
| 'no-source-selected'
|
| 'no-source-selected'
|
||||||
| 'capture-failed'
|
| 'capture-failed'
|
||||||
|
| 'wrong-display-surface' // Plan 01-09 D-15-display-surface
|
||||||
| 'unknown';
|
| 'unknown';
|
||||||
|
|
||||||
export function classifyCaptureError(error: unknown): CaptureErrorCode {
|
export function classifyCaptureError(error: unknown): CaptureErrorCode {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
// Plan 01-09 D-15-display-surface: post-grant validation throws
|
||||||
|
// `Error('wrong-display-surface: got "<surface>"…')` 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
|
// Our own assertion is the cleanest signal — it never wraps a
|
||||||
// DOMException, so prefix-matching is safe and stable.
|
// DOMException, so prefix-matching is safe and stable.
|
||||||
if (error.message.startsWith('vp9 unsupported')) {
|
if (error.message.startsWith('vp9 unsupported')) {
|
||||||
@@ -210,11 +221,52 @@ async function startRecording(): Promise<void> {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
assertCodecSupported(); // throws if vp9 missing — no fallback
|
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({
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
video: true,
|
video: { displaySurface: 'monitor', cursor: 'always' },
|
||||||
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
|
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
|
||||||
|
} as DisplayMediaStreamOptions & {
|
||||||
|
video: { displaySurface: 'monitor'; cursor: 'always' };
|
||||||
});
|
});
|
||||||
mediaStream = stream;
|
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
|
// Track end detection — RESEARCH.md Example F. Attach to ALL tracks
|
||||||
// (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в
|
// (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в
|
||||||
// несинхронизованном состоянии. Регистрация — один раз на поток;
|
// несинхронизованном состоянии. Регистрация — один раз на поток;
|
||||||
|
|||||||
Reference in New Issue
Block a user