fix(01-review): WR-01+WR-02 stable capture error codes + pure assertCodecSupported

This commit is contained in:
2026-05-16 09:49:01 +02:00
parent 7bc2ef8c38
commit 650c546a6e
2 changed files with 166 additions and 7 deletions

View File

@@ -105,6 +105,14 @@ export function resetBuffer(): void {
// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
// WR-02 fix: assertCodecSupported is a PURE predicate that throws. The
// previous implementation broadcast a RECORDING_ERROR before throwing,
// which violated single responsibility (a function named `assert*`
// shouldn't have side effects) AND double-emitted with the
// startRecording catch block — popup received two RECORDING_ERROR
// messages for the same underlying problem. The single-source-of-truth
// for the broadcast is now `startRecording`'s catch block via
// classifyCaptureError.
export function assertCodecSupported(): void {
const supported =
typeof MediaRecorder !== 'undefined' &&
@@ -112,12 +120,79 @@ export function assertCodecSupported(): void {
MediaRecorder.isTypeSupported(VIDEO_MIME);
if (!supported) {
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '<unknown>';
const errMessage = `vp9 unsupported. UA=${ua}`;
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: errMessage });
throw new Error(errMessage);
throw new Error(`vp9 unsupported. UA=${ua}`);
}
}
// WR-01 fix: classify capture-pipeline failures into a stable,
// programmatically actionable error code. The previous implementation
// forwarded the raw DOMException message ("Permission denied by user",
// "Could not start video source", etc.) which changes between Chrome
// versions and locales. The popup / future telemetry needs a stable
// vocabulary — a string union the SW can switch on. The raw browser
// error stays in the logs at warn level for forensic value.
//
// Codes:
// 'user-cancelled' — operator dismissed the getDisplayMedia picker
// (DOMException name 'NotAllowedError' with no
// system-permission denial context).
// 'permission-denied' — system-level screen-recording permission denied
// by the OS (macOS Screen Recording privacy
// toggle, etc.). NotAllowedError + DOMException
// message hints OR SecurityError.
// 'codec-unsupported' — assertCodecSupported() threw; vp9 not in
// MediaRecorder.isTypeSupported.
// 'no-source-selected' — NotFoundError: picker yielded no source
// (rare; theoretically impossible if the picker
// closes via Cancel — that is NotAllowedError).
// 'capture-failed' — AbortError / generic stream-acquisition failure.
// 'unknown' — anything else; we still log the raw error.
export type CaptureErrorCode =
| 'user-cancelled'
| 'permission-denied'
| 'codec-unsupported'
| 'no-source-selected'
| 'capture-failed'
| 'unknown';
export function classifyCaptureError(error: unknown): CaptureErrorCode {
if (error instanceof Error) {
// 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')) {
return 'codec-unsupported';
}
// DOMException has a stable `name` field per the WebIDL standard:
// https://webidl.spec.whatwg.org/#idl-DOMException
// The name does NOT vary between Chrome versions or locales, unlike
// the human-readable message — exactly what we want for routing.
const name = (error as { name?: string }).name;
if (name === 'NotAllowedError') {
// Distinguish system-permission denial from user-cancel by sniffing
// the message for the "system" keyword Chrome uses on macOS denial
// ("Permission denied by system"). On Linux/Windows the system case
// is typically wrapped as a SecurityError instead — handled below.
// The message text is locale-stable for English Chrome; for other
// locales we fall back to 'user-cancelled' which is the dominant
// NotAllowedError path in practice.
if (/system/i.test(error.message)) {
return 'permission-denied';
}
return 'user-cancelled';
}
if (name === 'SecurityError') {
return 'permission-denied';
}
if (name === 'NotFoundError') {
return 'no-source-selected';
}
if (name === 'AbortError') {
return 'capture-failed';
}
}
return 'unknown';
}
// ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ────────────
async function startRecording(): Promise<void> {
@@ -150,9 +225,13 @@ async function startRecording(): Promise<void> {
'max_segments:', MAX_SEGMENTS,
);
} catch (error) {
// WR-01: emit a stable error code, not the raw DOMException message.
// Raw message stays in logs at warn level for forensic value.
const code = classifyCaptureError(error);
const msg = error instanceof Error ? error.message : String(error);
logger.error('startRecording failed:', msg);
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: msg });
logger.warn('startRecording failed (raw):', msg);
logger.error('startRecording failed (code):', code);
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: code });
throw error;
}
}