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)));