From a4633cf4b231d542f3677ff392c75bedaae3eb5c Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Tue, 4 Aug 2026 16:56:41 -0700 Subject: [PATCH] media: ask for camera/mic the way a browser does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PORTING.md | 23 +- lib/flutter_cef.dart | 3 + lib/src/cef_web_controller.dart | 106 ++++++ .../macos/Classes/CefProfileHost.swift | 2 +- .../macos/Classes/CefWebSession.swift | 46 +++ .../macos/Classes/FlutterCefPlugin.swift | 21 ++ .../cef_host/entitlements.release.plist | 16 +- .../flutter_cef_macos/native/cef_host/main.mm | 304 +++++++++++++++++- .../lib/src/cef_events.dart | 81 +++++ test/cef_web_controller_test.dart | 196 ++++++++++- 10 files changed, 783 insertions(+), 15 deletions(-) diff --git a/PORTING.md b/PORTING.md index 19027c2..331f0e8 100644 --- a/PORTING.md +++ b/PORTING.md @@ -77,7 +77,28 @@ the `case` labels in `FlutterCefPlugin.handle` (host→native verbs: `create`, `navigate`, `loadTrusted`, `resize`, `dispose`, `pointer`, `key`, `reload`, `executeJavaScript`, cookies, IME, …) and the `emit(...)` calls (native→Dart events: `cursor`, `loadingState`, `title`, `url`, `consoleMessage`, `jsDialog`, -`cookies`, `imeCompositionBounds`, …). +`cookies`, `imeCompositionBounds`, `mediaRequest`, `mediaState`, …). + +Camera/microphone follows the browser permission model and is one round-trip +plus one status event. A page's `getUserMedia` raises `mediaRequest` +(`{id, permissions, origin}`) **only** when the site has no remembered +decision; the host answers with the `respondMediaRequest` verb +(`{id, allow}`), and `cef_host` remembers that answer as a per-origin content +setting so the site is asked once. `mediaState` +(`{videoActive, audioActive, setting}`) reports what is capturing right now +plus the site's stored decision, and `setMediaSetting` (`{value}`: 0 ask / +1 allow) rewrites it. A platform that does not implement these degrades to +deny — the host grants nothing without an explicit answer. + +Only an ALLOW is ever persisted as a content setting. A *refusal* is kept by +the host, out of Chromium: a stored `BLOCK` is readable by the page through +`navigator.permissions.query()`, and sites check it before asking — so +persisting one makes their own "use camera" button inert, with no request left +for the embedder to prompt on and no in-page way back. (Under Alloy style a +stored BLOCK enforces nothing anyway: `CheckMediaAccessPermission` always +returns true and the request path consults no content settings, so +`OnRequestMediaAccessPermission` is the only real gate.) cef_host clears any +`BLOCK` it finds at page load, for profiles written by older builds. ## 3. The `cef_host` subprocess — the platform seams diff --git a/lib/flutter_cef.dart b/lib/flutter_cef.dart index c5236bb..d2eb982 100644 --- a/lib/flutter_cef.dart +++ b/lib/flutter_cef.dart @@ -15,6 +15,9 @@ export 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.da CefFindResult, CefJsDialogRequest, CefLoadError, + CefMediaPermissionRequest, + CefMediaSetting, + CefMediaState, CefSurfaceInfo; export 'src/cef_web_controller.dart' show CefWebController; export 'src/cef_web_view.dart' show CefWebView; diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 9920f18..402a416 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -41,6 +41,7 @@ class CefWebController { bool _lastVisible = true; bool _visibilityExplicitlySet = false; + /// Stable id for this session, echoed in every host message. final String sessionId; @@ -159,6 +160,31 @@ class CefWebController { Future Function(CefJsDialogRequest request)? onJavaScriptTextInputDialog; + /// Handle a page's request for the camera/microphone (`getUserMedia`). Show + /// your permission UI and return the user's answer: + /// + /// * `true` — allow, and REMEMBER it for the requesting origin. + /// * `false` — block, and REMEMBER it. + /// * `null` — deny this one request WITHOUT remembering, for when no human + /// actually chose (your UI was dismissed by a navigation, the tile went + /// away, a request arrived while another prompt was open). Returning + /// `false` there would persist a site-wide block the user never asked for, + /// and since a remembered block is applied without asking, that silently + /// kills camera/mic for the site with no prompt left to undo it. + /// + /// Fires only when the site has no stored decision. **If unset, requests are + /// denied** (transiently), so a page can never reach the camera without a + /// host that deliberately handles this. + Future Function(CefMediaPermissionRequest request)? + onMediaPermissionRequest; + + /// Live camera/mic status for the current page: what is actually capturing + /// right now, plus the site's remembered decision. Drives an "in use" or + /// "blocked" indicator; pair with [setMediaSetting] to change the decision. + final ValueNotifier mediaState = + ValueNotifier(const CefMediaState()); + + static final Map _bySession = {}; static bool _handlerInstalled = false; @@ -246,6 +272,20 @@ class CefWebController { case 'jsDialog': _handleJsDialog(a); break; + case 'mediaRequest': + _handleMediaRequest(a); + break; + case 'mediaState': + mediaState.value = CefMediaState( + videoActive: a['videoActive'] as bool? ?? false, + audioActive: a['audioActive'] as bool? ?? false, + setting: switch (a['setting'] as int? ?? 0) { + 1 => CefMediaSetting.allow, + 2 => CefMediaSetting.block, + _ => CefMediaSetting.ask, + }, + ); + break; case 'evalResult': _handleEvalResult(a['payload'] as String? ?? ''); break; @@ -381,6 +421,45 @@ class CefWebController { {'sessionId': sessionId, 'id': id, 'ok': ok, 'text': text}); } + /// A page asked for the camera/mic and the site has no remembered decision. + /// Mirrors [_handleJsDialog]: the page's `getUserMedia` is blocked on the + /// native callback until this answers, so every path must answer exactly once. + Future _handleMediaRequest(Map a) async { + final id = a['id'] as int? ?? 0; + // Bits from cef_media_access_permission_types_t: audio = 1<<0, video = 1<<1. + final permissions = a['permissions'] as int? ?? 0; + final req = CefMediaPermissionRequest( + origin: a['origin'] as String? ?? '', + camera: permissions & 0x2 != 0, + microphone: permissions & 0x1 != 0, + ); + // Fail closed: no handler means no way to ask a human, so deny — but + // TRANSIENTLY (null), never as a remembered site-wide block. + bool? decision; + try { + decision = await onMediaPermissionRequest?.call(req); + } catch (e, st) { + decision = null; + FlutterError.reportError(FlutterErrorDetails( + exception: e, + stack: st, + library: 'flutter_cef', + context: + ErrorDescription('handling a camera/microphone request from a page'), + )); + } + // Torn down mid-prompt: drop it. cef_host cancels every pending request on + // dispose/navigation, and an unanswered callback denies rather than hangs. + if (_disposed) return; + await _channel.invokeMethod('respondMediaRequest', { + 'sessionId': sessionId, + 'id': id, + 'allow': decision ?? false, + // Only a real answer is remembered. + 'remember': decision != null, + }); + } + // ── Spawn throttle ────────────────────────────────────────────────────── // Each create() spawns a cef_host Chromium process tree (GPU + renderer + // helper subprocesses). Mounting many CefWebViews in one frame would fork/exec @@ -555,6 +634,10 @@ class CefWebController { _channel.invokeMethod( 'setVisible', {'sessionId': sessionId, 'visible': _lastVisible}); } + // Camera/mic needs no re-assert: the decision lives with the site (a + // per-origin content setting in the profile), not with this session, so it + // survives create/recover/thaw the way a browser's site permissions survive + // reopening a tab. return textureId; } @@ -870,6 +953,28 @@ class CefWebController { 'setVisible', {'sessionId': sessionId, 'visible': visible}); } + /// Change what this site is remembered as being allowed to do with the camera + /// and microphone. + /// + /// This is the "site settings" path behind an in-use / blocked indicator: + /// [CefMediaSetting.ask] forgets the decision (the page will prompt the next + /// time it asks), [CefMediaSetting.block] revokes it, [CefMediaSetting.allow] + /// grants it without prompting. The page is NOT reloaded — a browser doesn't + /// yank the page to change a permission, so the new decision simply applies + /// the next time the site calls `getUserMedia`, and a stream already running + /// keeps running because it belongs to the page. + Future setMediaSetting(CefMediaSetting setting) { + return _channel.invokeMethod('setMediaSetting', { + 'sessionId': sessionId, + 'value': switch (setting) { + CefMediaSetting.ask => 0, + CefMediaSetting.allow => 1, + CefMediaSetting.block => 2, + }, + }); + } + + /// Mute or unmute the page's audio output. Besides silencing it, a hidden /// AND muted page regains Chromium's intensive wake-up throttling (audible /// pages are exempt), so muting on hide keeps a background tile's timers @@ -1039,6 +1144,7 @@ class CefWebController { canGoForward.dispose(); title.dispose(); url.dispose(); + mediaState.dispose(); await _channel.invokeMethod('dispose', {'sessionId': sessionId}); } } diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift index 4369331..6ef4834 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift @@ -39,7 +39,7 @@ final class CefProfileHost { // processGone) instead of silently mis-parsing frames into frozen/blank tiles; the // skew vectors are FLUTTER_CEF_HOST overrides, stale from-source builds, and stale // embedded copies (the content-hash fetch can't drift on the normal path). - static let protocolVersion: UInt8 = 5 + static let protocolVersion: UInt8 = 6 // Profile identity / config. let profileId: String diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift index b5ee446..be294fc 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift @@ -49,6 +49,11 @@ final class CefWebSession: NSObject, FlutterTexture { private static let opDownload: UInt8 = 0x18 private static let opImeBounds: UInt8 = 0x19 private static let opCookies: UInt8 = 0x1a + // cef_host -> us: a page called getUserMedia and the site has no remembered + // decision, so the host must show a permission prompt. {u32 id}{u32 mask}{utf8 origin} + private static let opMediaRequest: UInt8 = 0x1e + // cef_host -> us: {u8 videoActive}{u8 audioActive}{u8 setting 0=ask 1=allow} + private static let opMediaState: UInt8 = 0x1f private static let opNavigate: UInt8 = 0x20 private static let opReload: UInt8 = 0x21 private static let opStop: UInt8 = 0x22 @@ -71,6 +76,12 @@ final class CefWebSession: NSObject, FlutterTexture { private static let opShowDevTools: UInt8 = 0x33 private static let opLoadTrusted: UInt8 = 0x34 private static let opSetVisible: UInt8 = 0x35 + // us -> cef_host: answer a permission prompt {u32 id}{u8 allow}{u8 remember}; + // remembered per-origin only when a human chose, exactly like a browser. + private static let opMediaResponse: UInt8 = 0x3c + // us -> cef_host: {u8 0=ask 1=allow 2=block} rewrite this site's remembered + // camera/mic decision (the URL-bar "site settings" path). No reload. + private static let opSetMediaSetting: UInt8 = 0x3d private static let opOpenAuthWindow: UInt8 = 0x39 private static let opSetAudioMuted: UInt8 = 0x3a // {u8 muted} -> CefBrowserHost::SetAudioMuted private static let opSetPumpInterval: UInt8 = 0x3b // {u16 BE ms} visible begin-frame cadence @@ -94,6 +105,8 @@ final class CefWebSession: NSObject, FlutterTexture { var onDownload: ((String) -> Void)? // suggested name var onImeBounds: ((Int, Int, Int, Int) -> Void)? // caret rect x,y,w,h (DIP) var onCookies: ((Int, String) -> Void)? // request id, json array + var onMediaRequest: ((Int, Int, String) -> Void)? // id, permission mask, origin + var onMediaState: ((Bool, Bool, Int) -> Void)? // videoActive, audioActive, setting // Fired when the backing IOSurface is (re)allocated — at create and on every // resize() (which reallocs). Args are the live global surface id and the // PHYSICAL (Retina) pixel dims. A consumer that mirrors the live frame @@ -426,6 +439,28 @@ final class CefWebSession: NSObject, FlutterTexture { sendFrame(Self.opSetVisible, [visible ? 1 : 0]) } + /// Owner opt-in for camera/mic (getUserMedia) on this browser. Deny-default in + /// cef_host; this flips the per-browser gate. Grants only device capture + /// (camera/mic), never screen capture. + /// Answer a pending camera/mic prompt. The host remembers the choice for the + /// requesting origin, so the page is asked once — the browser contract. + /// `remember` only when a human actually chose — a defensive auto-deny must + /// not persist as a site-wide block. + func respondMediaRequest(id: Int, allow: Bool, remember: Bool) { + var p = [UInt8]() + appendU32(&p, UInt32(truncatingIfNeeded: id)) + p.append(allow ? 1 : 0) + p.append(remember ? 1 : 0) + sendFrame(Self.opMediaResponse, p) + } + + /// Rewrite this site's remembered camera/mic decision (0 = ask again, 1 = + /// allow, 2 = block). No reload — it applies next time the page asks. + func setMediaSetting(_ value: Int) { + sendFrame(Self.opSetMediaSetting, [UInt8(clamping: value)]) + } + + /// Mute/unmute the page's audio. Besides silencing it, a hidden AND muted /// page regains Chromium's intensive wake-up throttling (audible pages are /// exempt), so muting on hide keeps a background tile's timers cheap. @@ -750,6 +785,17 @@ final class CefWebSession: NSObject, FlutterTexture { onCookies?(readU32(payload, 0), String(bytes: payload[4...], encoding: .utf8) ?? "[]") } + case Self.opMediaRequest: + if payload.count >= 8 { + let origin = payload.count > 8 + ? (String(bytes: payload[8...], encoding: .utf8) ?? "") + : "" + onMediaRequest?(readU32(payload, 0), readU32(payload, 4), origin) + } + case Self.opMediaState: + if payload.count >= 3 { + onMediaState?(payload[0] != 0, payload[1] != 0, Int(payload[2])) + } default: break } diff --git a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift index 1336ae9..74cff0b 100644 --- a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift +++ b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift @@ -145,6 +145,15 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin { result(nil) case "setVisible": withSession(args) { $0.setVisible(args["visible"] as? Bool ?? true) } + case "respondMediaRequest": + withSession(args) { + $0.respondMediaRequest(id: args["id"] as? Int ?? 0, + allow: args["allow"] as? Bool ?? false, + remember: args["remember"] as? Bool ?? false) + } + result(nil) + case "setMediaSetting": + withSession(args) { $0.setMediaSetting(args["value"] as? Int ?? 0) } result(nil) case "setAudioMuted": withSession(args) { $0.setAudioMuted(args["muted"] as? Bool ?? true) } @@ -439,6 +448,18 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin { session.onCookies = { [weak self] id, json in self?.emit("cookies", ["sessionId": sessionId, "id": id, "json": json]) } + session.onMediaRequest = { [weak self] id, permissions, origin in + self?.emit("mediaRequest", [ + "sessionId": sessionId, "id": id, + "permissions": permissions, "origin": origin, + ]) + } + session.onMediaState = { [weak self] video, audio, setting in + self?.emit("mediaState", [ + "sessionId": sessionId, "videoActive": video, + "audioActive": audio, "setting": setting, + ]) + } session.onSurface = { [weak self] surfaceId, width, height in self?.emit("onSurface", [ "sessionId": sessionId, "surfaceId": Int(surfaceId), diff --git a/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist b/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist index 1db35f5..96051b3 100644 --- a/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist +++ b/packages/flutter_cef_macos/native/cef_host/entitlements.release.plist @@ -15,11 +15,19 @@ library validation satisfied without it, so it's dev-only. - allow-unsigned-executable-memory: strictly broader than allow-jit and deprecated on macOS 14+; V8/MAP_JIT works with just allow-jit (Mozilla - ships V8 the same way), so it's dev-only. - - device.camera / device.audio-input: camera/mic are now gated by the - deny-default CefPermissionHandler, so untrusted page content can't reach - them and the OS-level entitlement buys nothing here. --> + ships V8 the same way), so it's dev-only. --> com.apple.security.cs.allow-jit + + com.apple.security.device.camera + com.apple.security.device.audio-input com.apple.security.device.bluetooth diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index 68423b4..c501cfc 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -87,6 +87,7 @@ #include "include/cef_life_span_handler.h" #include "include/cef_permission_handler.h" #include "include/cef_render_handler.h" +#include "include/cef_request_context.h" #include "include/cef_request_handler.h" #include "include/cef_task.h" #include "include/wrapper/cef_closure_task.h" @@ -105,7 +106,7 @@ // stale embedded copy). BUMP THIS on any semantic change to the kOp wire protocol // below, together with CefProfileHost.protocolVersion (Swift side) — the two must // stay equal. Hosts predating the handshake send a 1-byte payload and read as v0. -constexpr uint8_t kCefHostProtocolVersion = 5; +constexpr uint8_t kCefHostProtocolVersion = 6; // ---- Opcodes ---- constexpr uint8_t kOpPresent = 0x01; @@ -131,6 +132,8 @@ constexpr uint8_t kOpTargetId = 0x1b; // {utf8 targetId} -> plugin: this browser's CDP targetId (CEF-2b) constexpr uint8_t kOpCreated = 0x1c; // {} H3: OnAfterCreated — browser is up; host's pacer sends the next create constexpr uint8_t kOpCreateFailed = 0x1d; // {} H7: async CreateBrowser dispatch failed; host drops the session +constexpr uint8_t kOpMediaRequest = 0x1e; // {u32 id}{u32 requested}{utf8 origin} page called getUserMedia and there is NO stored decision -> host prompts +constexpr uint8_t kOpMediaState = 0x1f; // {u8 videoActive}{u8 audioActive}{u8 setting 0=ask 1=allow} page media status -> URL-bar "in use" / "allowed" indicator constexpr uint8_t kOpPointer = 0x10; constexpr uint8_t kOpResize = 0x11; // {u32 w}{u32 h}{f64 dpr} — producer-allocates: no sid constexpr uint8_t kOpKey = 0x12; @@ -161,6 +164,8 @@ constexpr uint8_t kOpSetVisible = 0x35; // {u8 visible} -> CefBrowserHost::WasHidden(!visible) constexpr uint8_t kOpResolveTargetId = 0x36; // {} resolve this browser's CDP targetId (CEF-2b) -> kOpTargetId constexpr uint8_t kOpInvalidate = 0x37; // {} C1: force a repaint (Invalidate PET_VIEW) to re-kick a stalled first frame +constexpr uint8_t kOpMediaResponse = 0x3c; // {u32 id}{u8 allow}{u8 remember} answer a kOpMediaRequest prompt; remembered per-origin ONLY when `remember` (a human chose) — never for a defensive auto-deny +constexpr uint8_t kOpSetMediaSetting = 0x3d; // {u8 value} rewrite the CURRENT origin's camera+mic content setting (0=ask/default 1=allow 2=block) — the URL-bar "site settings" path; no reload, it applies next time the page asks constexpr uint8_t kOpEditCommand = 0x38; // {u8 cmd} run a focused-frame edit command (0=copy 1=cut 2=paste 3=selectAll 4=undo 5=redo) constexpr uint8_t kOpOpenAuthWindow = 0x39; // {utf8 url} open a windowed Chrome-runtime browser for a WebAuthn/Touch ID ceremony the OSR tile can't host (shares the tile's cookie jar) constexpr uint8_t kOpSetAudioMuted = 0x3a; // {u8 muted} -> CefBrowserHost::SetAudioMuted; a hidden AND muted page regains intensive wake-up throttling (audible pages are exempt) @@ -193,6 +198,33 @@ // both run on the CEF UI thread), so no lock. bool close_requested = false; + // Pending getUserMedia permission callbacks, keyed by request id — the browser + // permission model: a page asks, the host shows a prompt, the answer comes back + // over the IPC (kOpMediaResponse -> DoMediaResponse -> Continue). Held exactly + // like `dialogs` below: UI-thread-only (OnRequestMediaAccessPermission and the + // response both run on the CEF UI thread), per-slot so one browser's request id + // can never Continue() another's. A held callback that is never answered is + // dropped (Cancel) in OnBeforeClose / on navigation, matching a page whose + // permission prompt is dismissed by leaving it. + // The requested mask is held with the callback because CefMediaAccessCallback + // requires that, for a getUserMedia request, the allowed permissions MATCH the + // requested ones — so a grant is all-or-nothing and the host enforces that + // itself rather than trusting whatever mask the response carries. + struct PendingMedia { + CefRefPtr callback; + uint32_t wanted = 0; + // The ORIGIN that asked — the decision is remembered against this, not the + // address bar, so a cross-origin iframe's grant can't be recorded for (or + // silently inherited from) the top-level page. + std::string origin; + }; + std::map media_requests; + uint32_t media_req_next = 1; + // Last capture state reported by OnMediaAccessChange, so the complete media + // status can be re-sent (on load, or on demand) without waiting for a change. + bool media_video_active = false; + bool media_audio_active = false; + // Guards surface / width / height / dpr / popup_* for THIS browser. Per-slot // (not a single global) so paints on independent browsers don't contend. std::mutex surface_mutex; @@ -1073,16 +1105,136 @@ void OnImeCompositionRangeChanged(CefRefPtr, const CefRange&, // CefPermissionHandler permission type (they go through the authenticator / // Bluetooth stack, gated by the OS + the bluetooth entitlement), so denying // media/geo here leaves the passkey-over-Bluetooth flow untouched. +// Send the page's COMPLETE media status: what is capturing right now, plus the +// site's remembered decision. The stored setting has to be reported explicitly +// because Chromium enforces a remembered BLOCK itself, without ever calling the +// permission handler — so the UI could otherwise never learn that a site is +// blocked (there is no request to observe). UI-thread only (GetContentSetting). +void SendMediaState(const std::shared_ptr& slot) { + CEF_REQUIRE_UI_THREAD(); + uint8_t setting = 0; // 0 = ask (no stored decision) + if (slot->browser) { + CefRefPtr ctx = + slot->browser->GetHost()->GetRequestContext(); + CefRefPtr frame = slot->browser->GetMainFrame(); + const std::string url = frame ? frame->GetURL().ToString() : std::string(); + if (ctx && + (url.rfind("https://", 0) == 0 || url.rfind("http://", 0) == 0)) { + const cef_content_setting_values_t cam = ctx->GetContentSetting( + url, CefString(), CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_CAMERA); + const cef_content_setting_values_t mic = ctx->GetContentSetting( + url, CefString(), CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_MIC); + // Heal a stored BLOCK from an older build, at LOAD time. It cannot wait + // for the next getUserMedia: the page reads this through + // navigator.permissions.query() BEFORE deciding whether to ask at all, so + // a site that sees "denied" never calls getUserMedia and the request-time + // heal would never run — the page stays permanently dead. Clearing it + // here puts the site back to "ask"; a refusal now lives on the Campus + // side, invisible to the page. + if (cam == CEF_CONTENT_SETTING_VALUE_BLOCK || + mic == CEF_CONTENT_SETTING_VALUE_BLOCK) { + ctx->SetContentSetting(url, CefString(), + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_CAMERA, + CEF_CONTENT_SETTING_VALUE_DEFAULT); + ctx->SetContentSetting(url, CefString(), + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_MIC, + CEF_CONTENT_SETTING_VALUE_DEFAULT); + setting = 0; + } else if (cam == CEF_CONTENT_SETTING_VALUE_ALLOW || + mic == CEF_CONTENT_SETTING_VALUE_ALLOW) { + setting = 1; + } + } + } + const uint8_t p[3] = {static_cast(slot->media_video_active ? 1 : 0), + static_cast(slot->media_audio_active ? 1 : 0), + setting}; + SendFrame(slot->browser_id, kOpMediaState, p, 3); +} + class HostPermissionHandler : public CefPermissionHandler { public: - // getUserMedia (camera/mic) and any other media-access request: grant NOTHING. - // Returning true means we handled it; Continue(CEF_MEDIA_PERMISSION_NONE) - // denies (allowed must be a subset of required, and the empty set is valid). + explicit HostPermissionHandler(std::shared_ptr slot) + : slot_(std::move(slot)) {} + + // getUserMedia (camera/mic): the standard BROWSER model — ask once per origin, + // then remember. Never auto-grant: with no stored decision we hold the callback + // and ask the host to show a prompt over the tile (kOpMediaRequest), and the + // answer is written back as a per-origin content setting so the page is never + // asked twice. Only DEVICE capture is ever on the table; DESKTOP capture + // (screen-share) is dropped here and stays a separate capability. bool OnRequestMediaAccessPermission( - CefRefPtr, CefRefPtr, const CefString&, uint32_t, + CefRefPtr browser, CefRefPtr, + const CefString& requesting_origin, uint32_t requested_permissions, CefRefPtr callback) override { - callback->Continue(CEF_MEDIA_PERMISSION_NONE); - return true; + CEF_REQUIRE_UI_THREAD(); + const uint32_t device_only = + static_cast(CEF_MEDIA_PERMISSION_DEVICE_AUDIO_CAPTURE) | + static_cast(CEF_MEDIA_PERMISSION_DEVICE_VIDEO_CAPTURE); + const uint32_t wanted = requested_permissions & device_only; + // Nothing grantable (e.g. a pure getDisplayMedia/desktop request) -> deny. + if (!slot_ || wanted == 0) { + callback->Continue(CEF_MEDIA_PERMISSION_NONE); + return true; + } + const std::string origin = requesting_origin.ToString(); + // A remembered decision answers immediately — no prompt. Chromium normally + // short-circuits a stored setting before ever reaching this handler; we read + // it ourselves so the behavior is identical whether or not it does, and so a + // partially-stored decision (camera allowed, mic unset) still re-prompts. + CefRefPtr ctx = + browser ? browser->GetHost()->GetRequestContext() : nullptr; + if (ctx && !origin.empty()) { + uint32_t remembered = 0; + bool stored_block = false; + const auto read = [&](uint32_t bit, cef_content_setting_types_t type) { + if (!(wanted & bit)) return; + const cef_content_setting_values_t v = + ctx->GetContentSetting(origin, CefString(), type); + if (v == CEF_CONTENT_SETTING_VALUE_ALLOW) { + remembered |= bit; + } else if (v == CEF_CONTENT_SETTING_VALUE_BLOCK) { + stored_block = true; + } + }; + read(CEF_MEDIA_PERMISSION_DEVICE_VIDEO_CAPTURE, + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_CAMERA); + read(CEF_MEDIA_PERMISSION_DEVICE_AUDIO_CAPTURE, + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_MIC); + // A stored BLOCK is never written any more, but an older profile may + // still carry one — heal it. It has to go: the PAGE can read it through + // navigator.permissions.query(), and sites branch on that. Meet asks + // first and, seeing "denied", never calls getUserMedia at all — so its + // own "use camera" button goes dead with no request for the host to + // observe, prompt on, or offer a way back from. "Blocked" is remembered + // on the Campus side instead, where it can't lie to the page. + if (stored_block) { + ctx->SetContentSetting(origin, CefString(), + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_CAMERA, + CEF_CONTENT_SETTING_VALUE_DEFAULT); + ctx->SetContentSetting(origin, CefString(), + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_MIC, + CEF_CONTENT_SETTING_VALUE_DEFAULT); + } + // All-or-nothing: Continue must MATCH the request for getUserMedia, so a + // grant only short-circuits when EVERY requested device is remembered. + if (!stored_block && remembered == wanted) { + callback->Continue(remembered); + return true; + } + } + // Undecided -> hold the callback (exactly like a JS dialog) and prompt. + const uint32_t id = slot_->media_req_next++; + slot_->media_requests[id] = Slot::PendingMedia{callback, wanted, origin}; + std::vector p(8 + origin.size()); + for (int i = 0; i < 4; ++i) { + p[i] = (id >> (24 - 8 * i)) & 0xff; + p[4 + i] = (wanted >> (24 - 8 * i)) & 0xff; + } + memcpy(p.data() + 8, origin.data(), origin.size()); + SendFrame(slot_->browser_id, kOpMediaRequest, p.data(), + static_cast(p.size())); + return true; // answered asynchronously via DoMediaResponse } // Geolocation, notifications, clipboard, etc. all arrive as a permission // prompt: deny without ever showing UI. @@ -1094,6 +1246,9 @@ bool OnShowPermissionPrompt( } IMPLEMENT_REFCOUNTING(HostPermissionHandler); + + private: + std::shared_ptr slot_; }; // ───── Native windowed popup for OAuth (window.open with features) ────────── @@ -1261,7 +1416,7 @@ explicit HostClient(std::shared_ptr slot) : slot_(std::move(slot)) { router_ = CefMessageRouterBrowserSide::Create(config); router_->AddHandler(this, false); rh_ = new HostRenderHandler(slot_); - ph_ = new HostPermissionHandler(); // deny-default permission gate + ph_ = new HostPermissionHandler(slot_); // deny-default; owner opts in per tile } CefRefPtr router_; CefRefPtr rh_; @@ -1345,6 +1500,16 @@ void OnResetDialogState(CefRefPtr) override { slot_->dialogs.clear(); } + // CefDisplayHandler: camera/mic capture started or stopped on this page. This + // is the ONLY honest source for the URL bar's "in use" indicator — it reflects + // what Chromium is actually capturing, not what was merely permitted. + void OnMediaAccessChange(CefRefPtr, bool has_video_access, + bool has_audio_access) override { + slot_->media_video_active = has_video_access; + slot_->media_audio_active = has_audio_access; + SendMediaState(slot_); + } + // Recover from a renderer crash (multi-process only): reload rather than show // a dead page. In single-process a renderer CHECK kills the whole process, so // this never fires — which is why heavy pages need multi-process. @@ -1367,6 +1532,18 @@ void OnLoadStart(CefRefPtr, CefRefPtr frame, if (!frame) return; if (frame->IsMain()) { SendUtf8(slot_->browser_id, kOpPageStart, frame->GetURL().ToString()); + // A navigation abandons any camera/mic prompt the previous page raised — + // Cancel the held callbacks so they don't leak (the page that asked is + // gone and will never be answered). The host dismisses its prompt UI off + // the url change, mirroring how OnResetDialogState drops JS dialogs. + for (auto& kv : slot_->media_requests) { + if (kv.second.callback) kv.second.callback->Cancel(); + } + slot_->media_requests.clear(); + // Leaving the page stops its capture; don't strand a stale "in use" dot if + // the teardown's OnMediaAccessChange(false,false) doesn't arrive. + slot_->media_video_active = false; + slot_->media_audio_active = false; // SECURITY: install the JS-channel shims ONLY into the MAIN frame. The shims expose the // privileged campusHost bridge (window. -> window.cefQuery 'ch:'); injecting them // into cross-origin SUBFRAMES would hand an untrusted embedded iframe that bridge. (The @@ -1378,6 +1555,9 @@ void OnLoadEnd(CefRefPtr browser, CefRefPtr frame, int /*httpStatusCode*/) override { if (frame && frame->IsMain()) { SendUtf8(slot_->browser_id, kOpPageFinish, frame->GetURL().ToString()); + // Report the new page's remembered camera/mic decision so the URL bar can + // show a "blocked" indicator for a site Chromium will silently refuse. + SendMediaState(slot_); // C1 + RENDER FLOOR: force a repaint when the main frame finishes. Invalidate(PET_VIEW) // ALONE is coalesce-able — the scheduler can drop it, which on a shared GPU/Viz process // under a multi-browser establishment burst is exactly when the real-content first frame @@ -1550,6 +1730,12 @@ bool OnProcessMessageReceived(CefRefPtr browser, // paint refs (which copied the shared_ptr) drain. void OnBeforeClose(CefRefPtr browser) override { if (router_) router_->OnBeforeClose(browser); + // Answer nothing and release everything: a tile closed with a camera/mic + // prompt still up must not leave a held callback behind. + for (auto& kv : slot_->media_requests) { + if (kv.second.callback) kv.second.callback->Cancel(); + } + slot_->media_requests.clear(); { std::lock_guard lock(g_slots_mutex); g_slots_by_wire_id.erase(slot_->browser_id); @@ -2128,6 +2314,89 @@ void DoSetVisible(const std::shared_ptr& slot, bool visible) { void DoSetAudioMuted(const std::shared_ptr& slot, bool muted) { if (slot->browser) slot->browser->GetHost()->SetAudioMuted(muted); } +// Write a camera+mic content setting for `origin` on this browser's context. +// This is what makes a decision STICK the way a browser's does — and what +// un-poisons an origin Chromium has already stored a BLOCK for (with a stored +// BLOCK it short-circuits getUserMedia and never consults the permission +// handler, so a site the user once denied would otherwise be permanently dead +// with no way back). UI-thread only. +void SetMediaContentSetting(const std::shared_ptr& slot, + const std::string& origin, + cef_content_setting_values_t value) { + CEF_REQUIRE_UI_THREAD(); + if (!slot->browser) return; + // Content settings are origin-keyed; only http(s) carries one worth writing + // (about:blank et al have no meaningful origin to remember). + if (origin.rfind("https://", 0) != 0 && origin.rfind("http://", 0) != 0) return; + CefRefPtr ctx = + slot->browser->GetHost()->GetRequestContext(); + if (!ctx) return; + ctx->SetContentSetting(origin, CefString(), + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_CAMERA, value); + ctx->SetContentSetting(origin, CefString(), + CEF_CONTENT_SETTING_TYPE_MEDIASTREAM_MIC, value); +} + +// Answer a pending prompt (kOpMediaRequest -> the host showed UI -> the user +// chose). Grants all-or-nothing because Continue must MATCH the getUserMedia +// request. +// +// `remember` MUST be set only when a human actually chose. The host also denies +// defensively — no permission UI wired up, the prompt handler threw, the tile +// was torn down or its page replaced mid-prompt — and persisting those as a +// site-wide BLOCK would permanently, silently kill camera/mic for the site with +// no request left to re-prompt on. Deny transiently instead: the page simply +// asks again next time. +void DoMediaResponse(const std::shared_ptr& slot, uint32_t id, bool allow, + bool remember) { + CEF_REQUIRE_UI_THREAD(); + auto it = slot->media_requests.find(id); + if (it == slot->media_requests.end()) return; // already answered/abandoned + const uint32_t wanted = it->second.wanted; + const std::string origin = it->second.origin; + CefRefPtr cb = it->second.callback; + slot->media_requests.erase(it); + // Remember BEFORE continuing: the page may re-ask the instant it is answered, + // and the stored setting is what keeps that from re-prompting. + // + // Only an ALLOW is ever written here. A stored BLOCK is visible to the page + // via navigator.permissions.query(), and sites check it before asking — so + // writing one makes their own "use camera" button do nothing and leaves no + // request to prompt on. A refusal is remembered on the Campus side instead. + if (remember) { + SetMediaContentSetting(slot, origin, + allow ? CEF_CONTENT_SETTING_VALUE_ALLOW + : CEF_CONTENT_SETTING_VALUE_DEFAULT); + } + if (cb) cb->Continue(allow ? wanted : CEF_MEDIA_PERMISSION_NONE); + SendMediaState(slot); // refresh the indicator for the new decision +} + +// The URL-bar "site settings" path: rewrite THIS page's camera+mic decision +// (0 = ask again / forget, 1 = allow, 2 = block). +// +// Deliberately does NOT reload. A browser never yanks the page out from under +// you to change a site permission — the new decision simply applies the next +// time the page asks. Reloading here also had a surprising side effect: the page +// re-runs its startup getUserMedia, so the permission prompt appeared by itself +// right after the reload instead of when the user pressed the site's own +// "use camera" button. An already-running stream keeps running (it belongs to +// the page), which is also what a browser does. +void DoSetMediaSetting(const std::shared_ptr& slot, uint8_t value) { + CEF_REQUIRE_UI_THREAD(); + if (!slot->browser) return; + CefRefPtr frame = slot->browser->GetMainFrame(); + const std::string url = frame ? frame->GetURL().ToString() : std::string(); + // 1 = allow; anything else clears the stored decision. "Block" deliberately + // does NOT write CEF_CONTENT_SETTING_VALUE_BLOCK — see DoMediaResponse: a + // stored block is readable by the page and stops it from ever asking. + const cef_content_setting_values_t setting = + value == 1 ? CEF_CONTENT_SETTING_VALUE_ALLOW + : CEF_CONTENT_SETTING_VALUE_DEFAULT; + SetMediaContentSetting(slot, url, setting); + SendMediaState(slot); // the indicator reflects the new decision immediately +} + void DoSetPumpInterval(const std::shared_ptr& slot, int ms) { // Clamp: <8ms buys nothing over 60fps begin-frames + risks pump starvation; // >250ms visible would read as a frozen tile. @@ -2610,6 +2879,25 @@ void IpcReadLoop() { CefPostTask(TID_UI, base::BindOnce(&DoSetVisible, slot, vis)); break; } + case kOpMediaResponse: { + // {u32 id}{u8 allow}{u8 remember} — an answer to a camera/mic prompt. + if (!slot) break; + if (plen < 5) break; + uint32_t id = ReadU32BE(p); + bool allow = p[4] != 0; + // Absent byte = don't remember: only an explicit human choice persists. + bool remember = plen >= 6 && p[5] != 0; + CefPostTask(TID_UI, + base::BindOnce(&DoMediaResponse, slot, id, allow, remember)); + break; + } + case kOpSetMediaSetting: { + // {u8 value} — change this site's remembered camera/mic decision. + if (!slot) break; + if (plen < 1) break; + CefPostTask(TID_UI, base::BindOnce(&DoSetMediaSetting, slot, p[0])); + break; + } case kOpSetAudioMuted: { if (!slot) break; bool muted = plen >= 1 ? p[0] != 0 : true; diff --git a/packages/flutter_cef_platform_interface/lib/src/cef_events.dart b/packages/flutter_cef_platform_interface/lib/src/cef_events.dart index c131235..06a8451 100644 --- a/packages/flutter_cef_platform_interface/lib/src/cef_events.dart +++ b/packages/flutter_cef_platform_interface/lib/src/cef_events.dart @@ -72,6 +72,87 @@ class CefJsDialogRequest { String toString() => 'CefJsDialogRequest($message)'; } +/// A page's request to use the camera and/or microphone (`getUserMedia`), +/// raised only when the site has no remembered decision. Passed to +/// [CefWebController.onMediaPermissionRequest]; answer by returning +/// allow/deny, and the answer is remembered for [origin]. +/// +/// The grant is all-or-nothing: CEF requires the answer to a `getUserMedia` +/// request to cover exactly what was asked for, so a page wanting camera AND +/// mic cannot be granted just one. +class CefMediaPermissionRequest { + const CefMediaPermissionRequest({ + required this.origin, + required this.camera, + required this.microphone, + }); + + /// The security origin that asked — the requesting frame's own origin, which + /// for a cross-origin iframe is NOT the address-bar URL. + final String origin; + + /// Whether camera access was requested. + final bool camera; + + /// Whether microphone access was requested. + final bool microphone; + + @override + String toString() => + 'CefMediaPermissionRequest($origin, camera: $camera, mic: $microphone)'; +} + +/// What a site is remembered as being allowed to do with camera/mic. +enum CefMediaSetting { + /// No stored decision — the page will raise a permission request. + ask, + + /// Remembered allow: `getUserMedia` succeeds without prompting. + allow, + + /// Remembered block: `getUserMedia` is refused without prompting. + block, +} + +/// Live camera/microphone status for a page: whether capture is actually +/// happening right now, plus the site's remembered decision. Delivered by +/// [CefWebController.mediaState]; the capture flags are the honest source for +/// an "in use" indicator, since they reflect what Chromium is really capturing +/// rather than what was merely permitted. +class CefMediaState { + const CefMediaState({ + this.videoActive = false, + this.audioActive = false, + this.setting = CefMediaSetting.ask, + }); + + /// The camera is capturing right now. + final bool videoActive; + + /// The microphone is capturing right now. + final bool audioActive; + + /// The current page's remembered camera/mic decision. + final CefMediaSetting setting; + + /// Either device is capturing. + bool get isCapturing => videoActive || audioActive; + + @override + bool operator ==(Object other) => + other is CefMediaState && + other.videoActive == videoActive && + other.audioActive == audioActive && + other.setting == setting; + + @override + int get hashCode => Object.hash(videoActive, audioActive, setting); + + @override + String toString() => + 'CefMediaState(video: $videoActive, audio: $audioActive, $setting)'; +} + /// The live frame surface backing a session: the global IOSurface id its /// off-screen CVPixelBuffer is wrapped over, plus the surface's PHYSICAL /// (Retina) pixel dimensions. Delivered by [CefWebController.onSurface] on each diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart index 13b74f6..c1412f6 100644 --- a/test/cef_web_controller_test.dart +++ b/test/cef_web_controller_test.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart' - show TargetPlatform, debugDefaultTargetPlatformOverride; + show FlutterError, TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_cef/flutter_cef.dart'; @@ -258,6 +258,200 @@ void main() { expect(c.cursor.value, SystemMouseCursors.text); }); + test('a media request reaches the handler and its answer is sent back', + () async { + final c = CefWebController(sessionId: 'm1'); + await c.create(url: 'about:blank', width: 10, height: 10); + CefMediaPermissionRequest? seen; + c.onMediaPermissionRequest = (req) async { + seen = req; + return true; + }; + + // permissions bits: audio = 1<<0, video = 1<<1 -> both requested. + await messenger.handlePlatformMessage( + 'flutter_cef', + const StandardMethodCodec().encodeMethodCall( + const MethodCall('mediaRequest', { + 'sessionId': 'm1', + 'id': 42, + 'permissions': 3, + 'origin': 'https://meet.google.com', + }), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + + expect(seen?.origin, 'https://meet.google.com'); + expect(seen?.camera, isTrue); + expect(seen?.microphone, isTrue); + final args = + (log.firstWhere((m) => m.method == 'respondMediaRequest').arguments + as Map) + .cast(); + expect(args['sessionId'], 'm1'); + expect(args['id'], 42); + expect(args['allow'], isTrue); + expect(args['remember'], isTrue); + }); + + test('an abandoned media prompt denies WITHOUT remembering', () async { + // Regression: a defensive deny (nobody chose — the prompt was dismissed by + // a navigation / teardown) must not persist as a site-wide block. It did, + // which silently killed camera/mic for the site with no prompt left to + // undo it, because a remembered block is applied without ever asking. + final c = CefWebController(sessionId: 'm6'); + await c.create(url: 'about:blank', width: 10, height: 10); + c.onMediaPermissionRequest = (_) async => null; + + await messenger.handlePlatformMessage( + 'flutter_cef', + const StandardMethodCodec().encodeMethodCall( + const MethodCall('mediaRequest', { + 'sessionId': 'm6', + 'id': 5, + 'permissions': 3, + 'origin': 'https://meet.google.com', + }), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + + final args = + (log.firstWhere((m) => m.method == 'respondMediaRequest').arguments + as Map) + .cast(); + expect(args['allow'], isFalse); + expect(args['remember'], isFalse); + }); + + test('an explicit block IS remembered', () async { + final c = CefWebController(sessionId: 'm7'); + await c.create(url: 'about:blank', width: 10, height: 10); + c.onMediaPermissionRequest = (_) async => false; + + await messenger.handlePlatformMessage( + 'flutter_cef', + const StandardMethodCodec().encodeMethodCall( + const MethodCall('mediaRequest', { + 'sessionId': 'm7', + 'id': 9, + 'permissions': 3, + 'origin': 'https://example.com', + }), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + + final args = + (log.firstWhere((m) => m.method == 'respondMediaRequest').arguments + as Map) + .cast(); + expect(args['allow'], isFalse); + expect(args['remember'], isTrue); + }); + + test('a media request with no handler is denied, but not remembered', + () async { + // Fail closed: a host that never opted into showing permission UI must not + // let a page reach the camera — transiently, so wiring the UI up later + // still gets to ask. + final c = CefWebController(sessionId: 'm2'); + await c.create(url: 'about:blank', width: 10, height: 10); + + await messenger.handlePlatformMessage( + 'flutter_cef', + const StandardMethodCodec().encodeMethodCall( + const MethodCall('mediaRequest', { + 'sessionId': 'm2', + 'id': 7, + 'permissions': 2, + 'origin': 'https://example.com', + }), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + + final args = + (log.firstWhere((m) => m.method == 'respondMediaRequest').arguments + as Map) + .cast(); + expect(args['allow'], isFalse); + expect(args['remember'], isFalse); + }); + + test('a throwing media handler denies rather than granting', () async { + final c = CefWebController(sessionId: 'm3'); + await c.create(url: 'about:blank', width: 10, height: 10); + c.onMediaPermissionRequest = (_) async => throw StateError('boom'); + final reported = []; + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) => reported.add(details.exception); + addTearDown(() => FlutterError.onError = previousOnError); + + await messenger.handlePlatformMessage( + 'flutter_cef', + const StandardMethodCodec().encodeMethodCall( + const MethodCall('mediaRequest', { + 'sessionId': 'm3', + 'id': 1, + 'permissions': 1, + 'origin': 'https://example.com', + }), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + + final args = + (log.firstWhere((m) => m.method == 'respondMediaRequest').arguments + as Map) + .cast(); + expect(args['allow'], isFalse); + // A handler that blew up is not a human choosing "block". + expect(args['remember'], isFalse); + // The consumer's bug is reported, not swallowed. + expect(reported.single, isA()); + }); + + test('a media state event updates the controller status', () async { + final c = CefWebController(sessionId: 'm4'); + await c.create(url: 'about:blank', width: 10, height: 10); + expect(c.mediaState.value.isCapturing, isFalse); + expect(c.mediaState.value.setting, CefMediaSetting.ask); + + await messenger.handlePlatformMessage( + 'flutter_cef', + const StandardMethodCodec().encodeMethodCall( + const MethodCall('mediaState', { + 'sessionId': 'm4', + 'videoActive': true, + 'audioActive': false, + 'setting': 1, + }), + ), + (_) {}, + ); + + expect(c.mediaState.value.videoActive, isTrue); + expect(c.mediaState.value.isCapturing, isTrue); + expect(c.mediaState.value.setting, CefMediaSetting.allow); + }); + + test('setMediaSetting forwards the encoded decision', () async { + final c = CefWebController(sessionId: 'm5'); + await c.setMediaSetting(CefMediaSetting.block); + final args = + (log.firstWhere((m) => m.method == 'setMediaSetting').arguments as Map) + .cast(); + expect(args['sessionId'], 'm5'); + expect(args['value'], 2); + }); + test('session ids are unique when not supplied', () { expect(CefWebController().sessionId, isNot(equals(CefWebController().sessionId)));