diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index aa29d70..a6733dd 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -440,6 +440,12 @@ class CefWebController { /// Spawn the renderer for [url] at [width]×[height] logical px. Returns the /// [Texture] id to display, or null on failure. /// + /// Pass [html] instead of a real [url] to create the browser DIRECTLY on an + /// authored document (host-trusted, allowlist-exempt — the same content + /// [loadHtmlString] loads, but in ONE step at create, so there is no + /// about:blank → later loadHtmlString race). [html] wins when both are given; + /// [url] should be `about:blank` in that case. + /// /// Idempotent under concurrency: if a session already exists ([isCreated]) the /// existing [textureId] is returned, and if a create() is already in flight /// this call adopts it (same future, no second spawn) — [url]/[width]/[height] @@ -452,6 +458,7 @@ class CefWebController { Set? allowedSchemes, bool enableCdp = false, bool agentControl = false, + String? html, }) { // The TCP enableCdp+named-profile combination is rejected because CDP-over-TCP // is an unauthenticated localhost port that could read the shared cookie jar. @@ -463,8 +470,12 @@ class CefWebController { 'unauthenticated localhost port that could read the shared cookie jar). ' 'Use agentControl for a private CDP-over-pipe channel instead.'); if (textureId != null) return Future.value(textureId); + // create-with-html: a base64 data: URL (as loadHtmlString builds). cef_host + // arms the trusted-load exemption for a data:/file: create URL, so this + // renders the authored doc as the browser's first (and only) page. + final createUrl = html != null ? _htmlDataUrl(html) : url; return _createInFlight ??= _createSession( - url: url, + url: createUrl, width: width, height: height, dpr: dpr, @@ -779,13 +790,18 @@ class CefWebController { Future imeCancelComposition() => _channel.invokeMethod('imeCancelComposition', {'sessionId': sessionId}); + /// The `data:` URL an HTML string loads as (base64, utf-8). Shared by + /// [loadHtmlString] and create-with-html so the two produce identical content. + static String _htmlDataUrl(String html) => + 'data:text/html;charset=utf-8;base64,' + '${base64Encode(const Utf8Encoder().convert(html))}'; + /// Load an HTML string. (`baseUrl` is accepted for API familiarity but not yet /// honoured — relative URLs resolve against the `data:` document.) /// /// Host-trusted content: rendered regardless of the view's `allowedSchemes`. Future loadHtmlString(String html, {String? baseUrl}) { - final encoded = base64Encode(const Utf8Encoder().convert(html)); - return _loadTrusted('data:text/html;charset=utf-8;base64,$encoded'); + return _loadTrusted(_htmlDataUrl(html)); } /// Load a local file by absolute path. diff --git a/lib/src/cef_web_view.dart b/lib/src/cef_web_view.dart index c81f5d0..031efe3 100644 --- a/lib/src/cef_web_view.dart +++ b/lib/src/cef_web_view.dart @@ -65,6 +65,7 @@ class CefWebView extends StatefulWidget { this.profile, this.renderScale, this.onFind, + this.html, }) : assert(!(enableCdp && !agentControl && profile != null && profile != ''), 'enableCdp cannot be combined with a named profile: CDP-over-TCP ' 'exposes an unauthenticated localhost port that could read the ' @@ -74,6 +75,12 @@ class CefWebView extends StatefulWidget { /// Page to load. Changing it on an existing view navigates. final String url; + /// Authored HTML to create the browser DIRECTLY on (host-trusted, one-step — + /// no about:blank + later load). When set, the browser's first page IS this + /// document; [url] should be `about:blank`. Changing it after create does NOT + /// re-navigate (call `controller.loadHtmlString` for a live update). + final String? html; + /// Optional external controller (to script the view). If null, one is created /// and owned internally (and disposed with the view). /// @@ -251,6 +258,7 @@ class _CefWebViewState extends State try { final id = await _controller.create( url: widget.url, + html: widget.html, width: w, height: h, dpr: dpr, diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift index edfb865..7914549 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 = 2 + static let protocolVersion: UInt8 = 3 // Profile identity / config. let profileId: String diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index f4b7188..a413269 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -105,7 +105,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 = 2; +constexpr uint8_t kCefHostProtocolVersion = 3; // ---- Opcodes ---- constexpr uint8_t kOpPresent = 0x01; @@ -1553,6 +1553,19 @@ void DoCreateBrowser(uint32_t wire_id, int w, int h, double dpr, slot->pending_nav_url = url; create_url = "about:blank"; } + // create-with-html/file: a data:/file: create URL is host-trusted content + // injection (the same schemes loadHtmlString/loadFile use via kOpLoadTrusted), + // and can NEVER arise from an untrusted page navigation — the scheme allowlist + // refuses data:/file: in OnBeforeBrowse. So arm the trusted-load exemption for + // the INITIAL load here, exactly as DoNavigateTrusted does, letting a consumer + // create the browser directly on its authored document in ONE step (no + // about:blank + later loadHtmlString, which raced blank). Identical trust model + // to a post-create loadTrusted; only the timing (at create) differs. + if (!g_allowed_schemes.empty() && + (create_url.rfind("data:", 0) == 0 || + create_url.rfind("file:", 0) == 0)) { + slot->trusted_pending.insert(create_url); + } CefRefPtr client = new HostClient(slot); // H3: ASYNC create. CreateBrowserSync BLOCKS this (the single CEF UI) thread until // the renderer + GPU/Viz accelerated-surface handshake completes — so a burst of diff --git a/test/cef_web_view_test.dart b/test/cef_web_view_test.dart index 3082fb9..4a00f30 100644 --- a/test/cef_web_view_test.dart +++ b/test/cef_web_view_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -63,6 +65,25 @@ void main() { expect(args['height'], 240); }); + testWidgets('create(html:) creates directly on the authored doc as a ' + 'trusted data: URL (one step, no about:blank)', (tester) async { + await tester.pumpWidget(boxed(const CefWebView( + url: 'about:blank', + html: '

hello

', + ))); + await tester.pumpAndSettle(); + final creates = callsTo('create'); + expect(creates, hasLength(1)); + final url = (creates.single.arguments as Map)['url'] as String; + expect(url, startsWith('data:text/html;charset=utf-8;base64,'), + reason: 'the browser is born on the authored doc, not about:blank'); + // Round-trips to the original html. + final b64 = url.substring('data:text/html;charset=utf-8;base64,'.length); + expect(utf8.decode(base64Decode(b64)), '

hello

'); + // No separate loadTrusted — the doc rode the create. + expect(callsTo('loadTrusted'), isEmpty); + }); + testWidgets('resizes the session when the layout changes', (tester) async { const key = ValueKey('v'); await tester.pumpWidget(