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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -452,6 +458,7 @@ class CefWebController {
Set<String>? 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.
Expand All @@ -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<int?>.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,
Expand Down Expand Up @@ -779,13 +790,18 @@ class CefWebController {
Future<void> 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<void> 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.
Expand Down
8 changes: 8 additions & 0 deletions lib/src/cef_web_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand All @@ -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).
///
Expand Down Expand Up @@ -251,6 +258,7 @@ class _CefWebViewState extends State<CefWebView>
try {
final id = await _controller.create(
url: widget.url,
html: widget.html,
width: w,
height: h,
dpr: dpr,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class CefProfileHost {
// processGone) instead of silently mis-parsing frames into frozen/blank tiles; the
// skew vectors are FLUTTER_CEF_HOST overrides, stale from-source builds, and stale
// embedded copies (the content-hash fetch can't drift on the normal path).
static let protocolVersion: UInt8 = 2
static let protocolVersion: UInt8 = 3

// Profile identity / config.
let profileId: String
Expand Down
15 changes: 14 additions & 1 deletion packages/flutter_cef_macos/native/cef_host/main.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HostClient> 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
Expand Down
21 changes: 21 additions & 0 deletions test/cef_web_view_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -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: '<h1>hello</h1>',
)));
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)), '<h1>hello</h1>');
// 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(
Expand Down
Loading