Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions lib/flutter_cef.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
106 changes: 106 additions & 0 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -159,6 +160,31 @@ class CefWebController {
Future<String?> 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<bool?> 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<CefMediaState> mediaState =
ValueNotifier<CefMediaState>(const CefMediaState());


static final Map<String, CefWebController> _bySession =
<String, CefWebController>{};
static bool _handlerInstalled = false;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> _handleMediaRequest(Map<String, dynamic> 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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<void> 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
Expand Down Expand Up @@ -1039,6 +1144,7 @@ class CefWebController {
canGoForward.dispose();
title.dispose();
url.dispose();
mediaState.dispose();
await _channel.invokeMethod('dispose', {'sessionId': sessionId});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
21 changes: 21 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<key>com.apple.security.cs.allow-jit</key><true/>
<!-- Camera / microphone. The permission gate is deny-DEFAULT: HostPermissionHandler
never grants getUserMedia on its own — it prompts the user (kOpMediaRequest)
and remembers the answer per-origin (and grants only DEVICE capture, never
screen), the way a browser does. These OS-level
entitlements are what let macOS actually hand the sandboxed cef_host the
camera/mic once that in-app gate has allowed it — without them a signed build
is denied by the sandbox even after the handler grants (e.g. joining a Google
Meet from a webview tile). Paired with the NSCamera/NSMicrophoneUsageDescription
strings in cef_host's Info.plist. -->
<key>com.apple.security.device.camera</key><true/>
<key>com.apple.security.device.audio-input</key><true/>
<!-- Bluetooth is load-bearing: caBLE / hybrid passkeys (WebAuthn cross-device)
reach Web Bluetooth, so this stays even in the minimal release set. -->
<key>com.apple.security.device.bluetooth</key><true/>
Expand Down
Loading
Loading