diff --git a/package-lock.json b/package-lock.json index 459d3b3db..d0808e094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2305,6 +2305,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.609.0.tgz", "integrity": "sha512-0bNPAyPdkWkS9EGB2A9BZDkBNrnVCBzk5lYRezoT4K3/gi9w1DTYH5tuRdwaTZdxW19U1mq7CV0YJJARKO1L9Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2358,6 +2359,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.609.0.tgz", "integrity": "sha512-A0B3sDKFoFlGo8RYRjDBWHXpbgirer2bZBkCIzhSPHc1vOFHt/m2NcUoE2xnBKXJFrptL1xDkvo1P+XYp/BfcQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4982,7 +4984,6 @@ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5025,7 +5026,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5066,7 +5066,6 @@ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -5084,7 +5083,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5151,7 +5149,6 @@ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -5166,7 +5163,6 @@ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -5260,7 +5256,6 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5271,7 +5266,6 @@ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -6017,6 +6011,7 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -6053,6 +6048,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6065,6 +6061,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6089,6 +6086,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -6132,6 +6130,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6255,6 +6254,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6267,6 +6267,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6282,6 +6283,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6418,7 +6420,6 @@ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -8275,6 +8276,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8472,6 +8474,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -9976,6 +9979,7 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10502,6 +10506,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -10934,6 +10939,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11821,8 +11827,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cookie": { "version": "0.7.2", @@ -12508,7 +12513,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1400418.tgz", "integrity": "sha512-U8j75zDOXF8IP3o0Cgb7K4tFA9uUHEOru2Wx64+EUqL4LNOh9dRe1i8WKR1k3mSpjcCe3aIkTDvEwq0YkI4hfw==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "7.0.0", @@ -12820,6 +12826,7 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -13050,6 +13057,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -14421,7 +14429,6 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -16386,7 +16393,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -16829,7 +16835,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -16866,7 +16871,6 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -19645,6 +19649,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -21575,7 +21580,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -21586,8 +21590,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -22691,6 +22694,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24213,7 +24217,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/turndown": { "version": "7.2.4", @@ -24284,6 +24289,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -27232,6 +27238,7 @@ "integrity": "sha512-SyrSVpygEdPzvgpapVZRQCy8XIOecadp56bPQewpfSfo9ypB6wdOUkx13NBu2ANDlUAtJX7KaLJpTtywVHNlVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^22.2.0", "@wdio/config": "8.46.0", @@ -27312,6 +27319,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -27361,6 +27369,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -27437,6 +27446,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -27681,6 +27691,7 @@ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -27791,8 +27802,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", diff --git a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx index df4389851..ea402a2c4 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx @@ -22,6 +22,7 @@ import { WhisperTranscriptionClient } from "./WhisperTranscriptionClient"; import AudioWaveformWithTranscription from "./AudioWaveformWithTranscription"; import { AudioValidationBadge } from "./AudioValidationBadge"; import { useAudioValidationStatus } from "./hooks/useAudioValidationStatus"; +import { useAudioInputDevices } from "./hooks/useAudioInputDevices"; import SourceTextDisplay from "./SourceTextDisplay"; import { AudioHistoryViewer } from "./AudioHistoryViewer"; import { useMessageHandler } from "./hooks/useCentralizedMessageDispatcher"; @@ -533,6 +534,30 @@ const CellEditor: React.FC = ({ undefined, }; + // Microphone availability — drives the disabled state and warning under + // the recorder. Reacts live to both `devicechange` and permission changes + // so the UI stays in sync without a reload. See + // `hooks/useAudioInputDevices.ts` (top of file) for the in-code constant + // reviewers can flip to simulate "no microphone" / "permission denied" + // on machines with a working mic. + const { noMicDetected, micPermissionDenied, micUnavailable, reportRecorderError } = + useAudioInputDevices(); + // Mirror `micUnavailable` into a ref so guards inside callbacks (and + // setTimeout-deferred work like the auto-start path) can read the + // latest value without becoming stale closures. + const micUnavailableRef = useRef(micUnavailable); + useEffect(() => { + micUnavailableRef.current = micUnavailable; + }, [micUnavailable]); + // Ref for the runtime classifier so `startActualRecording`'s deps stay + // stable. `startActualRecording` is wrapped in `useCallback([audioUrl])` + // and changing its deps to include a new function on every render would + // ripple through several effects. + const reportRecorderErrorRef = useRef(reportRecorderError); + useEffect(() => { + reportRecorderErrorRef.current = reportRecorderError; + }, [reportRecorderError]); + const centerEditor = useCallback(() => { const el = cellEditorRef.current; if (!el) return; @@ -773,6 +798,12 @@ const CellEditor: React.FC = ({ }, }); + // Successful stream acquisition is the ground-truth signal that + // mic access works. Clear any previous runtime-reported denial so + // the UI recovers without waiting for a (potentially unreliable) + // OS-level permission event. + reportRecorderErrorRef.current?.(null); + const mediaRecorderOptions: MediaRecorderOptions = {}; try { if (typeof MediaRecorder !== "undefined") { @@ -846,7 +877,18 @@ const CellEditor: React.FC = ({ recorder.start(); setMediaRecorder(recorder); } catch (err) { - setRecordingStatus("Microphone access denied"); + // Promote the raw error to a typed availability state so the UI + // flips to the right warning (grey slashed mic + "Access denied" + // alert for `NotAllowedError`, "No microphone detected" for + // `NotFoundError`). The hook ignores unrecognized error names so + // transient failures don't accidentally disable recording. + reportRecorderErrorRef.current?.(err); + const errName = (err as { name?: string } | null)?.name; + const statusMessage = + errName === "NotFoundError" || errName === "DevicesNotFoundError" + ? "No microphone detected" + : "Microphone access denied"; + setRecordingStatus(statusMessage); console.error("Error accessing microphone:", err); setCountdown(null); setIsStartingRecording(false); @@ -2752,6 +2794,19 @@ const CellEditor: React.FC = ({ return; } + // Prevent recording — and importantly, the visible pre-record + // countdown — when no mic is available or permission is denied. + // Read from the ref so the auto-start path (deferred via setTimeout) + // sees the latest value rather than its captured render-time copy. + if (micUnavailableRef.current) { + setRecordingStatus( + micPermissionDenied + ? "Microphone access denied" + : "No microphone detected" + ); + return; + } + // If already recording, counting down, or in the warmup window, do // nothing. `isStartingRecording` covers both the user-configured 0s // debounce path and the async getUserMedia warmup. @@ -5707,6 +5762,10 @@ const CellEditor: React.FC = ({ : "idle"; const recorderTitle = isCellLocked ? "Cannot record: cell is locked" + : micPermissionDenied + ? "Microphone access denied" + : noMicDetected + ? "No microphone detected" : isRecording ? "Stop Recording" : countdown !== null @@ -5727,6 +5786,7 @@ const CellEditor: React.FC = ({ : startRecording } disabled={isCellLocked} + unavailable={micUnavailable} title={recorderTitle} /> @@ -5797,6 +5857,23 @@ const CellEditor: React.FC = ({ )} + {micUnavailable && ( + // Override the Alert's default absolute-icon layout so the + // AlertCircle and text share a single flex row with proper + // vertical centering. The `!` (important) prefixes are needed + // because the base Alert variant pins the SVG with + // `[&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4`. + + + + {micPermissionDenied + ? "Microphone access denied. Enable microphone permissions in your system settings to record." + : "No microphone detected. Connect an input device to record."} + + + )} {hint && ( { (window as any).AudioContext = OriginalAudioContext; }); + /** + * Helpers for setting up mock mic detection environments. The hook checks + * both `mediaDevices.enumerateDevices()` (for hardware presence) and + * `permissions.query({ name: "microphone" })` (for permission state), + * subscribing to `devicechange` and PermissionStatus `change` events for + * live updates. Both need EventTarget backing so `addEventListener` + * doesn't blow up. + */ + function mockMicEnvironment(opts: { + audioInputs: number; + permission: "granted" | "denied" | "prompt" | "missing"; + getUserMediaSpy?: ReturnType; + }) { + const fakeMediaDevices = new EventTarget() as EventTarget & { + enumerateDevices: ReturnType; + getUserMedia: ReturnType; + }; + fakeMediaDevices.enumerateDevices = vi.fn().mockResolvedValue( + Array.from({ length: opts.audioInputs }, (_, i) => ({ + deviceId: `mic-${i}`, + groupId: "g", + kind: "audioinput", + label: `Mic ${i}`, + toJSON: () => ({}), + })) as MediaDeviceInfo[] + ); + fakeMediaDevices.getUserMedia = + opts.getUserMediaSpy ?? + vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }); + (navigator as any).mediaDevices = fakeMediaDevices; + + if (opts.permission === "missing") { + delete (navigator as any).permissions; + } else { + const status = new EventTarget() as EventTarget & { + state: "granted" | "denied" | "prompt"; + }; + status.state = opts.permission; + (navigator as any).permissions = { + query: vi.fn().mockResolvedValue(status), + }; + } + } + + it("no microphone detected: disables record button, shows warning, and skips getUserMedia", async () => { + sessionStorage.setItem("preferred-editor-tab", "audio"); + + const props = { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + editHistory: mockTranslationUnits[0].editHistory, + cellIndex: 0, + cellType: CodexCellTypes.TEXT, + contentBeingUpdated: { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + cellChanged: false, + }, + setContentBeingUpdated: vi.fn(), + handleCloseEditor: vi.fn(), + handleSaveHtml: vi.fn(), + textDirection: "ltr" as const, + cellLabel: "Test Label", + cellTimestamps: { startTime: 0, endTime: 5 }, + cellIsChild: false, + openCellById: vi.fn(), + cell: mockTranslationUnits[0], + isSaving: false, + saveError: false, + saveRetryCount: 0, + footnoteOffset: 1, + audioAttachments: { "cell-1": "none" as const }, + }; + + const getUserMediaSpy = vi.fn(); + mockMicEnvironment({ + audioInputs: 0, + permission: "granted", + getUserMediaSpy, + }); + + render( + + + + + + + + ); + + const startBtn = await screen.findByRole("button", { + name: /No microphone detected/i, + }); + expect(startBtn.hasAttribute("disabled")).toBe(true); + expect( + await screen.findByText(/Connect an input device to record/i) + ).toBeTruthy(); + + fireEvent.click(startBtn); + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it("permission denied: shows distinct warning text and disables record button", async () => { + sessionStorage.setItem("preferred-editor-tab", "audio"); + + const props = { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + editHistory: mockTranslationUnits[0].editHistory, + cellIndex: 0, + cellType: CodexCellTypes.TEXT, + contentBeingUpdated: { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + cellChanged: false, + }, + setContentBeingUpdated: vi.fn(), + handleCloseEditor: vi.fn(), + handleSaveHtml: vi.fn(), + textDirection: "ltr" as const, + cellLabel: "Test Label", + cellTimestamps: { startTime: 0, endTime: 5 }, + cellIsChild: false, + openCellById: vi.fn(), + cell: mockTranslationUnits[0], + isSaving: false, + saveError: false, + saveRetryCount: 0, + footnoteOffset: 1, + audioAttachments: { "cell-1": "none" as const }, + }; + + // Mic exists but permission denied — different state from no-device. + const getUserMediaSpy = vi.fn(); + mockMicEnvironment({ + audioInputs: 1, + permission: "denied", + getUserMediaSpy, + }); + + render( + + + + + + + + ); + + const startBtn = await screen.findByRole("button", { + name: /Microphone access denied/i, + }); + expect(startBtn.hasAttribute("disabled")).toBe(true); + // Permission-specific copy is shown, not the no-device copy. + expect( + await screen.findByText(/Enable microphone permissions/i) + ).toBeTruthy(); + expect( + screen.queryByText(/Connect an input device/i) + ).toBeNull(); + + fireEvent.click(startBtn); + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it("auto-start with no mic: does not display countdown or call getUserMedia", async () => { + // Simulate the cell-list mic-button auto-start handoff: a session + // flag is set before the editor opens. When the mic is unavailable + // the countdown must not appear and getUserMedia must not be called. + sessionStorage.setItem("preferred-editor-tab", "audio"); + sessionStorage.setItem("start-audio-recording-cell-1", "1"); + // Default to 3s countdown so we'd see digits if the suppression failed. + (window as any).__recordingCountdownSeconds = 3; + + const getUserMediaSpy = vi.fn(); + mockMicEnvironment({ + audioInputs: 0, + permission: "granted", + getUserMediaSpy, + }); + + const props = { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + editHistory: mockTranslationUnits[0].editHistory, + cellIndex: 0, + cellType: CodexCellTypes.TEXT, + contentBeingUpdated: { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + cellChanged: false, + }, + setContentBeingUpdated: vi.fn(), + handleCloseEditor: vi.fn(), + handleSaveHtml: vi.fn(), + textDirection: "ltr" as const, + cellLabel: "Test Label", + cellTimestamps: { startTime: 0, endTime: 5 }, + cellIsChild: false, + openCellById: vi.fn(), + cell: mockTranslationUnits[0], + isSaving: false, + saveError: false, + saveRetryCount: 0, + footnoteOffset: 1, + audioAttachments: { "cell-1": "none" as const }, + }; + + render( + + + + + + + + ); + + // Wait for the auto-start setTimeout (300ms) to fire and any + // subsequent renders to settle. + await screen.findByText(/Connect an input device to record/i); + await new Promise((r) => setTimeout(r, 400)); + + // No countdown digits should ever have rendered. + expect(screen.queryByText(/^[123]$/)).toBeNull(); + // The button must reflect the unavailable state, never the countdown. + expect( + screen.queryByRole("button", { name: /Starting in/i }) + ).toBeNull(); + // And of course the stream API must never have been touched. + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + it("locked cell: should disable Start Recording and not call getUserMedia", async () => { sessionStorage.setItem("preferred-editor-tab", "audio"); diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts new file mode 100644 index 000000000..cdb7aba1f --- /dev/null +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useAudioInputDevices } from "../hooks/useAudioInputDevices"; + +/** + * Tests for the microphone availability hook used by the audio recorder. + * + * Backed by real EventTargets so `devicechange` and PermissionStatus + * `change` events can be exercised via genuine `dispatchEvent` calls — + * the same path the browser uses on hot-plug or permission change. + */ + +type FakeMediaDevices = EventTarget & { + enumerateDevices: ReturnType; +}; + +type FakePermissionStatus = EventTarget & { state: "granted" | "denied" | "prompt" }; +type FakePermissions = { + query: ReturnType; +}; + +function createFakeMediaDevices(devices: MediaDeviceInfo[]): FakeMediaDevices { + const target = new EventTarget() as FakeMediaDevices; + target.enumerateDevices = vi.fn().mockResolvedValue(devices); + return target; +} + +function createFakePermissionStatus( + state: "granted" | "denied" | "prompt" +): FakePermissionStatus { + const status = new EventTarget() as FakePermissionStatus; + status.state = state; + return status; +} + +function createFakePermissions(status: FakePermissionStatus | null): FakePermissions { + return { + query: vi.fn().mockImplementation(async ({ name }: { name: string }) => { + if (name !== "microphone") { + throw new Error(`unsupported permission name: ${name}`); + } + if (!status) { + throw new Error("permission query not supported"); + } + return status; + }), + }; +} + +function audioInputDevice(label = "Mock Mic"): MediaDeviceInfo { + return { + deviceId: `mock-${label}`, + groupId: "group-1", + kind: "audioinput", + label, + toJSON: () => ({}), + } as MediaDeviceInfo; +} + +function videoInputDevice(): MediaDeviceInfo { + return { + deviceId: "mock-cam", + groupId: "group-2", + kind: "videoinput", + label: "Mock Cam", + toJSON: () => ({}), + } as MediaDeviceInfo; +} + +describe("useAudioInputDevices", () => { + const originalMediaDevices = (navigator as any).mediaDevices; + const originalPermissions = (navigator as any).permissions; + + afterEach(() => { + if (originalMediaDevices) { + (navigator as any).mediaDevices = originalMediaDevices; + } else { + delete (navigator as any).mediaDevices; + } + if (originalPermissions) { + (navigator as any).permissions = originalPermissions; + } else { + delete (navigator as any).permissions; + } + }); + + it("reports availability=available when a mic exists and permission is granted", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.availability).toBe("available")); + expect(result.current.micUnavailable).toBe(false); + expect(result.current.noMicDetected).toBe(false); + expect(result.current.micPermissionDenied).toBe(false); + }); + + it("reports no-device when only non-audio devices are present", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([videoInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.availability).toBe("no-device")); + expect(result.current.noMicDetected).toBe(true); + expect(result.current.micPermissionDenied).toBe(false); + expect(result.current.micUnavailable).toBe(true); + }); + + it("reports permission-denied even when a mic IS enumerated", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("denied") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => + expect(result.current.availability).toBe("permission-denied") + ); + expect(result.current.micPermissionDenied).toBe(true); + expect(result.current.noMicDetected).toBe(false); + expect(result.current.micUnavailable).toBe(true); + }); + + it("re-evaluates on devicechange when a mic is plugged in mid-session", async () => { + const fake = createFakeMediaDevices([]); + (navigator as any).mediaDevices = fake; + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.availability).toBe("no-device")); + + fake.enumerateDevices.mockResolvedValueOnce([audioInputDevice("Plugged-in Mic")]); + act(() => { + fake.dispatchEvent(new Event("devicechange")); + }); + + await waitFor(() => expect(result.current.availability).toBe("available")); + }); + + it("re-evaluates when microphone permission changes mid-session", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + const status = createFakePermissionStatus("granted"); + (navigator as any).permissions = createFakePermissions(status); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.availability).toBe("available")); + + // User revokes mic access via system settings. + status.state = "denied"; + act(() => { + status.dispatchEvent(new Event("change")); + }); + + await waitFor(() => + expect(result.current.availability).toBe("permission-denied") + ); + }); + + it("falls back to unsupported when enumerateDevices is missing", () => { + (navigator as any).mediaDevices = {} as MediaDevices; + + const { result } = renderHook(() => useAudioInputDevices()); + + expect(result.current.availability).toBe("unsupported"); + // Treated as "assume available" so we never false-positive disable. + expect(result.current.micUnavailable).toBe(false); + }); + + it("treats missing permissions API as 'assume granted' (no false denial)", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + // No `navigator.permissions` at all. + delete (navigator as any).permissions; + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.availability).toBe("available")); + expect(result.current.micPermissionDenied).toBe(false); + }); + + describe("reportRecorderError (runtime override)", () => { + it("flips to permission-denied on NotAllowedError even when Permissions API reports granted", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + // This is the macOS "denied at OS level" case: passive APIs + // report granted, but getUserMedia throws. + const err = Object.assign(new Error("denied"), { + name: "NotAllowedError", + }); + act(() => { + result.current.reportRecorderError(err); + }); + + expect(result.current.availability).toBe("permission-denied"); + expect(result.current.micPermissionDenied).toBe(true); + }); + + it("flips to no-device on NotFoundError even when enumeration reported a device", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + const err = Object.assign(new Error("missing"), { name: "NotFoundError" }); + act(() => { + result.current.reportRecorderError(err); + }); + + expect(result.current.availability).toBe("no-device"); + }); + + it("clears the runtime override when called with null (e.g. after a successful retry)", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + act(() => { + result.current.reportRecorderError( + Object.assign(new Error(), { name: "NotAllowedError" }) + ); + }); + expect(result.current.availability).toBe("permission-denied"); + + act(() => { + result.current.reportRecorderError(null); + }); + expect(result.current.availability).toBe("available"); + }); + + it("ignores unrecognized error names so transient failures don't disable recording", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + act(() => { + result.current.reportRecorderError( + Object.assign(new Error("weird"), { name: "AbortError" }) + ); + }); + + expect(result.current.availability).toBe("available"); + }); + + it("runtime override outranks the passive Permissions API state", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + // Passive layer says granted... + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + // ...but runtime got denied. Runtime wins. + act(() => { + result.current.reportRecorderError( + Object.assign(new Error(), { name: "NotAllowedError" }) + ); + }); + expect(result.current.availability).toBe("permission-denied"); + }); + }); +}); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx index b10d58195..3076e8907 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Mic, Square } from "lucide-react"; +import { Mic, MicOff, Square } from "lucide-react"; import { Button } from "../../components/ui/button"; import { cn } from "../../lib/utils"; @@ -17,6 +17,11 @@ import { cn } from "../../lib/utils"; * `RecorderWaveform.tsx` as a single-canvas scrolling waveform rendered * below the button. * + * The `unavailable` prop is distinct from `disabled`: it specifically marks + * "no microphone present" and swaps to a grey, slashed-mic, no-pulse visual + * so the user reads it as a hardware issue rather than a temporary lock. + * `disabled` alone (e.g. locked cell) keeps the normal mic + faded primary. + * * Geometry: * - Outer container is 128x128 so the ring-pulse halo can scale past the * 96x96 button edge without being clipped. @@ -33,6 +38,9 @@ export interface RecorderCircleProps { countdown: number | null; onClick: () => void; disabled?: boolean; + /** True when no microphone is connected. Implies disabled and swaps to + * the slashed-mic + neutral-grey + no-pulse visual treatment. */ + unavailable?: boolean; title?: string; } @@ -41,11 +49,14 @@ export const RecorderCircle: React.FC = ({ countdown, onClick, disabled = false, + unavailable = false, title, }) => { const isRecording = state === "recording"; const isCountdown = state === "countdown"; const isIdle = state === "idle"; + const isUnavailable = unavailable; + const effectiveDisabled = disabled || isUnavailable; return (
= ({ > {/* Ring-pulse halo for idle ("mic ready") and recording. Skipped during countdown so the per-digit animation isn't competing with - a continuous expanding ring. The parent's overflow-visible lets + a continuous expanding ring. Also skipped when unavailable — + a static "no input" state shouldn't be drawing attention with + a continuous animation. The parent's overflow-visible lets the halo expand past the button edge. */} - {!isCountdown && ( + {!isCountdown && !isUnavailable && (
); diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts new file mode 100644 index 000000000..25f790c83 --- /dev/null +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts @@ -0,0 +1,244 @@ +import { useCallback, useEffect, useState } from "react"; + +/** + * Detects whether the user can record audio right now. Reports separately + * for two different failure modes: + * - "no-device" — there is no audioinput hardware enumerated. The user + * needs to plug in / connect a microphone. + * - "permission-denied" — a device exists but the OS / browser blocks + * access. The user needs to grant microphone access in system settings. + * - "available" — at least one device exists and permission is not blocked. + * - "checking" — initial detection still in flight. + * - "unsupported" — the necessary APIs aren't available. Treated as + * "assume available" by callers so we never false-positive disable on a + * working machine. + * + * Reacts live to three signals so the UI stays in sync without a reload: + * - `devicechange` on `navigator.mediaDevices` (mic plugged / unplugged) + * - `change` on the `microphone` PermissionStatus (user grants/revokes + * access via the address bar or browser-level settings) + * - Runtime reports from the recorder via `reportRecorderError(err)` — the + * only reliable way to observe an OS-level block on macOS / Linux (the + * Permissions API only reflects browser-level state, not OS settings, + * so a user who denies in System Settings will still see `"granted"` + * from `navigator.permissions.query`; we only learn the real state + * when `getUserMedia()` throws `NotAllowedError`). + * + * Devices typically show up in `enumerateDevices()` even before the user has + * granted permission (their labels are blank, but the entries still count). + * That means device count alone can't tell us if access is *blocked* — only + * the permissions API or a real `getUserMedia()` attempt can. + * + * ───────────────────────────────────────────────────────────────────────── + * REVIEWER NOTE — testing without unplugging your microphone: + * + * Edit the `FORCE_STATE_FOR_REVIEW` constant below to force a specific + * state regardless of real hardware. Rebuild the webview + * (`pnpm run build:CodexCellEditor`) and reload the extension host to + * see the corresponding UI. **Set back to `null` before committing.** + * + * FORCE_STATE_FOR_REVIEW = "no-device" // grey slashed-mic + "no microphone detected" alert + * FORCE_STATE_FOR_REVIEW = "permission-denied" // grey slashed-mic + "access denied" alert + * FORCE_STATE_FOR_REVIEW = null // real detection (default) + * ───────────────────────────────────────────────────────────────────────── + */ + +/** Override real detection for visual review. MUST be `null` in committed code. */ +const FORCE_STATE_FOR_REVIEW: MicAvailability | null = null; + +export type MicAvailability = + | "available" + | "checking" + | "no-device" + | "permission-denied" + | "unsupported"; + +export interface UseAudioInputDevicesResult { + /** High-level state for branching UI. */ + availability: MicAvailability; + /** True when the user can't record (no device or permission denied). */ + micUnavailable: boolean; + /** True specifically when no audioinput hardware exists. */ + noMicDetected: boolean; + /** True specifically when permission is denied for an existing mic. */ + micPermissionDenied: boolean; + /** + * Call from the recorder's `getUserMedia` catch block with the thrown + * error. The hook classifies the error name (`NotAllowedError`, + * `NotFoundError`, etc.) and updates `availability` accordingly. This + * is the only reliable path for catching OS-level mic blocks where the + * Permissions API misreports. Pass `null` to clear a runtime-set state + * (e.g. after a successful `getUserMedia` call). + */ + reportRecorderError: (err: unknown | null) => void; +} + +type PermissionState = "granted" | "denied" | "prompt" | "unknown"; + +async function queryMicPermission(): Promise { + try { + if (typeof navigator === "undefined" || !navigator.permissions?.query) { + return "unknown"; + } + // `microphone` isn't in TS's PermissionName union in all lib versions. + const status = (await navigator.permissions.query({ + name: "microphone" as PermissionName, + })) as PermissionStatus; + return status.state as PermissionState; + } catch { + // Some environments (Safari, restricted contexts) throw for + // unsupported permission names. Treat as "we don't know". + return "unknown"; + } +} + +async function subscribeToMicPermission( + onChange: (state: PermissionState) => void +): Promise<(() => void) | null> { + try { + if (typeof navigator === "undefined" || !navigator.permissions?.query) { + return null; + } + const status = (await navigator.permissions.query({ + name: "microphone" as PermissionName, + })) as PermissionStatus; + const handler = () => onChange(status.state as PermissionState); + status.addEventListener("change", handler); + return () => status.removeEventListener("change", handler); + } catch { + return null; + } +} + +/** + * Map a `getUserMedia` rejection to a `MicAvailability` value. Returns `null` + * for transient or unknown errors so the caller leaves the state untouched. + * + * The `name` property is set by the browser per the WebRTC spec and is the + * supported way to distinguish these cases. Older browsers used different + * names (e.g. `DevicesNotFoundError`), so we accept the most common aliases. + */ +function classifyRecorderError(err: unknown): MicAvailability | null { + if (!err || typeof err !== "object") return null; + const name = (err as { name?: string }).name; + switch (name) { + case "NotAllowedError": + case "PermissionDeniedError": // Legacy Chromium alias. + case "SecurityError": + return "permission-denied"; + case "NotFoundError": + case "DevicesNotFoundError": // Legacy alias. + case "OverconstrainedError": + return "no-device"; + default: + return null; + } +} + +export function useAudioInputDevices(): UseAudioInputDevicesResult { + const isSupported = + typeof navigator !== "undefined" && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.enumerateDevices === "function"; + + const [availability, setAvailability] = useState( + isSupported ? "checking" : "unsupported" + ); + // When the recorder reports an error, we pin the state until cleared. + // Passive signals (`devicechange`, permission `change`) won't override it, + // because on macOS those signals are unreliable for OS-level mic blocks. + // The recorder calls `reportRecorderError(null)` after a successful + // `getUserMedia` to release the pin. + const [runtimeOverride, setRuntimeOverride] = useState(null); + + useEffect(() => { + if (FORCE_STATE_FOR_REVIEW !== null) { + setAvailability(FORCE_STATE_FOR_REVIEW); + return; + } + + if (!isSupported) { + setAvailability("unsupported"); + return; + } + + // Capture `mediaDevices` at mount so the cleanup path can remove its + // listener even if the global is later swapped (tests, hot-reload). + const mediaDevices = navigator.mediaDevices; + let cancelled = false; + + const evaluate = async () => { + try { + const [devices, permission] = await Promise.all([ + mediaDevices.enumerateDevices(), + queryMicPermission(), + ]); + if (cancelled) return; + + if (permission === "denied") { + setAvailability("permission-denied"); + return; + } + const audioInputs = devices.filter((d) => d.kind === "audioinput"); + setAvailability(audioInputs.length > 0 ? "available" : "no-device"); + } catch (err) { + console.warn("useAudioInputDevices: detection failed", err); + if (!cancelled) { + // On detection failure, fall back to "assume present" so + // we don't disable recording on a machine that may well + // have a working mic. The actual `getUserMedia` call will + // surface a clearer error if recording is then attempted. + setAvailability("available"); + } + } + }; + + evaluate(); + + const handleDeviceChange = () => evaluate(); + mediaDevices.addEventListener("devicechange", handleDeviceChange); + + let unsubscribePermission: (() => void) | null = null; + subscribeToMicPermission(() => evaluate()).then((unsub) => { + if (cancelled) { + unsub?.(); + return; + } + unsubscribePermission = unsub; + }); + + return () => { + cancelled = true; + mediaDevices.removeEventListener("devicechange", handleDeviceChange); + unsubscribePermission?.(); + }; + }, [isSupported]); + + const reportRecorderError = useCallback((err: unknown | null) => { + if (err === null) { + setRuntimeOverride(null); + return; + } + const classified = classifyRecorderError(err); + if (classified) setRuntimeOverride(classified); + }, []); + + // Runtime override (from a real `getUserMedia` failure) takes precedence + // because it reflects ground truth, not Chromium's stale view of OS + // permissions. The dev-only `FORCE_STATE_FOR_REVIEW` still wins above + // everything for visual testing. + const effectiveAvailability: MicAvailability = + FORCE_STATE_FOR_REVIEW ?? runtimeOverride ?? availability; + + const noMicDetected = effectiveAvailability === "no-device"; + const micPermissionDenied = effectiveAvailability === "permission-denied"; + const micUnavailable = noMicDetected || micPermissionDenied; + + return { + availability: effectiveAvailability, + micUnavailable, + noMicDetected, + micPermissionDenied, + reportRecorderError, + }; +}