getUserMedia 权限排障实战:浏览器拒绝,不一定是用户点了“拒绝”

调用摄像头最常见的示例只有一行:

js 复制代码
const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false
});

但线上出现 NotAllowedError 时,"让用户去浏览器设置里点允许"往往不是一个合格的答案。

最近我排查过一个很有代表性的故障:摄像头与麦克风测试页在多台电脑、多个浏览器上全部失败,页面表现和用户拒绝授权几乎一样。最终原因不在前端按钮,也不在用户设置,而是 Nginx 返回了这条响应头:

http 复制代码
Permissions-Policy: camera=(), microphone=()

空的允许列表意味着:当前文档中的摄像头和麦克风能力被服务器策略直接关闭。浏览器甚至没有必要把决定权交给用户。

把它修正为仅允许同源页面使用后,正常的权限提示才重新出现:

http 复制代码
Permissions-Policy: camera=(self), microphone=(self)

这篇文章不会只停留在"复制一行配置"。我们从浏览器的权限模型开始,建立一套可以复用的排查顺序。

一、先建立四道"权限门"的模型

getUserMedia() 成功前,至少要依次通过四层检查:

text 复制代码
安全上下文(HTTPS)
        ↓
文档级 Permissions Policy
        ↓
浏览器站点权限 + 操作系统权限
        ↓
设备存在、未被阻断,并且满足媒体约束

任何一层失败,最终都可能表现为"摄像头打不开"。如果没有分层思考,很容易在浏览器设置、前端代码和服务器配置之间反复试错。

第 1 层:页面是不是安全上下文

navigator.mediaDevices.getUserMedia() 只在安全上下文中可用。生产环境应使用 HTTPS;本地开发中的 localhost 通常被浏览器视为可信来源。

先检查能力,而不是直接调用:

js 复制代码
if (!navigator.mediaDevices?.getUserMedia) {
  throw new Error("getUserMedia 不可用:请检查 HTTPS 和浏览器支持");
}

注意:在不安全上下文中,问题可能不是 Promise 被拒绝,而是 navigator.mediaDevices 本身不可用。因此只写 .catch() 不足以覆盖这一层。

第 2 层:服务器是否允许当前文档使用能力

Permissions-Policy 是文档的能力开关。它在用户授权之前生效。

下面的策略禁止任何来源使用摄像头和麦克风:

http 复制代码
Permissions-Policy: camera=(), microphone=()

下面的策略允许当前响应所属的源使用:

http 复制代码
Permissions-Policy: camera=(self), microphone=(self)

这里的 self 是"同源",不是"同一个主域名的大概范围"。协议、主机名或端口不同,都可能构成不同的 origin。

第 3 层:用户和操作系统是否授权

服务器允许,只代表浏览器可以继续询问用户。用户仍然可以拒绝,浏览器也可能记住此前的"阻止"选择。Windows、macOS、Android 和 iOS 还有各自的系统级摄像头/麦克风开关。

因此应分别检查:

  • 地址栏旁的站点权限;
  • 浏览器全局隐私设置;
  • 操作系统对浏览器应用的摄像头和麦克风权限。

第 4 层:设备和约束是否可满足

即使前三层都通过,也可能没有设备、设备被系统阻断,或指定的分辨率、采样率、deviceId 无法满足。

这时再检查硬件与约束,顺序才合理。

二、一个能正确释放设备的最小实现

先从最小请求开始,不要一上来就叠加 4K 分辨率、帧率、前后摄像头和精确设备 ID。

js 复制代码
let stream = null;

async function startCamera() {
  if (!navigator.mediaDevices?.getUserMedia) {
    throw new Error("当前页面无法使用媒体设备 API");
  }

  stream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: false
  });

  const video = document.querySelector("video");
  video.srcObject = stream;
  await video.play();
}

function stopCamera() {
  stream?.getTracks().forEach(track => track.stop());
  stream = null;

  const video = document.querySelector("video");
  video.srcObject = null;
}

window.addEventListener("pagehide", stopCamera);

video.pause() 或把预览区域隐藏起来,并不会停止摄像头。真正释放硬件的是对每条 MediaStreamTrack 调用 stop()

页面离开时也应清理。否则摄像头指示灯可能继续亮一段时间,下一次请求或其他应用也可能受影响。

三、Nginx 应该怎么配,以及最容易漏掉什么

同源页面需要直接使用摄像头和麦克风时,可以这样设置:

nginx 复制代码
add_header Permissions-Policy "camera=(self), microphone=(self)" always;

always 让该响应头也能出现在部分非 2xx 响应上,便于策略保持一致。不过,配置正确与否不能靠"我改过文件"判断,必须核对浏览器最终收到的响应。

1. 检查最终响应,而不是只读 Nginx 配置

在浏览器 DevTools 的 Network 面板中:

  1. 重新加载目标页面;
  2. 点开页面文档本身的请求;
  3. 查看 Response Headers;
  4. 搜索 permissions-policy
  5. 确认没有旧值、重复值或上游代理追加的值。

也可以在终端做只读检查:

bash 复制代码
curl -I https://example.com/tools/camera-test

真实链路中,CDN、托管平台、反向代理和应用框架都可能写响应头。只看某一层配置,不等于知道客户端收到什么。

2. 注意 add_header 的作用域

Nginx 的 add_header 具有配置层级和继承规则。server 中看似正确的配置,可能被更具体的 location 块影响。

如果同一个页面在不同路由上的响应头不同,检查:

  • httpserverlocation 三个层级;
  • 是否存在多个包含文件;
  • 静态文件与应用代理是否走了不同的 location
  • CDN 是否缓存了旧响应头。

不要盲目叠加第二条 Permissions-Policy。先找出最终响应中每一个同名头由谁产生。

3. 不要把 CSP 和 Permissions Policy 混为一谈

CSP 主要限制脚本、样式、图片、连接等资源的来源;Permissions-Policy 控制摄像头、麦克风、地理位置等浏览器能力是否可用。

调整 connect-src 通常不能修复一个被 camera=() 禁止的 getUserMedia() 请求,因为读取本地摄像头不是一次普通的 HTTP 上传请求。

四、跨域 iframe 比顶层页面多一道授权

如果摄像头工具运行在 iframe 中,仅修改子页面自己的 JavaScript 还不够。

假设:

  • 顶层页面是 https://app.example.com
  • iframe 是 https://camera.example.net

父文档需要通过响应头允许目标来源,并在 iframe 上授予对应能力。例如:

http 复制代码
Permissions-Policy: camera=(self "https://camera.example.net"), microphone=(self "https://camera.example.net")
html 复制代码
<iframe
  src="https://camera.example.net/test"
  allow="camera; microphone"
></iframe>

响应头定义文档可以把能力委托给谁,iframe 的 allow 属性进一步声明这个嵌入内容可使用哪些能力。最终结果受两者共同约束;allow 不能突破响应头已经设置的上限。

此外,用户最终授权的对象与具体浏览器行为仍受 origin 和浏览器策略影响。跨域嵌入时不要用 * 图省事,应明确列出真正需要的来源。

五、不要把所有失败都显示成"请允许权限"

错误名是排障最有价值的线索之一。

js 复制代码
function explainMediaError(error) {
  switch (error?.name) {
    case "NotAllowedError":
    case "SecurityError":
      return "访问被策略、浏览器权限或系统权限阻止";

    case "NotFoundError":
      return "没有找到符合条件的媒体设备";

    case "OverconstrainedError":
      return `设备无法满足约束:${error.constraint || "未知约束"}`;

    case "NotReadableError":
      return "设备存在,但浏览器无法读取;可能被系统或其他应用占用";

    case "AbortError":
      return "浏览器在启动设备时中止了操作";

    default:
      return `启动失败:${error?.name || "UnknownError"}`;
  }
}

需要注意两点:

第一,NotAllowedError 并不等于"用户刚刚点了拒绝"。不安全上下文、Permissions Policy 和已保存的站点阻止设置都可能落到相近的拒绝结果。

第二,不同浏览器和操作系统对"设备被占用"的处理不完全一致。错误提示应该给出下一步检查方法,不要假装一个错误名可以定位所有根因。

推荐记录用于诊断的最少信息:错误 name、失败阶段、请求的约束。不要把设备标签、用户名、页面输入或媒体内容发进日志系统。

六、设备列表为什么一开始没有名称

enumerateDevices() 可以列出设备:

js 复制代码
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(device => device.kind === "videoinput");

但在用户授权前,浏览器通常会隐藏或限制设备标签,以减少指纹识别风险。因此常见的正确流程是:

  1. 先用宽松约束请求一次媒体权限;
  2. 授权成功后调用 enumerateDevices()
  3. 展示可用设备列表;
  4. 用户切换时停止旧 track,再按 deviceId 请求新设备。
js 复制代码
async function switchCamera(deviceId) {
  stopCamera();

  stream = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { exact: deviceId } },
    audio: false
  });

  document.querySelector("video").srcObject = stream;
}

移动端还可以优先尝试 facingMode: "user""environment",但不要假定每台设备都提供相同的摄像头集合。

七、麦克风音量条不需要录音,也不需要上传

如果目标只是确认麦克风是否拾音,可以把 MediaStream 接入 Web Audio 的 AnalyserNode,读取时域采样并计算 RMS(均方根)。

js 复制代码
function createVolumeMeter(stream, onLevel) {
  const audioContext = new AudioContext();
  const source = audioContext.createMediaStreamSource(stream);
  const analyser = audioContext.createAnalyser();
  analyser.fftSize = 512;

  source.connect(analyser);
  const samples = new Uint8Array(analyser.frequencyBinCount);
  let frameId;

  function draw() {
    analyser.getByteTimeDomainData(samples);

    let squareSum = 0;
    for (const sample of samples) {
      const normalized = (sample - 128) / 128;
      squareSum += normalized * normalized;
    }

    const rms = Math.sqrt(squareSum / samples.length);
    onLevel(Math.min(1, rms * 2.4));
    frameId = requestAnimationFrame(draw);
  }

  draw();

  return async function stop() {
    cancelAnimationFrame(frameId);
    stream.getTracks().forEach(track => track.stop());
    await audioContext.close();
  };
}

这段路径只在浏览器内实时分析采样。它没有创建 MediaRecorder,没有建立 RTCPeerConnection,也没有执行上传请求。

更准确的说法是:getUserMedia() 属于 Media Capture and Streams API,也常用于 WebRTC 应用;但"使用 getUserMedia"不等于"已经把音视频通过 WebRTC 发给远端"。是否离开设备,要看后续代码是否把流交给连接、录制或网络传输逻辑。

八、一套从快到慢的排查清单

遇到线上故障时,我按下面的顺序处理:

  1. 确认目标页面是 HTTPS,且 navigator.mediaDevices?.getUserMedia 存在;
  2. 在 Console 记录错误的 namemessage
  3. 在 Network 面板检查主文档最终收到的 Permissions-Policy
  4. 如果在 iframe 中,检查响应头和 iframe allow 两层委托;
  5. 检查地址栏站点权限和操作系统隐私权限;
  6. 关闭 Zoom、Teams、OBS 等可能占用设备的应用;
  7. { video: true }{ audio: true } 的最小约束重试;
  8. 授权后再枚举设备、指定 deviceId 或增加分辨率约束;
  9. 停止时确认所有 tracks 都执行了 stop()
  10. 用 DevTools Network 验证测试期间是否存在不应有的媒体上传。

需要一个现成页面交叉验证时,可以使用这个浏览器端摄像头与麦克风测试页:它把摄像头预览、分辨率、设备切换和本地实时音量条放在同一页。把它当作对照工具,而不是结论;如果多个网站都失败,更应回到浏览器与系统层检查。

最后的判断原则

摄像头权限不是一个布尔开关,而是一条由服务器、文档、浏览器、系统、设备和约束共同决定的链路。

所以,看到 NotAllowedError 时不要立刻责怪用户;看到 HTTPS 也不要断定权限层没有问题;改完 Nginx 更不要跳过最终响应验证。

把故障拆成四道门,一次只验证一层,getUserMedia() 的排查就会从碰运气变成工程问题。

参考资料

相关推荐
weixin_446729164 小时前
Nginx简单学习与了解
运维·nginx
云计算磊哥@16 小时前
运维开发宝典058-大型网站nginx服务器管理全集4
服务器·nginx·运维开发
郝亚军3 天前
使用Vue 3和Nginx打包和部署Vue.js项目的一般步骤
前端·vue.js·nginx
某林2123 天前
ROS2 + WebRTC + MQTT 异构系统架构
架构·系统架构·机器人·硬件架构·webrtc·ros2
難釋懷3 天前
Nginx代理https请求
redis·nginx·https
chexus4 天前
21. 深入 Nginx HTTP 缓存源码:CDN功能
nginx·http·缓存
BelongPanda5 天前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
郝亚军6 天前
nginx的三个基础库:PCRE、OpenSSL、Zlib的安装
linux·服务器·nginx