Skip to content

media: ask for camera/mic the way a browser does - #31

Merged
wenkaifan0720 merged 1 commit into
mainfrom
feat/webview-media-permission
Aug 5, 2026
Merged

media: ask for camera/mic the way a browser does#31
wenkaifan0720 merged 1 commit into
mainfrom
feat/webview-media-permission

Conversation

@wenkaifan0720

Copy link
Copy Markdown
Collaborator

A cefWebview page could not reach the camera at all. cef_host's permission handler denied every getUserMedia unconditionally, and the release entitlements had dropped device.camera / device.audio-input, so even a granted request was refused by the sandbox. Joining a Google Meet from a web tile was impossible.

What this does

Replaces the deny-all gate with the browser permission flow:

  • A page's getUserMedia with no remembered decision raises mediaRequest and the CefMediaAccessCallback is held — id-keyed per slot, mirroring the existing JS-dialog path — until the embedder answers respondMediaRequest. That lets the host show a real permission prompt.
  • Only device capture is ever granted (desktop/screen bits are dropped), and a grant is all-or-nothing because CEF requires the answer to a getUserMedia request to match what was asked for.
  • mediaState reports what is actually capturing plus the site's stored decision, for an in-use / allowed indicator. setMediaSetting is the site-settings path behind it.
  • Held callbacks are cancelled on navigation and on browser close.
  • Release entitlements re-add device.camera + device.audio-input (load-bearing now that the gate can grant).

Why a refusal is not persisted

Only an ALLOW is written as a content setting. A block deliberately is not, and this is the subtle part:

A stored CONTENT_SETTING_BLOCK is readable by the page through navigator.permissions.query(), and sites check it before deciding whether to ask. Meet read denied, never called getUserMedia at all, and its own "use microphone and camera" button became inert — with no request left for the embedder to prompt on and no way back from inside the page. Verified live:

permissions.query({name:'camera'})  ->  state=denied

Under Alloy style that BLOCK also enforced nothing: CheckMediaAccessPermission returns true unconditionally and RequestMediaAccessPermission consults no content settings, so OnRequestMediaAccessPermission was already the only real gate. It bought no enforcement and cost the entire recovery path. Chrome escapes the same trap with omnibox chrome an OSR embedder doesn't render (and shipped the <permission> element because of it); Firefox avoids it by keeping a plain "Block" temporary. Embedders hold the refusal themselves instead.

A BLOCK left in a profile by an earlier build is cleared at page load.

Fail-closed, without persisting

remember on the response marks a real human answer. The defensive denies — no handler wired, handler threw, prompt abandoned by navigation or teardown — refuse that one request without silently writing a site-wide block. That regression is covered by tests: an abandoned prompt denies without remembering, while an explicit Block does persist the caller's intent.

Protocol

kCefHostProtocolVersion 5 → 6, bumped in lockstep with CefProfileHost.protocolVersion. Consumers must publish a matching cef_host and bump the pin together — a stale host is refused at handshake.

Windows does not implement the new verbs and degrades to deny (nothing is granted without an explicit answer); documented in PORTING.md.

Testing

flutter analyze clean; flutter test 67/67 including 7 new tests for the round-trip, the fail-closed paths, and the remember/don't-remember split. Verified live in Campus against a real Google Meet: prompt on the site's own button, allow grants camera + mic, and a previously-stuck site now reports state=prompt instead of denied.

A cefWebview page could not reach the camera at all: cef_host's permission
handler denied every getUserMedia unconditionally, and the release
entitlements had dropped camera/audio-input, so even a granted request was
refused by the sandbox.

Replace the deny-all gate with the browser flow. A page's getUserMedia with
no remembered decision now raises kOpMediaRequest and the callback is HELD
(id-keyed per slot, exactly like the JS-dialog path) until the host answers
with kOpMediaResponse, so the embedder can show a permission prompt. Only
DEVICE capture is ever on the table — desktop/screen bits are dropped — and a
grant is all-or-nothing because CEF requires the answer to match the request.
kOpMediaState reports what is actually capturing plus the site's stored
decision, for an in-use indicator; kOpSetMediaSetting is the site-settings
path behind it. Held callbacks are cancelled on navigation and on close.

Only an ALLOW is persisted as a content setting. A refusal deliberately is
NOT: a stored BLOCK is readable by the page through
navigator.permissions.query(), and sites check it before deciding whether to
ask — Google Meet saw "denied", never called getUserMedia, and its own "use
camera" button went inert with no request left to prompt on and no way back
from inside the page. Under Alloy style that BLOCK also enforced nothing:
CheckMediaAccessPermission always returns true and the request path consults
no content settings, so OnRequestMediaAccessPermission is the only real gate.
A BLOCK left by an older build is cleared at page load.

`remember` on the response marks a real human answer, so the defensive denies
(no handler wired, handler threw, prompt abandoned by a navigation or
teardown) refuse one request without silently persisting a site-wide block.

Protocol 5 -> 6; publish a matching cef_host and bump the pin in lockstep.
@wenkaifan0720
wenkaifan0720 merged commit 15b54f1 into main Aug 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant