From 7369801e63f2cc333260e18857c630f9fbb1f3b4 Mon Sep 17 00:00:00 2001
From: wenkaifan0720
Date: Thu, 6 Aug 2026 19:00:10 -0700
Subject: [PATCH 1/3] feat(osr): surface the context menu Chromium already
builds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Right-click did nothing in an OSR tile. Not because the menu was missing —
Chromium builds a complete, correctly-stateful one for every right-click
(back/forward/reload, cut/copy/paste with the right items greyed out,
view-source, copy-link-address, the whole spellcheck block with live dictionary
suggestions) — but because no CefContextMenuHandler was implemented, so CEF
constructed it and threw it away.
We cannot let CEF display it: the default path is a native menu parented to a
window, and a windowless browser has none. So RunContextMenu takes over display
instead. Serialise the model Chromium built (labels, command ids, enabled /
checked, separators, one level of submenu) plus the hit context (coords, link
url, media source, selection, misspelled word) and hand it to the host to draw;
send the chosen command id back so CHROMIUM executes it. Every command's
behaviour and enabled state stays authoritative rather than reimplemented.
The invariant is answer-exactly-once: CEF requires the menu callback be
continued or cancelled, and a dropped answer wedges the page's menu handling so
later right-clicks are silently ignored. Every path therefore answers —
chosen, dismissed, no handler, throwing handler, malformed json — with 0
meaning dismiss. OnContextMenuDismissed drops pending entries because CEF has
already invalidated the callback by then. 5 tests cover those paths.
Protocol 6 -> 7 (kOpContextMenu 0x20 up, kOpContextMenuCommand 0x3e down); both
sides bumped together, since a mismatch is a hard processGone(protocolMismatch).
Co-Authored-By: Claude Opus 5
---
lib/flutter_cef.dart | 3 +
lib/src/cef_web_controller.dart | 47 +++++
.../macos/Classes/CefProfileHost.swift | 2 +-
.../macos/Classes/CefWebSession.swift | 22 +++
.../macos/Classes/FlutterCefPlugin.swift | 11 ++
.../flutter_cef_macos/native/cef_host/main.mm | 154 +++++++++++++++-
.../lib/src/cef_events.dart | 128 +++++++++++++
test/cef_web_controller_test.dart | 170 ++++++++++++++++++
8 files changed, 535 insertions(+), 2 deletions(-)
diff --git a/lib/flutter_cef.dart b/lib/flutter_cef.dart
index d7495a5..d282c50 100644
--- a/lib/flutter_cef.dart
+++ b/lib/flutter_cef.dart
@@ -12,6 +12,9 @@ export 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.da
show
CefCookie,
CefConsoleMessage,
+ CefContextMenuItem,
+ CefContextMenuItemType,
+ CefContextMenuRequest,
CefFindResult,
CefJsDialogRequest,
CefLoadError,
diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart
index 680c55f..2f75d1b 100644
--- a/lib/src/cef_web_controller.dart
+++ b/lib/src/cef_web_controller.dart
@@ -178,6 +178,18 @@ class CefWebController {
Future 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 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.
@@ -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,
@@ -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 _handleContextMenu(Map 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,
+ );
+ 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 _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.
diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift
index 6ef4834..bac00e7 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 = 6
+ static let protocolVersion: UInt8 = 7
// 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 c1f4c83..5ecc8c2 100644
--- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
+++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
@@ -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 = 0x20
// 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
@@ -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
@@ -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
@@ -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) {
@@ -824,6 +839,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]))
diff --git a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
index 64aea41..41a12a4 100644
--- a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
+++ b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
@@ -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)
@@ -469,6 +475,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,
diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm
index 61b97fe..079db49 100644
--- a/packages/flutter_cef_macos/native/cef_host/main.mm
+++ b/packages/flutter_cef_macos/native/cef_host/main.mm
@@ -107,7 +107,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 = 6;
+constexpr uint8_t kCefHostProtocolVersion = 7;
// ---- Opcodes ----
constexpr uint8_t kOpPresent = 0x01;
@@ -135,6 +135,7 @@
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 kOpContextMenu = 0x20; // {u32 id}{utf8 json} right-click in the page: Chromium's OWN menu model + params, for the host to draw in Flutter (OSR has no window to put a native menu in)
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;
@@ -171,6 +172,7 @@
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)
constexpr uint8_t kOpSetPumpInterval = 0x3b; // {u16 BE ms} visible begin-frame cadence for this slot, clamped to [8, 250]; hidden slots stay on the 100ms no-op poll
+constexpr uint8_t kOpContextMenuCommand = 0x3e; // {u32 id}{u32 commandId} run the chosen command from a kOpContextMenu (commandId 0 = dismissed); Chromium executes it, so copy/paste/back/spellcheck behave exactly as in Chrome
// ---- Shared runtime state ----
// Atomic: the reader thread reads it (ReadAll), SendFrame on any thread reads it,
@@ -264,6 +266,14 @@ bool NoteRendererCrashAndCheckLoop() {
std::string origin;
};
std::map media_requests;
+
+ // Right-click menus awaiting a choice from the Flutter side. Same shape as
+ // media_requests and for the same reason: OSR has no window, so the menu is
+ // drawn by the host and the callback has to survive until the user picks.
+ // CEF requires the callback be answered (Continue or Cancel) exactly once, so
+ // a dropped entry would leave the page's menu logic hanging.
+ std::map> context_menus;
+ uint32_t next_context_menu_id = 1;
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.
@@ -1446,6 +1456,49 @@ static void OpenNativeAuthPopup(const CefString& url, const CefPopupFeatures& f,
[win release]; // drop our alloc +1; PopupClient's retain keeps it alive
}
+std::string JsonEscape(const std::string& s); // defined below
+
+// Flatten Chromium's CefMenuModel into JSON for the Flutter side to draw.
+// Recurses one level for submenus (the spellcheck / "Spelling and Grammar"
+// blocks are submenus). Separators are emitted so the drawn menu keeps
+// Chromium's grouping instead of one undifferentiated list.
+std::string SerializeMenuModel(CefRefPtr model) {
+ std::string out = "[";
+ const size_t n = model->GetCount();
+ for (size_t i = 0; i < n; ++i) {
+ if (i) out += ",";
+ const cef_menu_item_type_t type = model->GetTypeAt(i);
+ const int command = model->GetCommandIdAt(i);
+ out += "{";
+ if (type == MENUITEMTYPE_SEPARATOR) {
+ out += "\"type\":\"separator\"";
+ } else {
+ const char* t = type == MENUITEMTYPE_CHECK ? "check"
+ : type == MENUITEMTYPE_RADIO ? "radio"
+ : type == MENUITEMTYPE_SUBMENU ? "submenu"
+ : "command";
+ out += "\"type\":\"" + std::string(t) + "\",";
+ out += "\"label\":\"" + JsonEscape(model->GetLabelAt(i).ToString()) + "\",";
+ out += "\"commandId\":" + std::to_string(command) + ",";
+ // Chromium owns enabled/checked — reporting them keeps the drawn menu
+ // honest (e.g. Paste greyed out with an empty clipboard) without Campus
+ // re-deriving state it cannot see.
+ out += "\"enabled\":" +
+ std::string(model->IsEnabledAt(i) ? "true" : "false") + ",";
+ out += "\"checked\":" +
+ std::string(model->IsCheckedAt(i) ? "true" : "false");
+ if (type == MENUITEMTYPE_SUBMENU) {
+ if (CefRefPtr sub = model->GetSubMenuAt(i)) {
+ out += ",\"items\":" + SerializeMenuModel(sub);
+ }
+ }
+ }
+ out += "}";
+ }
+ out += "]";
+ return out;
+}
+
class HostClient : public CefClient,
public CefLoadHandler,
public CefDisplayHandler,
@@ -1454,6 +1507,7 @@ static void OpenNativeAuthPopup(const CefString& url, const CefPopupFeatures& f,
public CefJSDialogHandler,
public CefDownloadHandler,
public CefRequestHandler,
+ public CefContextMenuHandler,
public CefMessageRouterBrowserSide::Handler {
public:
explicit HostClient(std::shared_ptr slot) : slot_(std::move(slot)) {
@@ -1475,6 +1529,69 @@ explicit HostClient(std::shared_ptr slot) : slot_(std::move(slot)) {
CefRefPtr GetJSDialogHandler() override { return this; }
CefRefPtr GetDownloadHandler() override { return this; }
CefRefPtr GetRequestHandler() override { return this; }
+ CefRefPtr GetContextMenuHandler() override {
+ return this;
+ }
+
+ // ── CefContextMenuHandler ──────────────────────────────────────────────
+ //
+ // Chromium already builds a complete, correctly-stateful context menu for
+ // every right-click (back/forward/reload, cut/copy/paste with the right items
+ // greyed out, view-source, copy-link-address, the whole spellcheck block with
+ // live dictionary suggestions). Without a handler CEF constructs it and throws
+ // it away, which is why right-click did nothing in a web tile.
+ //
+ // We can't let CEF display it: the default display path is a native menu
+ // parented to a window, and OSR has none. So RunContextMenu takes over
+ // display — serialise the model Chromium built, hand it to Flutter to draw in
+ // the Campus design system, and send the chosen command id back so CHROMIUM
+ // executes it. That keeps every command's behaviour and enabled/checked state
+ // authoritative instead of reimplementing it.
+ bool RunContextMenu(CefRefPtr browser, CefRefPtr,
+ CefRefPtr params,
+ CefRefPtr model,
+ CefRefPtr callback) override {
+ CEF_REQUIRE_UI_THREAD();
+ if (!slot_ || !params || !model || !callback) return false;
+ // An empty model means Chromium had nothing to offer; let the default
+ // (no-op) path handle it rather than showing an empty menu.
+ if (model->GetCount() == 0) return false;
+
+ uint32_t id;
+ {
+ std::lock_guard lock(slot_->surface_mutex);
+ id = slot_->next_context_menu_id++;
+ slot_->context_menus[id] = callback;
+ }
+
+ std::string json = "{";
+ json += "\"x\":" + std::to_string(params->GetXCoord()) + ",";
+ json += "\"y\":" + std::to_string(params->GetYCoord()) + ",";
+ json += "\"editable\":" + std::string(params->IsEditable() ? "true" : "false") + ",";
+ json += "\"linkUrl\":\"" + JsonEscape(params->GetLinkUrl().ToString()) + "\",";
+ json += "\"sourceUrl\":\"" + JsonEscape(params->GetSourceUrl().ToString()) + "\",";
+ json += "\"selectionText\":\"" + JsonEscape(params->GetSelectionText().ToString()) + "\",";
+ json += "\"misspelledWord\":\"" + JsonEscape(params->GetMisspelledWord().ToString()) + "\",";
+ json += "\"items\":" + SerializeMenuModel(model);
+ json += "}";
+
+ std::vector p(4 + json.size());
+ for (int i = 0; i < 4; ++i) p[i] = (id >> (8 * (3 - i))) & 0xff;
+ std::memcpy(p.data() + 4, json.data(), json.size());
+ SendFrame(slot_->browser_id, kOpContextMenu, p.data(),
+ static_cast(p.size()));
+ return true; // we display it
+ }
+
+ // The menu is gone for a reason other than a pick (page navigated, browser
+ // destroyed). Drop our pending entry — CEF has already invalidated the
+ // callback, so answering it later would be a use-after-free.
+ void OnContextMenuDismissed(CefRefPtr,
+ CefRefPtr) override {
+ if (!slot_) return;
+ std::lock_guard lock(slot_->surface_mutex);
+ slot_->context_menus.clear();
+ }
// CefDownloadHandler: allow downloads (CEF blocks them without a handler) and
// notify the host. Continue with an empty path + show_dialog so the user picks
@@ -2402,6 +2519,29 @@ void SetMediaContentSetting(const std::shared_ptr& slot,
// 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.
+// Answer a Flutter-drawn context menu. commandId 0 means dismissed: CEF requires
+// the callback be answered exactly once either way, so a dismissal must Cancel
+// rather than simply drop the entry — otherwise the page's menu logic hangs and
+// the next right-click is ignored.
+void DoContextMenuCommand(const std::shared_ptr& slot, uint32_t id,
+ uint32_t command) {
+ CEF_REQUIRE_UI_THREAD();
+ CefRefPtr cb;
+ {
+ std::lock_guard lock(slot->surface_mutex);
+ auto it = slot->context_menus.find(id);
+ if (it == slot->context_menus.end()) return; // already answered/dismissed
+ cb = it->second;
+ slot->context_menus.erase(it);
+ }
+ if (!cb) return;
+ if (command == 0) {
+ cb->Cancel();
+ } else {
+ cb->Continue(static_cast(command), EVENTFLAG_NONE);
+ }
+}
+
void DoMediaResponse(const std::shared_ptr& slot, uint32_t id, bool allow,
bool remember) {
CEF_REQUIRE_UI_THREAD();
@@ -2946,6 +3086,18 @@ void IpcReadLoop() {
base::BindOnce(&DoMediaResponse, slot, id, allow, remember));
break;
}
+ case kOpContextMenuCommand: {
+ // {u32 id}{u32 commandId} — the user picked an item in the Flutter-drawn
+ // context menu (commandId 0 = dismissed without choosing). Chromium runs
+ // the command, so behaviour matches Chrome exactly.
+ if (!slot) break;
+ if (plen < 8) break;
+ uint32_t id = ReadU32BE(p);
+ uint32_t command = ReadU32BE(p + 4);
+ CefPostTask(TID_UI,
+ base::BindOnce(&DoContextMenuCommand, slot, id, command));
+ break;
+ }
case kOpSetMediaSetting: {
// {u8 value} — change this site's remembered camera/mic decision.
if (!slot) break;
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 54e5af6..02a16ea 100644
--- a/packages/flutter_cef_platform_interface/lib/src/cef_events.dart
+++ b/packages/flutter_cef_platform_interface/lib/src/cef_events.dart
@@ -252,3 +252,131 @@ class CefCookie {
String toString() => 'CefCookie($name=$value; domain=$domain path=$path'
'${secure ? ' secure' : ''}${httpOnly ? ' httpOnly' : ''})';
}
+
+/// One row in a page context menu, as Chromium built it.
+///
+/// Campus draws these; Chromium executes the chosen [commandId]. That split is
+/// deliberate — [enabled] and [checked] come from Chromium's own menu model, so
+/// "Paste" greys out with an empty clipboard and the spellcheck block carries
+/// live dictionary suggestions without Campus deriving any of it.
+class CefContextMenuItem {
+ const CefContextMenuItem({
+ required this.type,
+ required this.label,
+ required this.commandId,
+ required this.enabled,
+ required this.checked,
+ this.items = const [],
+ });
+
+ factory CefContextMenuItem.fromJson(Map json) {
+ final rawItems = json['items'];
+ return CefContextMenuItem(
+ type: switch (json['type']) {
+ 'separator' => CefContextMenuItemType.separator,
+ 'check' => CefContextMenuItemType.check,
+ 'radio' => CefContextMenuItemType.radio,
+ 'submenu' => CefContextMenuItemType.submenu,
+ _ => CefContextMenuItemType.command,
+ },
+ label: (json['label'] as String?) ?? '',
+ commandId: (json['commandId'] as num?)?.toInt() ?? 0,
+ enabled: (json['enabled'] as bool?) ?? true,
+ checked: (json['checked'] as bool?) ?? false,
+ items: rawItems is List
+ ? rawItems
+ .whereType
),
),
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),
@@ -404,6 +463,7 @@ and committed text — including emoji — should appear intact.
// exclusive with a named profile, so only request it when none
// is active.
enableCdp: _profile == null,
+ ),
),
),
],
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 6183241..51d63b5 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -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:
@@ -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:
@@ -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:
From 5ea91b55e787bc15bd5f4f9bcbe846b47fc9bd7b Mon Sep 17 00:00:00 2001
From: wenkaifan0720
Date: Thu, 6 Aug 2026 20:40:43 -0700
Subject: [PATCH 3/3] feat(osr): inspect-at-point DevTools, view-source, and a
free opcode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three follow-ups from driving the context menu in a real app.
DevTools inspect point. showDevTools now takes an optional page-DIP point and
passes it to ShowDevTools(inspect_element_at), so a host can open DevTools
already inspecting the right-clicked element — what "Inspect" means in a
browser. Empty payload still means "just open", so the existing call is
unchanged. DevTools is a REAL window even though the page is windowless, which
is why this works at all where view-source does not.
view-source is judged by what it wraps. OnBeforeBrowse refused `view-source:`
outright (not in the allowlist, not `about:`), so Chromium's own View Page
Source silently did nothing — no error, no log. Viewing the source of a page you
were already allowed to LOAD grants no new reach: it renders bytes as text and
executes nothing. So `view-source:X` is allowed iff X's own scheme is allowed.
Verified: `view-source:https://…` renders, `view-source:file:///etc/hosts` is
still refused. Chromium rejects nested `view-source:view-source:` upstream, so
one unwrap is the whole story.
(The context-menu ROW for view-source is still dropped host-side: CEF's default
handler for it wants a new tab, and a windowless browser has no tab strip. The
scheme working is what lets an agent — or a future "open source in a new tile" —
reach it.)
Opcode collision. kOpContextMenu was 0x20, which kOpNavigate already uses. The
two travel in opposite directions so dispatch happened to work, but it was a
trap for the next reader; moved to 0x40.
Co-Authored-By: Claude Opus 5
---
lib/src/cef_web_controller.dart | 14 ++++-
.../macos/Classes/CefWebSession.swift | 15 ++++-
.../macos/Classes/FlutterCefPlugin.swift | 8 ++-
.../flutter_cef_macos/native/cef_host/main.mm | 55 +++++++++++++++----
4 files changed, 77 insertions(+), 15 deletions(-)
diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart
index 2f75d1b..461aa2b 100644
--- a/lib/src/cef_web_controller.dart
+++ b/lib/src/cef_web_controller.dart
@@ -915,8 +915,18 @@ class CefWebController {
}
/// Open Chromium's DevTools for this page in a separate window.
- Future 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 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
diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
index 5ecc8c2..6b8183d 100644
--- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
+++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
@@ -52,7 +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 = 0x20
+ 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
@@ -570,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))
diff --git a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
index 41a12a4..859dbe3 100644
--- a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
+++ b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
@@ -240,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.
diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm
index 079db49..b57d0aa 100644
--- a/packages/flutter_cef_macos/native/cef_host/main.mm
+++ b/packages/flutter_cef_macos/native/cef_host/main.mm
@@ -135,7 +135,7 @@
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 kOpContextMenu = 0x20; // {u32 id}{utf8 json} right-click in the page: Chromium's OWN menu model + params, for the host to draw in Flutter (OSR has no window to put a native menu in)
+constexpr uint8_t kOpContextMenu = 0x40; // {u32 id}{utf8 json} right-click in the page: Chromium's OWN menu model + params, for the host to draw in Flutter (OSR has no window to put a native menu in)
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,7 +161,7 @@
constexpr uint8_t kOpImeSetComp = 0x30; // {utf8 text} IME composition update
constexpr uint8_t kOpImeCommit = 0x31; // {utf8 text} commit composed text
constexpr uint8_t kOpImeCancel = 0x32; // {} cancel composition
-constexpr uint8_t kOpShowDevTools = 0x33; // {} open DevTools in a window
+constexpr uint8_t kOpShowDevTools = 0x33; // {} or {u32 x}{u32 y} open DevTools in a window; with a point, opened INSPECTING the element there (the right-click "Inspect" path)
constexpr uint8_t kOpLoadTrusted = 0x34; // {utf8 url} host content-load, exempt from allowlist
constexpr uint8_t kOpSetVisible = 0x35; // {u8 visible} -> CefBrowserHost::WasHidden(!visible)
constexpr uint8_t kOpResolveTargetId = 0x36; // {} resolve this browser's CDP targetId (CEF-2b) -> kOpTargetId
@@ -1952,9 +1952,30 @@ bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame,
colon == std::string::npos ? std::string() : url.substr(0, colon);
std::transform(scheme.begin(), scheme.end(), scheme.begin(),
[](unsigned char c) { return std::tolower(c); });
- // `about:` (blank placeholder) is always allowed; anything else must be
- // in the host allowlist or the navigation is refused.
- if (scheme != "about" && g_allowed_schemes.count(scheme) == 0) {
+ // `view-source:` is judged by what it wraps: viewing the source of a
+ // page you were already allowed to LOAD grants no new reach (it renders
+ // bytes as text and runs nothing), whereas refusing it silently breaks
+ // Chromium's own View Page Source menu command. `view-source:file:///…`
+ // stays refused, because `file` is not in the allowlist.
+ //
+ // Nesting is not recursive in Chromium (`view-source:view-source:` is
+ // rejected upstream), so one unwrap is the whole story.
+ if (scheme == "view-source") {
+ const std::string inner = url.substr(colon + 1);
+ const size_t inner_colon = inner.find(':');
+ std::string inner_scheme = inner_colon == std::string::npos
+ ? std::string()
+ : inner.substr(0, inner_colon);
+ std::transform(inner_scheme.begin(), inner_scheme.end(),
+ inner_scheme.begin(),
+ [](unsigned char c) { return std::tolower(c); });
+ if (g_allowed_schemes.count(inner_scheme) == 0) {
+ return true; // cancel — the wrapped scheme is not permitted
+ }
+ } else if (scheme != "about" &&
+ g_allowed_schemes.count(scheme) == 0) {
+ // `about:` (blank placeholder) is always allowed; anything else must
+ // be in the host allowlist or the navigation is refused.
return true; // cancel
}
}
@@ -2753,14 +2774,21 @@ void DoDeleteCookie(const std::shared_ptr& slot, const std::string& url,
if (mgr) mgr->DeleteCookies(url, name, nullptr);
}
-void DoShowDevTools(const std::shared_ptr& slot) {
+void DoShowDevTools(const std::shared_ptr& slot, int inspect_x,
+ int inspect_y) {
if (!slot->browser) return;
// Windowed DevTools (default CefWindowInfo is windowed) — the OSR host can
// still host a real window. null client lets CEF manage it.
+ //
+ // A non-negative point opens DevTools already inspecting the element there,
+ // which is what "Inspect" from a right-click means. DevTools is a real window
+ // (unlike the page itself), so nothing here depends on OSR having one.
CefWindowInfo window_info;
CefBrowserSettings settings;
- slot->browser->GetHost()->ShowDevTools(window_info, nullptr, settings,
- CefPoint());
+ const CefPoint at = (inspect_x >= 0 && inspect_y >= 0)
+ ? CefPoint(inspect_x, inspect_y)
+ : CefPoint();
+ slot->browser->GetHost()->ShowDevTools(window_info, nullptr, settings, at);
}
// CEF-2b: resolve a browser's CDP targetId so the Swift relay can scope an agent's
@@ -3210,10 +3238,17 @@ void IpcReadLoop() {
CefPostTask(TID_UI, base::BindOnce(&DoImeCommitText, slot, text));
break;
}
- case kOpShowDevTools:
+ case kOpShowDevTools: {
if (!slot) break;
- CefPostTask(TID_UI, base::BindOnce(&DoShowDevTools, slot));
+ // Back-compat: an empty payload still means "just open DevTools".
+ int ix = -1, iy = -1;
+ if (plen >= 8) {
+ ix = static_cast(ReadU32BE(p));
+ iy = static_cast(ReadU32BE(p + 4));
+ }
+ CefPostTask(TID_UI, base::BindOnce(&DoShowDevTools, slot, ix, iy));
break;
+ }
case kOpResolveTargetId:
if (!slot) break;
CefPostTask(TID_UI, base::BindOnce(&DoResolveTargetId, slot));