Embed a live Chromium browser (via the Chromium Embedded Framework) as a Flutter widget — rendered into a Texture, so it composites, transforms, clips, and zooms like any other widget, and keeps rendering even when off-screen / not focused. Pointer, scroll, and trackpad two-finger pans are forwarded by coordinate (pans are caught even when an ancestor opts into Flutter's trackpad gesture API, as canvas hosts do), and keyboard input reaches the page as real keydown → keypress → keyup events (Enter activates a focused button / submits a form, Space toggles a checkbox) — including platform IME composition for CJK / emoji and the ⌃⌘Space emoji picker. Text input is bound to the hosting FlutterView (as EditableText does), so it works in multi-view / multi-window apps; the page cursor drives a MouseRegion.
Status: experimental, macOS 12+ only (CEF 144 runtime floor). Real Chromium (any site — JS/CSS/WebGL/video). Multi-process by default (GPU-accelerated OSR —
OnAcceleratedPaintGPU compositing into a shared IOSurface, Retina-crisp; renderer/utility crashes isolated, so heavy SPAs like Google sign-in render and survive);CEF_MULTI_PROCESS=OFF packages/flutter_cef_macos/native/build_cef_host.shfor the simpler single-process build. No mobile (iOS bans third-party engines); desktop by nature.
import 'package:flutter_cef/flutter_cef.dart';
CefWebView(url: 'https://flutter.dev')
// Opt into a persistent, shared profile: the login (cookies + storage) survives
// relaunch and is shared by every view with the same profile name. Omit
// `profile:` (the default) for an ephemeral, throwaway session. See "Profiles".
CefWebView(url: 'https://app.example.com', profile: 'work')Drive and observe it via a controller:
final c = CefWebController();
CefWebView(url: startUrl, controller: c);
// navigation + history + loading
c.navigate('https://example.com');
c.reload(); c.stop(); c.goBack(); c.goForward();
c.loadHtmlString('<h1>hi</h1>'); c.loadFile('/abs/page.html');
c.setZoomLevel(1.0); // 1.2^level (0 = 100%)
c.find('term'); c.stopFind(); // results on c.onFindResult
// JavaScript: run, return a value, and talk back from the page
c.executeJavaScript('document.body.style.zoom = 1.2');
final title = await c.runJavaScriptReturningResult('document.title'); // String/num/List/Map
c.addJavaScriptChannel('Native', onMessageReceived: (m) => print('JS says $m'));
// then in the page: window.Native.postMessage('hello')
// page state (ValueListenables) + lifecycle/dialog callbacks
c.isLoading; c.url; c.title; c.canGoBack; c.canGoForward;
c.onPageStarted = (u) {}; c.onPageFinished = (u) {}; c.onProgress = (p) {};
c.onUrlChange = (u) {}; c.onCreateWindow = (u) => c.navigate(u); // target=_blank
c.onLoadError = (e) => print('${e.errorCode} ${e.url}');
c.onConsoleMessage = (m) => print(m.message);
c.onJavaScriptConfirmDialog = (req) async => askUser(req.message); // alert/confirm/prompt
// cookies + scroll + storage
c.setCookie(url: 'https://example.com/', name: 'sid', value: 'abc');
final cookies = await c.getCookies(); // read/enumerate; pass url: to scope
c.deleteCookie(url: 'https://example.com/', name: 'sid'); c.clearCookies();
c.scrollTo(0, 200); c.scrollBy(0, -50); await c.getScrollPosition();
c.clearLocalStorage(); await c.getTitle(); await c.getUserAgent();
c.onDownload = (suggestedName) {}; // downloads land in ~/Downloads
// open the Chrome DevTools inspector for this view in its own window
c.openDevTools();
// open the macOS emoji & symbols picker over the focused page (same as ⌃⌘Space)
c.showEmojiPicker();See example/ for a full browser chrome (URL bar, back/forward/reload, loading
bar, live title).
Dart CefWebView + CefWebController (MethodChannel "flutter_cef")
→ macOS plugin (FlutterCefPlugin / CefWebSession):
allocates a global IOSurface + CVPixelBuffer, registers a FlutterTexture,
spawns one cef_host.app per view, relays input + cursor over a Unix socket
→ cef_host.app: CEF windowless (OSR), multi-process — the GPU/Viz process
composites the page and hands OnAcceleratedPaint a shared-texture
IOSurface, which cef_host copies into the host-shared IOSurface →
"present" → the texture re-samples. (OnPaint software blit is the
single-process fallback.)
Same pattern JCEF (JetBrains) and CefSharp use to render Chromium into a non-native toolkit — adapted to Flutter's Texture + IOSurface.
CEF (~200 MB) is fetched, not vendored. Build the renderer once:
# The macOS implementation lives in packages/flutter_cef_macos.
cd packages/flutter_cef_macos
native/build_cef_host.sh # fetches CEF + builds cef_host.app
export FLUTTER_CEF_HOST="$PWD/native/cef_host/build/cef_host.app/Contents/MacOS/cef_host"
cd ../../example && flutter run -d macosFor a shipped .app (no dev env var), cef_host.app must live in your bundle's
Contents/Frameworks and be signed by your build. The plugin resolves it there
automatically ($FLUTTER_CEF_HOST → pod resources → Contents/Frameworks →
Contents/Helpers). After flutter build macos, run:
packages/flutter_cef_macos/tool/bundle_cef_host.sh "build/macos/.../YourApp.app" "" "<signing-identity>"or wire it as a Run Script build phase on your Runner target (snippet in
packages/flutter_cef_macos/tool/bundle_cef_host.sh) so it runs before Xcode's
code-sign phase. Your host
app must not be App-Sandboxed (CEF spawns the helper, shares a global
IOSurface, writes a cache); entitlements need
com.apple.security.cs.disable-library-validation + JIT — see
example/macos/Runner/*.entitlements for the reference set. Sign everything with
one identity (framework → cef_host → app, inside-out) and library validation can
stay on.
Privacy usage descriptions (required in YOUR app's Info.plist). cef_host
is spawned as a child of your app, so macOS TCC attributes the page's
privacy-sensitive hardware access to your app (the responsible process) and reads
the usage string from your app's Info.plist — not cef_host's. Without it,
the process is SIGABRT'd the instant a page touches the hardware (e.g. Google
sign-in probing WebAuthn/FIDO security keys reaches Bluetooth). Declare at least:
<key>NSBluetoothAlwaysUsageDescription</key><string>A web page is requesting Bluetooth to use a security key or passkey.</string>
<key>NSCameraUsageDescription</key><string>A web page is requesting camera access.</string>
<key>NSMicrophoneUsageDescription</key><string>A web page is requesting microphone access.</string>(example/macos/Runner/Info.plist carries these.) Hardened-runtime apps that want
the access to actually function (not just avoid the crash) also need the
matching com.apple.security.device.{bluetooth,camera,microphone} entitlements;
with them absent the access is denied gracefully, which is enough to keep
password sign-in working when WebAuthn isn't supported.
flutter_cef embeds a full Chromium that runs arbitrary web content with JIT.
Treat any page you load as untrusted code. The security posture is driven by one
build flag, CEF_HOST_ADHOC (default ON):
CEF_HOST_ADHOC=ON (default, dev/CI) |
CEF_HOST_ADHOC=OFF (signed release) |
|
|---|---|---|
| Chromium renderer/GPU sandbox | off (no_sandbox=true) |
on — helper calls CefScopedSandboxContext |
| Mach-port peer validation | bypassed (env var + --disable-features) |
enforced |
| Cookie-at-rest encryption | mock keychain / password-store=basic |
real Keychain / OSCrypt |
get-task-allow entitlement |
present (local debugging) | absent (entitlements.release.plist) |
The OFF posture only validates under correct inside-out Developer-ID
signing of the cef_host tree (deepest helper → libcef_sandbox.dylib + CEF
framework → host, depth-first, Hardened Runtime + trusted timestamp). Build it
with CEF_HOST_ADHOC=OFF CODESIGN_ID="<Developer ID>" packages/flutter_cef_macos/native/build_cef_host.sh,
or — when bundled into a host app — let the app's own signing re-sign the tree
with those entitlements. Ad-hoc/dev builds run unsandboxed by necessity (the
sandbox can't validate without proper signing), which is why ON is the default.
Other always-on protections:
- Hardened-runtime relaxations. The signed-release set
(
entitlements.release.plist) is intentionally minimal: onlyallow-jit(CEF's V8 JIT, via MAP_JIT) plusdevice.bluetooth(caBLE passkeys). The dev/ad-hoc set (entitlements.plist) additionally relaxesdisable-library-validationandallow-unsigned-executable-memoryfor convenience, but neither is load-bearing under correct inside-out single-identity signing, so release drops both (see the hardening backlog andentitlements.release.plistfor the rationale). - Navigation scheme allowlist (
CefWebView(allowedSchemes:)) — gate which schemes a page may navigate to (main-frame nav, programmaticnavigate(), clicks, redirects); host content-injection (loadHtmlString/loadFile) is exempt. Off by default (allow-all). This is a navigation policy knob, not a content-isolation boundary: it gates main-frame navigations only — it does not restrict subframes, subresources, orfetch/XHR/<img>/ws:loads, so a page can still issue requests over any scheme. Use it to constrain where the top-level frame can go, not to sandbox what content can load. - JS channel names are validated as JS identifiers before injection, and
runJavaScriptReturningResultexpects a single expression from trusted app code. - Per-user, per-process CEF cache (under the 0700 temp dir, not a fixed
world-readable
/tmppath) and a randomized control-socket name — a namedprofile:instead uses a stable 0700 dir under Application Support (see Profiles).
This is a competent CEF embedding with honestly-labeled deferrals, not a fully
hardened browser. Notable items still open — see
specs/persistent-profiles/SECURITY-REVIEW.md
for the full punch list (file:line) and prioritization:
- Per-product keychain item name for OSCrypt (true at-rest isolation from
other CEF apps) — needs a from-source CEF build to override the hardcoded
"Chromium Safe Storage"name (see Secrets at rest). - Browser-process auto-respawn — a
cef_hostcrash surfaces and unbricks the profile, but does not yet automatically restart it. - Socket peer authentication — the control socket is first-connector-wins
with no
getpeereid()check on the spawned process.
By default a CefWebView is ephemeral: cookies, localStorage, and the rest
of the page's storage live in a throwaway in-memory profile that is discarded
when the view is disposed (and the host process exits). Nothing persists across
relaunch. This is the historical behaviour and stays the default.
Pass profile: to opt into a persistent, shared profile:
CefWebView(url: 'https://app.example.com', profile: 'work')
// or, when scripting a view yourself:
final c = CefWebController(profile: 'work');
CefWebView(url: startUrl, controller: c);- Persistent. A named profile is stored on disk at
<Application Support>/<bundleId>/flutter_cef/profiles/<name>(the directory is created0700, owner-only, and the profile name is sanitized to[A-Za-z0-9._-]). CEF is started withpersist_session_cookieson, so a login survivescef_hostand host-app relaunch. - Shared. Every view constructed with the same non-null
profileis served by onecef_hostprocess with one cookie jar — they share one login. Cookie writes are therefore process-wide:clearCookies()/deleteCookie()clear the cookie for all views in the profile, by design.
Cookies (and Chromium's password / OSCrypt-encrypted data) are only encrypted
at rest under a signed release build (CEF_HOST_ADHOC=OFF — see
Security), where cef_host uses the real macOS Keychain /
OSCrypt. The encryption key is stored in the default OSCrypt login-Keychain
item named "Chromium Safe Storage" (you'll see a one-time Keychain
prompt the first time a profile is created). This item is shared with
every CEF/Chromium-based app that resolves the same default name — it is not
ACL-scoped to one signing identity, so it does not isolate our key from other
CEF apps the user has approved. At-rest protection therefore comes from
FileVault (full-disk encryption) plus the login keychain being locked
when the user is logged out — not from a per-binary keychain ACL. True per-app
isolation needs a per-product keychain item name, but that name is a
hardcoded literal baked into the prebuilt CEF framework; overriding it requires
a from-source CEF build and is tracked as a follow-up (see the hardening
backlog).
The default ad-hoc / dev build (CEF_HOST_ADHOC=ON) has only a mock keychain
— it cannot encrypt cookies at rest. To avoid silently writing a "persistent"
login to a plaintext on-disk store, an ad-hoc host downgrades a named profile
to ephemeral (and logs a warning) rather than persisting it. Set
FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1 in the environment to override this and
persist under the mock keychain anyway (dev convenience only — do not ship it).
The downgrade leaks nothing: the refusal happens before any browser is created,
so nothing is ever written to the persistent directory.
Two further caveats even under a signed build: localStorage and IndexedDB are
not encrypted by OSCrypt (they sit in the profile directory as plaintext —
FileVault is again the backstop), and CDP (enableCdp) is incompatible with a
named profile: CDP is an unauthenticated localhost port that could read the
shared cookie jar, so combining the two is rejected (the constructor asserts in
debug, and the native side refuses the create).
Let an external CDP client (e.g. agent-browser, which is Playwright-based) drive a live, logged-in tile — without a duplicate browser, without losing state, and without an open debug port.
// 1. Create the view in agent-control mode (CDP over a private inherited pipe,
// not a TCP port — so it's permitted on a named profile, unlike enableCdp).
CefWebView(controller: c, profile: 'work', agentControl: true)
// 2. When you want an agent to drive THIS tile, broker a token-gated, per-tile
// loopback CDP endpoint and hand it to the agent:
final grant = await c.enableAgentControl(); // -> {wsUrl, token, port}
// e.g. agent-browser --cdp <grant.port> open https://example.com
await c.disableAgentControl(); // revoke when doneagentControl: true launches cef_host so Chromium speaks CDP over an inherited
pipe (--remote-debugging-pipe, NUL-framed JSON on fds 3/4) instead of a TCP port —
there is no listening debug port. enableAgentControl() then starts a small
loopback HTTP+WebSocket relay that bridges a standard CDP client to that pipe and
returns the endpoint.
Trust model. The relay is the only way in, and it is deliberately narrow:
- Per-tile opt-in. Nothing is exposed until you call
enableAgentControl(); the relay exists only while the grant is active and is torn down ondisableAgentControl(), tile dispose, or host shutdown. - Loopback + ephemeral + single-client. It binds
127.0.0.1on an OS-assigned port and accepts one client at a time. The returnedtokenis validated if a client presents it; agent-browser/Playwright can't attach one, so for those the controls above are the gate. (This is strictly better than raw Chrome's fixed, always-open, multi-client--remote-debugging-port. A same-UID process that wins a sub-second race on the ephemeral port before the agent connects is the documented residual — and on macOS same-UID is already game-over via the Keychain.) - Per-tile isolation. Tiles in a shared profile run in one
cef_hostprocess behind one browser-wide CDP pipe, so the relay enforces the boundary itself: a deny-by-default, fail-closed, flatten-only CDP Target-domain filter exposes the client only its own tile's target — sibling tiles are hidden (not inTarget.getTargets) and unreachable (attachToTarget,sendMessageToTarget,attachToBrowserTarget, foreign sessions are all refused).
Limits, by design. Per-tile CDP isolation within a shared browser context is
inherently partial — browser-context-wide CDP can't be scoped to one tile. So
browser-context/process-global domains are refused entirely: Storage.*,
Tracing.*, Memory.*, SystemInfo.*, Browser.* (except getVersion), and the
cookie/cache methods (Network.getAllCookies/clearBrowserCookies/…). The agent can
drive its tile's page (navigate, click, type, read DOM, run JS) but cannot read or
clear the shared cookie jar or touch sibling tiles. It can act with the tile's own
authenticated session for the tile's own origin — that is inherent to driving a
logged-in page. Strictly airtight CDP isolation would require a per-tile browser
context, which would un-share the login the shared profile exists to provide. First
cut: one agent-controlled tile per cef_host process (a second, different tile in
the same process is refused).
Known limitation: the IOSurface is single-buffered, so very fast-updating pages
can tear slightly under the compositor; double-buffering is planned. Working
today: multi-process, GPU-accelerated OSR render (on/off-screen,
HiDPI/Retina-crisp, GPU compositing via OnAcceleratedPaint, heavy SPAs render +
survive),
pointer/scroll/trackpad-pan/keyboard input, IME text input (CJK composition
- emoji, the candidate window tracked under the caret, and the ⌃⌘Space emoji
picker —
showEmojiPicker()) in single- and multi-view (multi-window) hosts (the connection carriesTextInputConfiguration.viewIdand is re-shown on every click, EditableText-style),<select>popups, page cursor; navigation + history, page-lifecycle events (start/finish/progress/url-change), new-window routing (onCreateWindow), loading/title/url/error/console state; JS dialogs (alert/confirm/prompt), a JS bridge (addJavaScriptChannel+runJavaScriptReturningResultoverCefMessageRouter),executeJavaScript; content zoom, find-in-page,loadHtmlString/loadFile, cookies (set/clear plus read/enumerate viagetCookies+deleteCookie), scroll, title/user-agent getters, downloads, and a Chrome DevTools inspector window (openDevTools).
Next:
- True zero-copy GPU render. Rendering is now GPU-accelerated:
OnAcceleratedPaint(GPU compositing) is on by default, multi-process and crash-isolated. The-67030that used to gate the GPU→browser handoff (Chromium 144 validating cef_host's ad-hoc signature) is cleared by disabling theMachPortRendezvous*PeerRequirementsfeatures — no Developer-ID signing needed. We still copy the GPU surface into the shared surface (cheap on unified-memory Macs, where compositing — not the copy — was the bottleneck). TRUE zero-copy — handing the GPU IOSurface to Flutter with no copy — needs cross-process Mach-port surface transfer (CEF's GPU surfaces aren't resolvable by global id from another process), and mostly helps discrete-GPU Macs and scenes with many simultaneously-animating webviews; deferred until measured. - Double-buffer the IOSurface to remove the residual tearing on fast-updating pages (JCEF's named-mutex 2-slot buffer is a good reference).
- The CEF feature tail that CefSharp/JCEF expose:
loadRequestwith custom headers / POST body,setUserAgent, request / resource interception, custom scheme handlers, a typed DevTools/CDP client (the inspector window already ships viaopenDevTools; this is the programmatic CDP surface), andCefPermissionHandler(WebRTC camera/mic prompts). - Windows / Linux — the package is federated (
flutter_cef+flutter_cef_platform_interface+flutter_cef_macos); a new platform is a siblingflutter_cef_<os>package. The CEF logic + IPC protocol are portable; each OS supplies its own host plugin + shared-texture / transport / sandbox glue. SeePORTING.mdfor the full contract and seam map.
Built on CEF. Patterns drawn from CEF's cefclient OSR sample, JCEF, and CefSharp.