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
62 changes: 61 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class _BrowserDemoState extends State<BrowserDemo> {
// with enableCdp, so CDP is only requested in the ephemeral (null) case.
String? _profile;
late CefWebController _controller = _newController();
/// Anchors the context menu: the page reports click coords relative to the
/// view, which must be mapped through this box to global coords.
final GlobalKey _viewKey = GlobalKey();
final FocusNode _webFocus = FocusNode(debugLabel: 'web');
final TextEditingController _urlBar = TextEditingController(text: _startUrl);
double _zoom = 0;
Expand All @@ -61,6 +64,57 @@ class _BrowserDemoState extends State<BrowserDemo> {
_wireController();
}

/// Draw the page context menu and return the chosen command id (null =
/// dismissed). The view is a texture, so the menu is ordinary Flutter UI
/// positioned at the click point.
Future<int?> _showContextMenu(CefContextMenuRequest req) async {
final box = _viewKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !mounted) return null;
final origin = box.localToGlobal(Offset(req.x, req.y));
final overlay =
Overlay.of(context).context.findRenderObject() as RenderBox?;
if (overlay == null) return null;
debugPrint('context menu: ${req.items.length} items, link="${req.linkUrl}" '
'sel="${req.selectionText}" misspelled="${req.misspelledWord}"');
return showMenu<int>(
context: context,
position: RelativeRect.fromRect(
Rect.fromLTWH(origin.dx, origin.dy, 1, 1),
Offset.zero & overlay.size,
),
items: _menuEntries(req.items),
);
}

List<PopupMenuEntry<int>> _menuEntries(List<CefContextMenuItem> items) {
final out = <PopupMenuEntry<int>>[];
for (final item in items) {
switch (item.type) {
case CefContextMenuItemType.separator:
out.add(const PopupMenuDivider());
case CefContextMenuItemType.submenu:
// Flattened with a header for the demo; a real host would nest.
out.add(PopupMenuItem<int>(
enabled: false,
child: Text(item.label,
style: const TextStyle(fontWeight: FontWeight.w600)),
));
out.addAll(_menuEntries(item.items));
case CefContextMenuItemType.command:
case CefContextMenuItemType.check:
case CefContextMenuItemType.radio:
out.add(PopupMenuItem<int>(
value: item.commandId,
// Chromium's own enabled state — Paste greys out with an empty
// clipboard without the host deriving anything.
enabled: item.enabled,
child: Text(item.checked ? '\u2713 ${item.label}' : item.label),
));
}
}
return out;
}

/// Attach the demo's listeners/callbacks to the current [_controller]. Called
/// once at init and again whenever a profile toggle swaps the controller.
void _wireController() {
Expand All @@ -71,6 +125,9 @@ class _BrowserDemoState extends State<BrowserDemo> {
});
_controller.onLoadError = (e) =>
debugPrint('load error ${e.errorCode} ${e.url}: ${e.errorText}');
// Right-click: Chromium built the menu, we draw it. A plain Material menu
// here on purpose — this demonstrates the seam, not a design.
_controller.onContextMenu = _showContextMenu;
// Links that open a new window (target=_blank / window.open) load in place
// rather than spawning a separate native window.
_controller.onCreateWindow = (url) {
Expand Down Expand Up @@ -384,7 +441,9 @@ and committed text — including emoji — should appear intact.</p>
),
),
Expanded(
child: CefWebView(
child: KeyedSubtree(
key: _viewKey,
child: CefWebView(
// Key on the profile so toggling it rebuilds the view against
// the fresh controller (a profile is fixed at create() time).
key: ValueKey(_profile),
Expand All @@ -404,6 +463,7 @@ and committed text — including emoji — should appear intact.</p>
// exclusive with a named profile, so only request it when none
// is active.
enableCdp: _profile == null,
),
),
),
],
Expand Down
24 changes: 12 additions & 12 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
clock:
dependency: transitive
description:
Expand Down Expand Up @@ -162,26 +162,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.19.0"
path:
dependency: transitive
description:
Expand Down Expand Up @@ -271,18 +271,18 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.12"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.2"
vm_service:
dependency: transitive
description:
Expand Down
3 changes: 3 additions & 0 deletions lib/flutter_cef.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.da
show
CefCookie,
CefConsoleMessage,
CefContextMenuItem,
CefContextMenuItemType,
CefContextMenuRequest,
CefFindResult,
CefJsDialogRequest,
CefLoadError,
Expand Down
61 changes: 59 additions & 2 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ class CefWebController {
Future<bool?> Function(CefMediaPermissionRequest request)?
onMediaPermissionRequest;

/// A right-click landed in the page. Return the `commandId` of the chosen
/// item, or null to dismiss.
///
/// Chromium has already built the menu (and decided each item's enabled /
/// checked state); the host only DRAWS it, because an OSR browser has no
/// window for a native menu. Whatever id comes back is executed by Chromium,
/// so copy/paste/back/view-source/spellcheck behave exactly as in Chrome.
///
/// If unset, the menu is dismissed — right-click then does nothing, which is
/// the behaviour before this callback existed.
Future<int?> Function(CefContextMenuRequest request)? onContextMenu;

/// 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.
Expand Down Expand Up @@ -275,6 +287,9 @@ class CefWebController {
case 'mediaRequest':
_handleMediaRequest(a);
break;
case 'contextMenu':
_handleContextMenu(a);
break;
case 'mediaState':
mediaState.value = CefMediaState(
videoActive: a['videoActive'] as bool? ?? false,
Expand Down Expand Up @@ -424,6 +439,38 @@ class CefWebController {
/// 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> _handleContextMenu(Map<String, dynamic> a) async {
final id = a['id'] as int? ?? 0;
// Answer EXACTLY ONCE, whatever happens: CEF requires the menu callback be
// continued or cancelled, and a dropped one wedges the page's menu handling
// so later right-clicks are ignored. Hence 0 (= dismiss) on every failure
// path, including no handler and a throwing handler.
int? command;
if (onContextMenu != null) {
try {
final req = CefContextMenuRequest.fromJson(
id,
jsonDecode(a['json'] as String? ?? '{}') as Map<String, dynamic>,
);
command = await onContextMenu?.call(req);
} catch (e, st) {
command = null;
FlutterError.reportError(FlutterErrorDetails(
exception: e,
stack: st,
library: 'flutter_cef',
context: ErrorDescription('handling a page context menu'),
));
}
}
if (_disposed) return;
await _channel.invokeMethod('chooseContextMenu', {
'sessionId': sessionId,
'id': id,
'commandId': command ?? 0,
});
}

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.
Expand Down Expand Up @@ -868,8 +915,18 @@ class CefWebController {
}

/// Open Chromium's DevTools for this page in a separate window.
Future<void> openDevTools() =>
_channel.invokeMethod('showDevTools', {'sessionId': sessionId});
///
/// [inspectAt] (page DIP coordinates, as reported by
/// [CefContextMenuRequest.x]/[CefContextMenuRequest.y]) opens DevTools already
/// inspecting the element at that point — what "Inspect" on a right-click
/// means. DevTools is a real window even though the page is windowless, so
/// this works from an OSR view.
Future<void> openDevTools({Offset? inspectAt}) =>
_channel.invokeMethod('showDevTools', {
'sessionId': sessionId,
if (inspectAt != null) 'inspectX': inspectAt.dx.round(),
if (inspectAt != null) 'inspectY': inspectAt.dy.round(),
});

/// Open the macOS Character Viewer (the emoji & symbols picker — the same
/// panel as ⌃⌘Space) targeting this view. The view must be focused so the
Expand Down
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 = 6
static let protocolVersion: UInt8 = 7

// Profile identity / config.
let profileId: String
Expand Down
35 changes: 34 additions & 1 deletion packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ final class CefWebSession: NSObject, FlutterTexture {
// 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
private static let opContextMenu: UInt8 = 0x40
// 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
Expand Down Expand Up @@ -79,6 +80,7 @@ final class CefWebSession: NSObject, FlutterTexture {
// 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
private static let opContextMenuCommand: UInt8 = 0x3e
// 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
Expand Down Expand Up @@ -106,6 +108,9 @@ final class CefWebSession: NSObject, FlutterTexture {
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
/// Right-click in the page. `json` carries Chromium's own menu model + hit
/// context; the Flutter side draws it and answers with `chooseContextMenu`.
var onContextMenu: ((Int, String) -> Void)? // id, json
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
Expand Down Expand Up @@ -483,6 +488,16 @@ final class CefWebSession: NSObject, FlutterTexture {
sendFrame(Self.opMediaResponse, p)
}

/// Answer a context menu. `commandId` 0 means dismissed without choosing —
/// which must still be sent: CEF requires the menu callback be answered
/// exactly once, and skipping it wedges the page's menu handling.
func chooseContextMenu(id: Int, commandId: Int) {
var p = [UInt8]()
appendU32(&p, UInt32(truncatingIfNeeded: id))
appendU32(&p, UInt32(truncatingIfNeeded: commandId))
sendFrame(Self.opContextMenuCommand, 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) {
Expand Down Expand Up @@ -555,7 +570,18 @@ final class CefWebSession: NSObject, FlutterTexture {
sendFrame(Self.opDeleteCookie, Array((url + "\u{0}" + name).utf8))
}

func showDevTools() { sendFrame(Self.opShowDevTools) }
/// Open DevTools. With a point (page DIP coords) it opens INSPECTING the
/// element there — the right-click "Inspect" path.
func showDevTools(inspectAt: (x: Int, y: Int)? = nil) {
guard let at = inspectAt else {
sendFrame(Self.opShowDevTools)
return
}
var p = [UInt8]()
appendU32(&p, UInt32(truncatingIfNeeded: max(0, at.x)))
appendU32(&p, UInt32(truncatingIfNeeded: max(0, at.y)))
sendFrame(Self.opShowDevTools, p)
}

func imeSetComposition(_ text: String) {
sendFrame(Self.opImeSetComp, Array(text.utf8))
Expand Down Expand Up @@ -824,6 +850,13 @@ final class CefWebSession: NSObject, FlutterTexture {
: ""
onMediaRequest?(readU32(payload, 0), readU32(payload, 4), origin)
}
case Self.opContextMenu:
if payload.count >= 4 {
let json = payload.count > 4
? (String(bytes: payload[4...], encoding: .utf8) ?? "{}")
: "{}"
onContextMenu?(readU32(payload, 0), json)
}
case Self.opMediaState:
if payload.count >= 3 {
onMediaState?(payload[0] != 0, payload[1] != 0, Int(payload[2]))
Expand Down
19 changes: 18 additions & 1 deletion packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
remember: args["remember"] as? Bool ?? false)
}
result(nil)
case "chooseContextMenu":
withSession(args) {
$0.chooseContextMenu(id: args["id"] as? Int ?? 0,
commandId: args["commandId"] as? Int ?? 0)
}
result(nil)
case "setMediaSetting":
withSession(args) { $0.setMediaSetting(args["value"] as? Int ?? 0) }
result(nil)
Expand Down Expand Up @@ -234,7 +240,13 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
}
result(nil)
case "showDevTools":
withSession(args) { $0.showDevTools() }
withSession(args) {
if let x = args["inspectX"] as? Int, let y = args["inspectY"] as? Int {
$0.showDevTools(inspectAt: (x: x, y: y))
} else {
$0.showDevTools()
}
}
result(nil)
case "enableAgentControl":
// CEF-2b: broker a token-gated CDP endpoint scoped to THIS tile's CDP target.
Expand Down Expand Up @@ -469,6 +481,11 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
"permissions": permissions, "origin": origin,
])
}
session.onContextMenu = { [weak self] id, json in
self?.emit("contextMenu", [
"sessionId": sessionId, "id": id, "json": json,
])
}
session.onMediaState = { [weak self] video, audio, setting in
self?.emit("mediaState", [
"sessionId": sessionId, "videoActive": video,
Expand Down
Loading
Loading