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() + .map( + (e) => CefContextMenuItem.fromJson( + Map.from(e), + ), + ) + .toList(growable: false) + : const [], + ); + } + + final CefContextMenuItemType type; + final String label; + + /// Chromium's command id. Pass it back to run the command; 0 = dismissed. + final int commandId; + final bool enabled; + final bool checked; + + /// Children, for [CefContextMenuItemType.submenu]. + final List items; + + @override + String toString() => 'CefContextMenuItem($type, "$label", id: $commandId)'; +} + +enum CefContextMenuItemType { command, separator, check, radio, submenu } + +/// A right-click in a page: where it happened, what was under the cursor, and +/// the menu Chromium built for it. +class CefContextMenuRequest { + const CefContextMenuRequest({ + required this.id, + required this.x, + required this.y, + required this.editable, + required this.linkUrl, + required this.sourceUrl, + required this.selectionText, + required this.misspelledWord, + required this.items, + }); + + factory CefContextMenuRequest.fromJson(int id, Map json) { + final rawItems = json['items']; + return CefContextMenuRequest( + id: id, + x: (json['x'] as num?)?.toDouble() ?? 0, + y: (json['y'] as num?)?.toDouble() ?? 0, + editable: (json['editable'] as bool?) ?? false, + linkUrl: (json['linkUrl'] as String?) ?? '', + sourceUrl: (json['sourceUrl'] as String?) ?? '', + selectionText: (json['selectionText'] as String?) ?? '', + misspelledWord: (json['misspelledWord'] as String?) ?? '', + items: rawItems is List + ? rawItems + .whereType() + .map( + (e) => CefContextMenuItem.fromJson( + Map.from(e), + ), + ) + .toList(growable: false) + : const [], + ); + } + + /// Correlation id — answer with this, exactly once. + final int id; + + /// Where the click landed, in page (DIP) coordinates relative to the view. + final double x; + final double y; + + /// Whether the click was in an editable field (drives cut/paste relevance). + final bool editable; + + /// The link under the cursor, or empty. + final String linkUrl; + + /// The media source (image/video/audio) under the cursor, or empty. + final String sourceUrl; + + /// The current selection, or empty. + final String selectionText; + + /// The misspelled word under the cursor, or empty. + final String misspelledWord; + + final List items; + + @override + String toString() => + 'CefContextMenuRequest(#$id at $x,$y, ${items.length} items)'; +} diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart index c1412f6..1b590b6 100644 --- a/test/cef_web_controller_test.dart +++ b/test/cef_web_controller_test.dart @@ -1078,4 +1078,174 @@ void main() { reason: 'spacing only applies under contention — a single spawn returns ' 'immediately and never waits the gap'); }); + + // ── context menu ──────────────────────────────────────────────────────── + // + // The invariant under test is "answer exactly once". CEF requires the menu + // callback be continued or cancelled; a dropped answer wedges the page's menu + // handling so later right-clicks are silently ignored. So every path — chosen, + // dismissed, no handler, throwing handler — must produce one + // chooseContextMenu. + + Future sendContextMenu(String json, {int id = 1}) => + messenger.handlePlatformMessage( + 'flutter_cef', + const StandardMethodCodec().encodeMethodCall( + MethodCall('contextMenu', { + 'sessionId': 'cm', + 'id': id, + 'json': json, + }), + ), + (_) {}, + ); + + test('a chosen context-menu command is sent back', () async { + final c = CefWebController(sessionId: 'cm'); + await c.create(url: 'about:blank', width: 10, height: 10); + CefContextMenuRequest? seen; + c.onContextMenu = (req) async { + seen = req; + return 101; + }; + log.clear(); + + await sendContextMenu(jsonEncode({ + 'x': 12, + 'y': 34, + 'editable': true, + 'linkUrl': 'https://example.com/a', + 'sourceUrl': '', + 'selectionText': 'picked text', + 'misspelledWord': 'teh', + 'items': [ + { + 'type': 'command', + 'label': 'Back', + 'commandId': 100, + 'enabled': false, + 'checked': false, + }, + {'type': 'separator'}, + { + 'type': 'command', + 'label': 'Copy', + 'commandId': 101, + 'enabled': true, + 'checked': false, + }, + { + 'type': 'submenu', + 'label': 'Spelling', + 'commandId': 0, + 'enabled': true, + 'checked': false, + 'items': [ + { + 'type': 'command', + 'label': 'the', + 'commandId': 200, + 'enabled': true, + 'checked': false, + }, + ], + }, + ], + })); + + // Chromium's own state survives the round trip — Back disabled, the + // separator kept so grouping is drawable, the submenu nested. + expect(seen, isNotNull); + expect(seen!.x, 12); + expect(seen!.editable, isTrue); + expect(seen!.linkUrl, 'https://example.com/a'); + expect(seen!.selectionText, 'picked text'); + expect(seen!.misspelledWord, 'teh'); + expect(seen!.items, hasLength(4)); + expect(seen!.items[0].enabled, isFalse); + expect(seen!.items[1].type, CefContextMenuItemType.separator); + expect(seen!.items[3].type, CefContextMenuItemType.submenu); + expect(seen!.items[3].items.single.commandId, 200); + + final call = log.singleWhere((m) => m.method == 'chooseContextMenu'); + final args = (call.arguments as Map).cast(); + expect(args['id'], 1); + expect(args['commandId'], 101); + }); + + test('returning null dismisses rather than dropping the callback', () async { + final c = CefWebController(sessionId: 'cm'); + await c.create(url: 'about:blank', width: 10, height: 10); + c.onContextMenu = (_) async => null; + log.clear(); + + await sendContextMenu('{"items":[]}'); + + final args = (log + .singleWhere((m) => m.method == 'chooseContextMenu') + .arguments as Map) + .cast(); + expect(args['commandId'], 0); + }); + + test('no handler still answers (dismiss), so the menu is not wedged', + () async { + final c = CefWebController(sessionId: 'cm'); + await c.create(url: 'about:blank', width: 10, height: 10); + log.clear(); + + await sendContextMenu('{"items":[]}'); + + final args = (log + .singleWhere((m) => m.method == 'chooseContextMenu') + .arguments as Map) + .cast(); + expect(args['commandId'], 0); + }); + + test('a throwing handler is reported and still answers', () async { + final c = CefWebController(sessionId: 'cm'); + await c.create(url: 'about:blank', width: 10, height: 10); + c.onContextMenu = (_) async => throw StateError('boom'); + log.clear(); + + final errors = []; + final prior = FlutterError.onError; + FlutterError.onError = (d) => errors.add(d.exception); + try { + await sendContextMenu('{"items":[]}'); + } finally { + FlutterError.onError = prior; + } + + expect(errors.single, isA()); + final args = (log + .singleWhere((m) => m.method == 'chooseContextMenu') + .arguments as Map) + .cast(); + expect(args['commandId'], 0); + }); + + test('malformed menu json is reported and answered, never silent', () async { + final c = CefWebController(sessionId: 'cm'); + await c.create(url: 'about:blank', width: 10, height: 10); + c.onContextMenu = (_) async => 5; + log.clear(); + + final errors = []; + final prior = FlutterError.onError; + FlutterError.onError = (d) => errors.add(d.exception); + try { + await sendContextMenu('not json at all'); + } finally { + FlutterError.onError = prior; + } + + expect(errors, isNotEmpty); + final args = (log + .singleWhere((m) => m.method == 'chooseContextMenu') + .arguments as Map) + .cast(); + expect(args['commandId'], 0); + }); } From fbc1fd2b3ca4f5f60e71ebaf84b92ee931190967 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Thu, 6 Aug 2026 19:04:37 -0700 Subject: [PATCH 2/3] example: draw the page context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the seam end to end: Chromium's model arrives, a plain Material menu draws it at the click point, the chosen id goes back. Deliberately not styled — it shows the wiring, not a design. Chromium's enabled state is honored so Paste greys out with an empty clipboard without the host deriving anything. Co-Authored-By: Claude Opus 5 --- example/lib/main.dart | 62 ++++++++++++++++++++++++++++++++++++++++++- example/pubspec.lock | 24 ++++++++--------- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 13a492a..6dadeac 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -41,6 +41,9 @@ class _BrowserDemoState extends State { // 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; @@ -61,6 +64,57 @@ class _BrowserDemoState extends State { _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 _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( + context: context, + position: RelativeRect.fromRect( + Rect.fromLTWH(origin.dx, origin.dy, 1, 1), + Offset.zero & overlay.size, + ), + items: _menuEntries(req.items), + ); + } + + List> _menuEntries(List items) { + final out = >[]; + 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( + 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( + 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() { @@ -71,6 +125,9 @@ class _BrowserDemoState extends State { }); _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) { @@ -384,7 +441,9 @@ and committed text — including emoji — should appear intact.

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