From 580d3727f1d36e92434d89923eee92936d015001 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 15:23:58 -0700 Subject: [PATCH 1/5] fix(controller): harden the failure boundary + correct the agent-control security doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processGone now fails pending eval/cookie futures before notifying the consumer (a host crash is the likeliest failure; in-flight round-trips otherwise hang forever). - dispose() is idempotent (a second call returns early) — an externally-owned controller disposed twice no longer throws via the ValueNotifier asserts. - enableAgentControl tolerates a partial native reply (returns null, not a TypeError); its dartdoc now states the token is REQUIRED (matches CdpRelay's 401 gate), replacing the stale "token is not the gate" claim. - _handleJsDialog reports a throwing app handler via FlutterError.reportError (still fails closed) instead of swallowing it. - Document external-controller ownership on CefWebView.controller (you dispose what you pass — the single most likely resource leak). Adds tests for processGone-fails-pending, dispose-twice, and the nullable reply. Co-Authored-By: Claude Opus 4.8 --- lib/src/cef_web_controller.dart | 61 ++++++++++++++++++++++--------- lib/src/cef_web_view.dart | 7 +++- test/cef_web_controller_test.dart | 40 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 47f5882..9e68f59 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -241,8 +241,12 @@ class CefWebController { break; case 'processGone': // The native host dropped this session (crash, cache-lock loss, or a - // create that failed — reason 'createFailed'). The texture is dead; let the - // consumer react (show a reload affordance / recreate). + // create that failed — reason 'createFailed'). The texture is dead. Fail + // any in-flight eval/cookie round-trips FIRST — there is no host left to + // answer them, so they would otherwise hang forever (this is the single + // most likely failure) — then let the consumer react (reload / recreate). + _failPendingEvals('the cef_host process is gone'); + _failPendingCookies('the cef_host process is gone'); onProcessGone?.call(a['reason'] as String? ?? 'crashed'); break; case 'paintStalled': @@ -285,6 +289,15 @@ class CefWebController { } } + void _failPendingCookies(String reason) { + if (_cookiePending.isEmpty) return; + final pending = _cookiePending.values.toList(); + _cookiePending.clear(); + for (final c in pending) { + if (!c.isCompleted) c.completeError(StateError(reason)); + } + } + /// Deliver a JS-channel post. Payload is `"name:message"`. void _handleChannelMessage(String payload) { final i = payload.indexOf(':'); @@ -318,8 +331,16 @@ class CefWebController { default: await onJavaScriptAlertDialog?.call(req); } - } catch (_) { + } catch (e, st) { + // Fail closed (dismiss the dialog), but DON'T swallow the error — a throwing + // app dialog handler is a consumer bug they should see, not a silent dismiss. ok = false; + FlutterError.reportError(FlutterErrorDetails( + exception: e, + stack: st, + library: 'flutter_cef', + context: ErrorDescription('handling a JavaScript dialog from the page'), + )); } if (_disposed) return; // controller torn down while the callback awaited await _channel.invokeMethod('respondJsDialog', @@ -497,11 +518,14 @@ class CefWebController { /// `/json/version`). Idempotent: repeated calls return the same live endpoint. /// Tear down with [disableAgentControl]. /// - /// Security: vanilla agent-browser can't attach a secret to a CDP connection, so - /// the gate is the ephemeral loopback port + grant lifecycle + single active - /// client, not the token. The returned [token] is validated only **if** a client - /// passes `?token=` (defense-in-depth for a token-capable client); it's embedded - /// in [wsUrl] for that case. + /// Security: the [token] is **required**. The relay rejects the ws upgrade with + /// `401` unless the client presents it as an `Authorization: Bearer ` + /// header (a `?token=` query is an accepted fallback). The token is minted per + /// grant, held only in memory, and embedded in [wsUrl]. CDP discovery + /// (`/json/*`) stays token-free, so a local port-scanner can learn the ws-url + /// but cannot upgrade. On top of the token the relay binds loopback only, allows + /// a single active client, and exists only while enabled — so even a same-UID + /// local process can't attach without the in-memory token. /// /// Per-tile isolation: the relay is scoped to THIS tile's CDP target (resolved /// natively), and applies a deny-by-default / fail-closed / flatten-only Target-domain @@ -513,12 +537,13 @@ class CefWebController { Future<({String wsUrl, String token, int port})?> enableAgentControl() async { final res = await _channel.invokeMapMethod( 'enableAgentControl', {'sessionId': sessionId}); - if (res == null) return null; - return ( - wsUrl: res['wsUrl'] as String, - token: res['token'] as String, - port: res['port'] as int, - ); + // A partial/empty native reply returns null rather than throwing an + // uncatchable TypeError on a missing key. + final wsUrl = res?['wsUrl'] as String?; + final token = res?['token'] as String?; + final port = res?['port'] as int?; + if (wsUrl == null || token == null || port == null) return null; + return (wsUrl: wsUrl, token: token, port: port); } /// CEF-2a — tear down the agent-control relay (closes the listener and any client, @@ -788,13 +813,13 @@ class CefWebController { /// texture). Pending [runJavaScriptReturningResult] / [getCookies] futures /// fail with a [StateError], and the controller is unusable afterwards. Future dispose() async { + if (_disposed) return; // Idempotent: a controller can be disposed twice + // (e.g. an externally-owned controller torn down by both the app and a stale + // view). A second pass must not throw via the ValueNotifier dispose asserts. _disposed = true; _bySession.remove(sessionId); _failPendingEvals('controller disposed'); - for (final c in _cookiePending.values) { - if (!c.isCompleted) c.completeError(StateError('controller disposed')); - } - _cookiePending.clear(); + _failPendingCookies('controller disposed'); _channels.clear(); cursor.dispose(); cdpPort.dispose(); diff --git a/lib/src/cef_web_view.dart b/lib/src/cef_web_view.dart index cd3647d..cf852f2 100644 --- a/lib/src/cef_web_view.dart +++ b/lib/src/cef_web_view.dart @@ -65,7 +65,12 @@ class CefWebView extends StatefulWidget { final String url; /// Optional external controller (to script the view). If null, one is created - /// and owned internally. + /// and owned internally (and disposed with the view). + /// + /// When you supply a controller **you own its lifecycle** — the view only + /// auto-disposes a controller it created itself. Call `controller.dispose()` + /// when you're done with it, otherwise the per-view `cef_host` process tree, + /// the texture, and the controller's notifiers leak. final CefWebController? controller; /// Optional focus node. Provide one when an outer surface manages focus diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart index ffe53d1..925273c 100644 --- a/test/cef_web_controller_test.dart +++ b/test/cef_web_controller_test.dart @@ -552,6 +552,46 @@ void main() { expect(called, false, reason: 'events after dispose must be ignored'); }); + test('processGone fails pending eval + cookie futures (no indefinite hang)', + () async { + // The host crashing is the single most likely failure; in-flight round-trips + // have no one left to answer them and must fail, not hang forever. + final c = CefWebController(sessionId: 'pg'); + await c.create(url: 'about:blank', width: 1, height: 1); + final evalF = c.runJavaScriptReturningResult('slow()'); + final cookieF = c.getCookies(url: 'https://x.test/'); + final evalExp = expectLater(evalF, throwsA(isA())); + final cookieExp = expectLater(cookieF, throwsA(isA())); + String? reason; + c.onProcessGone = (r) => reason = r; + await emit('pg', 'processGone', {'reason': 'crashed'}); + await evalExp; + await cookieExp; + expect(reason, 'crashed'); + }); + + test('dispose() is idempotent — a second call does not throw', () async { + // Externally-owned controllers can be disposed twice (app + a stale view); + // the second pass must not throw via the ValueNotifier dispose asserts. + final c = CefWebController(sessionId: 'dd'); + await c.create(url: 'about:blank', width: 1, height: 1); + await c.dispose(); + await expectLater(c.dispose(), completes); + }); + + test('enableAgentControl returns null on a partial native reply', () async { + messenger.setMockMethodCallHandler(channel, (call) async { + log.add(call); + if (call.method == 'enableAgentControl') { + return {'wsUrl': 'ws://x', 'token': 'abc'}; // no port + } + return null; + }); + final c = CefWebController(sessionId: 'acp'); + // A missing key must yield null, not an uncatchable TypeError. + expect(await c.enableAgentControl(), isNull); + }); + test('alert dialog routes to the handler and acks', () async { final c = CefWebController(sessionId: 'al'); await c.create(url: 'about:blank', width: 1, height: 1); From 7249f928a4dc8c43205b7e698df97d8af505f5d6 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 15:23:58 -0700 Subject: [PATCH 2/5] test(cdp): fix the stale setAutoAttach filter assertion + run the filter suite in CI The CDP per-tile isolation filter suite had a stale assertion: it expected a browser-level Target.setAutoAttach(flatten) to be FORWARDED, but the relay correctly INTERCEPTS it (self-attaches + synthesizes attachedToTarget, H2) to prevent cross-tile control leak. The failure went unnoticed because CI never ran the suite. Fix the assertion to match the (correct) interception behavior, and wire run_filter_tests.sh into CI so the security keystone is gated on every PR; also analyze the sub-packages. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yaml | 9 ++++++++- .../flutter_cef_macos/test/CdpRelayFilterTests.swift | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d6e89a0..f937cd6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,9 +22,16 @@ jobs: - run: flutter --version - name: Install dependencies run: flutter pub get - - name: Analyze (package + example) + - name: Analyze (package + sub-packages + example) run: | flutter analyze + (cd packages/flutter_cef_platform_interface && flutter pub get && flutter analyze) + (cd packages/flutter_cef_macos && flutter pub get && flutter analyze) (cd example && flutter pub get && flutter analyze) - name: Test run: flutter test + - name: CDP isolation filter tests (security boundary) + # The CDP per-tile isolation filter is the security keystone; this standalone + # swiftc suite runs without Xcode/CocoaPods. (A regression here previously went + # unnoticed precisely because CI did not run it.) + run: ./packages/flutter_cef_macos/test/run_filter_tests.sh diff --git a/packages/flutter_cef_macos/test/CdpRelayFilterTests.swift b/packages/flutter_cef_macos/test/CdpRelayFilterTests.swift index 8bd4f8e..e436763 100644 --- a/packages/flutter_cef_macos/test/CdpRelayFilterTests.swift +++ b/packages/flutter_cef_macos/test/CdpRelayFilterTests.swift @@ -74,7 +74,12 @@ enum CdpRelayFilterTests { fwd("Target.getTargetInfo(no id)", #"{"id":1,"method":"Target.getTargetInfo"}"#) drop("Target.getTargets (synthesized, not forwarded)", #"{"id":1,"method":"Target.getTargets"}"#) drop("Target.setAutoAttach non-flatten", #"{"id":1,"method":"Target.setAutoAttach","params":{"flatten":false}}"#) - fwd("Target.setAutoAttach flatten", #"{"id":1,"method":"Target.setAutoAttach","params":{"flatten":true}}"#) + // Browser-level setAutoAttach(flatten) is INTERCEPTED, not forwarded: the relay + // self-attaches to our target + synthesizes attachedToTarget (H2), so a client + // can't change a sibling tile's auto-attach. Forwarding would be a cross-tile + // control leak — this is the per-tile isolation boundary, so it must return nil. + drop("Target.setAutoAttach flatten (self-attached + synthesized, not forwarded)", + #"{"id":1,"method":"Target.setAutoAttach","params":{"flatten":true}}"#) // ── C→R: page-scoped driving on OUR session is allowed; foreign session denied ── fwd("Page.navigate on our session", #"{"id":1,"method":"Page.navigate","sessionId":"SESS-A","params":{"url":"https://x"}}"#) From 22f9549c91a780b784fac10776c50fec3606908b Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 15:23:58 -0700 Subject: [PATCH 3/5] chore(release): fix macos version skew, trim pub descriptions, replace the example test - macos podspec 0.1.3 -> 0.2.0 to match its pubspec, with a 0.2.0 CHANGELOG entry (profiles, multi-view shared host, agent-control CDP relay, reliability). - Trim the root + platform-interface pubspec descriptions under pub.dev's 180-char limit. - Replace the example RunnerTests stub (it tested a non-existent verb and force-cast the result, crashing if ever run) with a real unknown-verb dispatch test. Co-Authored-By: Claude Opus 4.8 --- example/macos/RunnerTests/RunnerTests.swift | 26 ++++++++----------- packages/flutter_cef_macos/CHANGELOG.md | 14 ++++++++++ .../macos/flutter_cef_macos.podspec | 2 +- .../pubspec.yaml | 2 +- pubspec.yaml | 2 +- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/example/macos/RunnerTests/RunnerTests.swift b/example/macos/RunnerTests/RunnerTests.swift index 284e5d0..6225230 100644 --- a/example/macos/RunnerTests/RunnerTests.swift +++ b/example/macos/RunnerTests/RunnerTests.swift @@ -2,27 +2,23 @@ import Cocoa import FlutterMacOS import XCTest +@testable import flutter_cef_macos -@testable import flutter_cef - -// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. -// -// See https://developer.apple.com/documentation/xctest for more information about using XCTest. - +// Unit test for the macOS plugin's method-channel dispatcher. Real end-to-end +// behavior (rendering, JS channels, CDP) is covered by the example probes + +// test/run_channel_integration.sh against a real cef_host; this only checks the +// dispatcher's default path, which needs no live host. class RunnerTests: XCTestCase { - - func testGetPlatformVersion() { + func testUnknownVerbReturnsNotImplemented() { let plugin = FlutterCefPlugin() - - let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) - - let resultExpectation = expectation(description: "result block must be called.") + let call = FlutterMethodCall(methodName: "definitelyNotARealVerb", arguments: []) + let resultExpectation = expectation(description: "result block must be called") plugin.handle(call) { result in - XCTAssertEqual(result as! String, - "macOS " + ProcessInfo.processInfo.operatingSystemVersionString) + XCTAssertTrue( + result is FlutterMethodNotImplemented, + "an unknown verb must return FlutterMethodNotImplemented") resultExpectation.fulfill() } waitForExpectations(timeout: 1) } - } diff --git a/packages/flutter_cef_macos/CHANGELOG.md b/packages/flutter_cef_macos/CHANGELOG.md index 3d81ed8..d465183 100644 --- a/packages/flutter_cef_macos/CHANGELOG.md +++ b/packages/flutter_cef_macos/CHANGELOG.md @@ -1,3 +1,17 @@ +## 0.2.0 + +* Persistent + shared named profiles (`profile:`): one `cef_host` process with one + cookie jar per profile; ephemeral remains the default. +* Multi-view shared host: many sessions multiplex over one `cef_host` via a + per-browser wire id; channel registration buffers until the browser attaches, so + a page→host JS channel works on a shared host regardless of call/attach order. +* Agent control (`enableAgentControl`): a token-gated, loopback-only, per-tile-scoped + CDP relay with a deny-by-default / fail-closed Target-domain filter. +* Reliability: EINTR-resilient pipe IO, CDP relay + pending-waiter cleanup on host + death, and off-reader-thread CDP writes so one stuck client can't starve siblings. +* Ad-hoc dev builds (`CEF_HOST_ADHOC=ON`) downgrade a named profile to ephemeral + unless `FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1`. + ## 0.1.3 * Initial release of the federated macOS implementation of `flutter_cef` (the diff --git a/packages/flutter_cef_macos/macos/flutter_cef_macos.podspec b/packages/flutter_cef_macos/macos/flutter_cef_macos.podspec index 738df28..3b4592a 100644 --- a/packages/flutter_cef_macos/macos/flutter_cef_macos.podspec +++ b/packages/flutter_cef_macos/macos/flutter_cef_macos.podspec @@ -6,7 +6,7 @@ # Pod::Spec.new do |s| s.name = 'flutter_cef_macos' - s.version = '0.1.3' + s.version = '0.2.0' s.summary = 'Live Chromium (CEF) browser as a Flutter Texture (macOS).' s.description = <<-DESC Embed a live Chromium browser via CEF off-screen rendering, shown as a Flutter diff --git a/packages/flutter_cef_platform_interface/pubspec.yaml b/packages/flutter_cef_platform_interface/pubspec.yaml index 3569721..0048b70 100644 --- a/packages/flutter_cef_platform_interface/pubspec.yaml +++ b/packages/flutter_cef_platform_interface/pubspec.yaml @@ -1,5 +1,5 @@ name: flutter_cef_platform_interface -description: "A common platform interface for the flutter_cef plugin: the shared Dart types and the method-channel contract that each platform implementation (macOS, and future Windows/Linux) speaks." +description: "The common platform interface for flutter_cef: the shared Dart types and method-channel contract each platform implementation (macOS, future Windows/Linux) speaks." version: 0.1.3 homepage: https://github.com/FlutterFlow/flutter_cef repository: https://github.com/FlutterFlow/flutter_cef diff --git a/pubspec.yaml b/pubspec.yaml index 266b8fd..94512a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: flutter_cef -description: "Embed a live Chromium (CEF) browser as a Flutter Texture on macOS: composites and clips like any widget, with input forwarding, a JS bridge, navigation and page events." +description: "Embed a Chromium (CEF) browser as a Flutter Texture on macOS: composites and clips like a widget, with input forwarding, a JS bridge, and navigation events." version: 0.2.0 homepage: https://github.com/FlutterFlow/flutter_cef repository: https://github.com/FlutterFlow/flutter_cef From 4947b117c0e77612a007849cf80f1ae86d8356c7 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 15:34:20 -0700 Subject: [PATCH 4/5] feat(controller): removeJavaScriptChannel + mark raw input @internal; CdpRelay EINTR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add removeJavaScriptChannel (stops delivery; the process-global page-side shim is intentionally left intact since tearing it down would also remove it from sibling views on a shared profile). - Mark sendPointer/sendKey @internal — they are raw wire encodings driven by CefWebView, not supported public API (adds the `meta` dep). - CdpRelay.writeAll now retries on EINTR — the last pipe writer still treating a signal as a dead socket (matches CefProfileHost + the C++ host side). Tests: removeJavaScriptChannel stops delivery; processGone reason default/passthrough; paintStalled callback. Co-Authored-By: Claude Opus 4.8 --- lib/src/cef_web_controller.dart | 17 ++++++++++ .../macos/Classes/CdpRelay.swift | 5 ++- pubspec.yaml | 1 + test/cef_web_controller_test.dart | 32 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 9e68f59..9d2b5a6 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; import 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.dart'; @@ -598,6 +599,16 @@ class CefWebController { 'addJavaScriptChannel', {'sessionId': sessionId, 'name': name}); } + /// Stop delivering a JS channel registered with [addJavaScriptChannel]: + /// [onMessageReceived] is no longer invoked for [name]. The page-side + /// `window.` shim is intentionally NOT torn down — it is process-global on + /// a shared profile, so tearing it down here would also remove it from sibling + /// views — so the page can still call `window..postMessage`, but those + /// messages are dropped. + void removeJavaScriptChannel(String name) { + _channels.remove(name); + } + /// Scroll the page to an absolute pixel position. Future scrollTo(int x, int y) => executeJavaScript('window.scrollTo($x, $y)'); @@ -767,7 +778,10 @@ class CefWebController { 'dpr': dpr, }); + /// Internal — driven by [CefWebView]'s gesture forwarding; not part of the + /// supported public API (raw wire encoding). /// type: 0=move 1=down 2=up 3=wheel 4=leave; button: 0=left 1=middle 2=right. + @internal void sendPointer({ required int type, required double x, @@ -791,7 +805,10 @@ class CefWebController { }); } + /// Internal — driven by [CefWebView]'s key forwarding; not part of the + /// supported public API (raw wire encoding). /// type: 0=rawkeydown 2=keyup 3=char. + @internal void sendKey({ required int type, int modifiers = 0, diff --git a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift index d538c66..78f72ec 100644 --- a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift +++ b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift @@ -906,7 +906,10 @@ final class CdpRelay { var off = 0 while off < bytes.count { let n = bytes.withUnsafeBytes { write(fd, $0.baseAddress!.advanced(by: off), bytes.count - off) } - if n <= 0 { return false } // EPIPE/EBADF (SO_NOSIGPIPE keeps it from signaling) + if n <= 0 { + if n < 0 && errno == EINTR { continue } // signal: retry, not a dead pipe + return false // EPIPE/EBADF (SO_NOSIGPIPE keeps it from signaling) + } off += n } return true diff --git a/pubspec.yaml b/pubspec.yaml index 94512a1..eb6c249 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ environment: dependencies: flutter: sdk: flutter + meta: ^1.12.0 # @internal / annotations on the public API # Federation members are wired as path deps: the repo is consumed from source # (path / git), not pub.dev. PUBLISHING (when it happens): `pub publish` # rejects path deps, so publish bottom-up — flutter_cef_platform_interface, diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart index 925273c..e145047 100644 --- a/test/cef_web_controller_test.dart +++ b/test/cef_web_controller_test.dart @@ -592,6 +592,38 @@ void main() { expect(await c.enableAgentControl(), isNull); }); + test('removeJavaScriptChannel stops delivery to the handler', () async { + final c = CefWebController(sessionId: 'rmch'); + await c.create(url: 'about:blank', width: 1, height: 1); + final got = []; + await c.addJavaScriptChannel('Bridge', onMessageReceived: got.add); + await emit('rmch', 'channelMessage', {'payload': 'Bridge:one'}); + c.removeJavaScriptChannel('Bridge'); + await emit('rmch', 'channelMessage', {'payload': 'Bridge:two'}); + expect(got, ['one'], reason: 'messages after removal must be dropped'); + }); + + test('processGone defaults the reason to "crashed" / passes it through', + () async { + final c = CefWebController(sessionId: 'pgr'); + await c.create(url: 'about:blank', width: 1, height: 1); + String? reason; + c.onProcessGone = (r) => reason = r; + await emit('pgr', 'processGone', {}); // no reason + expect(reason, 'crashed'); + await emit('pgr', 'processGone', {'reason': 'locked'}); + expect(reason, 'locked'); + }); + + test('paintStalled event invokes onPaintStalled', () async { + final c = CefWebController(sessionId: 'pstall'); + await c.create(url: 'about:blank', width: 1, height: 1); + var stalled = false; + c.onPaintStalled = () => stalled = true; + await emit('pstall', 'paintStalled', {}); + expect(stalled, isTrue); + }); + test('alert dialog routes to the handler and acks', () async { final c = CefWebController(sessionId: 'al'); await c.create(url: 'about:blank', width: 1, height: 1); From 9d07c62cec710d9f4b87f5e1ef4df8a1df3d1163 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Wed, 24 Jun 2026 15:41:00 -0700 Subject: [PATCH 5/5] docs: add Troubleshooting + CONTRIBUTING + fill the README API tour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONTRIBUTING.md: federated layout, building cef_host (cmake+ninja), and the exact local check sequence CI runs (analyze x4, flutter test, the CDP-filter security suite, the real-host integration probes). - README: a symptom-keyed Troubleshooting section (blank texture, hardware-access SIGABRT, ephemeral-profile downgrade, 'locked', paint-stall, CDP 401) and the cmake+ninja build prerequisite. - README API tour: document the previously-untoured public members — removeJavaScriptChannel, onProcessGone, onPaintStalled, setVisible, enableCdp/cdpPort (with the agent-control contrast). Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 185 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 68 +++++++++++++++++- 2 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..718edb5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,185 @@ +# Contributing to flutter_cef + +`flutter_cef` embeds a live Chromium browser (via the +[Chromium Embedded Framework](https://bitbucket.org/chromiumembedded/cef/)) as a +Flutter `Texture` on **macOS 12+**. This guide covers the repo layout, building +the native renderer, and running the same checks CI does before you open a PR. + +## Package layout (federated plugin) + +The repo is a federated plugin — three packages, consumed from source (path / +git), not yet from pub.dev: + +| Package | Path | What it is | +| --- | --- | --- | +| `flutter_cef` | repo root (`lib/`, `pubspec.yaml`) | The app-facing API: `CefWebView`, `CefWebController`. Re-exports the platform interface. | +| `flutter_cef_platform_interface` | `packages/flutter_cef_platform_interface` | The shared Dart types + the method-channel contract every platform implementation speaks. No native code. Breaking changes here require a major bump + a coordinated update of all implementations. | +| `flutter_cef_macos` | `packages/flutter_cef_macos` | The endorsed macOS implementation: the Swift host plugin (`FlutterCefPlugin`), the `cef_host` subprocess sources + build, the CDP relay, and the bundling tooling. | + +The root `flutter_cef` package depends on both siblings via `path:` and endorses +`flutter_cef_macos` as the macOS `default_package`, so a plain dependency on +`flutter_cef` pulls in macOS support automatically. `example/` is a full browser +chrome (URL bar, back/forward/reload, loading bar, live title) plus the +integration probes (`example/lib/*.dart`). + +A new platform is a sibling `flutter_cef_` package — see +[`PORTING.md`](PORTING.md) for the contract and seam map. + +### Dependency / publish model + +Federation members are wired as `path:` deps because the repo is consumed from +source. `pub publish` rejects path deps, so **if/when we publish, publish +bottom-up**: + +1. `flutter_cef_platform_interface` +2. `flutter_cef_macos` +3. `flutter_cef` (root) + +At each step, swap the sibling `path:` deps for hosted caret constraints and keep +the `path:` entries under `dependency_overrides` for local dev. See the inline +note in the root `pubspec.yaml` (deps section) for the canonical wording. + +## Building the `cef_host` subprocess + +CEF (~200 MB) is **fetched, not vendored**. The off-screen renderer +(`cef_host.app`) is built once from +`packages/flutter_cef_macos/native/build_cef_host.sh`. + +**Prerequisites:** `cmake` + `ninja` (the script drives a CMake/Ninja build), +Xcode command-line tools, and a network connection (first run downloads + +SHA-256-verifies the pinned CEF binary distribution into `~/.cache/flutter_cef`). + +```sh +cd packages/flutter_cef_macos +native/build_cef_host.sh # fetches CEF + builds cef_host.app -> native/cef_host/build +export FLUTTER_CEF_HOST="$PWD/native/cef_host/build/cef_host.app/Contents/MacOS/cef_host" +cd ../../example && flutter run -d macos +``` + +The plugin resolves `cef_host` in this order: `$FLUTTER_CEF_HOST` → pod +resources → the host app's `Contents/Frameworks` → `Contents/Helpers`. For dev +work, exporting `$FLUTTER_CEF_HOST` is the easy path. + +### Build flags + +The script reads a few env vars (defaults in parentheses): + +| Var | Default | Effect | +| --- | --- | --- | +| `CEF_HOST_ADHOC` | `ON` | **Dev/CI.** Ad-hoc signature, mock keychain, Mach-port peer-validation bypass — runs without Developer-ID signing, unsandboxed. `OFF` = **signed release**: real Keychain/OSCrypt, enforced validation, sandbox — requires correct inside-out Developer-ID signing. Also required for at-rest cookie encryption on a persistent profile. | +| `CODESIGN_ID` | `-` (ad-hoc) | Pass a Developer ID / Apple Development identity for standalone use. When bundled into a host app, the app's own signing re-signs the tree instead. | +| `CEF_MULTI_PROCESS` | `ON` | Multi-process GPU-accelerated OSR (crash-isolated, heavy SPAs render). `OFF` = simpler single-process software-blit fallback. | +| `FLUTTER_CEF_CACHE` | `~/.cache/flutter_cef` | Where the CEF dist is fetched/extracted. | + +Signed-release build: + +```sh +CEF_HOST_ADHOC=OFF CODESIGN_ID="" native/build_cef_host.sh +``` + +### Bundling into a distributable app + +For a shipped `.app` (no dev env var), `cef_host.app` must live in +`Contents/Frameworks` and be signed by your build. After `flutter build macos`, +run `packages/flutter_cef_macos/tool/bundle_cef_host.sh` (see the snippet at the +top of that script to wire it as a Run Script build phase on the Runner target, +so it runs before Xcode's code-sign phase). The script picks entitlements by +signing posture: ad-hoc (`-`) keeps `entitlements.plist` (with `get-task-allow`, +for debugging); a real identity uses `entitlements.release.plist` (no +`get-task-allow`). Your host app **must not be App-Sandboxed**. + +## Running checks locally (exactly as CI does) + +CI (`.github/workflows/ci.yaml`, `macos-14`, Flutter **3.38.8 / stable**) runs +the steps below in order. Reproduce them all before pushing. + +> CI pins Flutter to **3.38.8** — the version the primary consumer (work_canvas) +> ships against — not floating `stable`. Floating stable breaks CI whenever the +> framework adds an interface method the pinned engine doesn't carry. Use the +> same version locally if you hit an analyzer/`TextInputClient` mismatch. + +### 1. Analyze (package + all sub-packages + example) + +`flutter analyze` does **not** recurse into sub-packages, so CI runs it in each +one explicitly: + +```sh +flutter pub get +flutter analyze +(cd packages/flutter_cef_platform_interface && flutter pub get && flutter analyze) +(cd packages/flutter_cef_macos && flutter pub get && flutter analyze) +(cd example && flutter pub get && flutter analyze) +``` + +### 2. Dart unit/widget tests + +```sh +flutter test +``` + +### 3. CDP isolation filter tests (security keystone) + +The per-tile CDP Target-domain isolation filter is the security boundary that +confines an agent-controlled tile to its own target. `CdpRelay.swift` uses only +system frameworks, so the suite compiles + runs with `swiftc` directly — no +Xcode/CocoaPods harness: + +```sh +./packages/flutter_cef_macos/test/run_filter_tests.sh +``` + +A regression here once shipped unnoticed precisely because CI wasn't running it — +**do not skip it** when touching `CdpRelay.swift` or the filter. + +### 4. Real-host integration probes (not in CI — run before bumping a consumer pin) + +The Dart `integration_test` mocks the host method channel, so it cannot catch +native channel-delivery or CDP-relay regressions (that's how the shared-host +page→host channel bug shipped). These probes drive the **real** `cef_host` +headless and assert a `/tmp` JSON result: + +```sh +./test/run_channel_integration.sh # all probes +./test/run_channel_integration.sh channel_probe_shared # just one +``` + +Probes (`example/lib/`): +- `channel_probe` — single ephemeral host: page→host JS channel delivers. +- `channel_probe_shared` — two sessions on one shared host: channel delivers + + routes per-session (the B→A regression). +- `multiview_probe` — agent-control / CDP relay isolation on a shared host. + +The runner builds an ad-hoc `cef_host` if `$FLUTTER_CEF_HOST` is unset, and sets +`FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1` so the shared-host probes get a real +shared host (an ad-hoc host otherwise downgrades named profiles to ephemeral, +masking the very regression they guard). + +## Coding conventions + +- **Match the surrounding style.** This codebase favors narrow, well-commented + changes over broad refactors. The Dart, Swift, and CMake all carry dense + explanatory comments at the non-obvious seams — keep that up; a tricky fix + should explain *why*, not just *what*. +- **Document threading assumptions in the native layers.** The Swift/native code + spans the Flutter platform thread, CEF's UI thread, the GPU/Viz process, and + the socket relay. When you touch a method that must run on (or hand off to) a + specific thread/queue, say so in a comment — wrong-thread CEF calls are a + common, hard-to-debug failure mode. +- **Respect the security posture.** JS channel names are validated as JS + identifiers before injection; `runJavaScriptReturningResult` expects a single + trusted expression; the CDP filter is deny-by-default / fail-closed / + flatten-only. Preserve these invariants and extend the corresponding tests + (`run_filter_tests.sh`, the integration probes) when you change behavior. +- **Keep the platform-interface contract clean.** New cross-platform surface goes + through `flutter_cef_platform_interface`; macOS-specific plumbing stays in + `flutter_cef_macos`. Don't reach around the method-channel contract. + +## Pull requests + +- Run all four check stages above (analyze ×4, `flutter test`, + `run_filter_tests.sh`, and — for anything touching native delivery or the + relay — `run_channel_integration.sh`). +- Add a `CHANGELOG.md` entry. +- If you change the wire protocol, update both `flutter_cef_platform_interface` + and `flutter_cef_macos` in the same PR, and note any pin-bump implications for + consumers. diff --git a/README.md b/README.md index c6ac7a4..6b0739c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ 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') +c.removeJavaScriptChannel('Native'); // stop delivering (page-side shim stays; see Profiles) // page state (ValueListenables) + lifecycle/dialog callbacks c.isLoading; c.url; c.title; c.canGoBack; c.canGoForward; @@ -42,6 +43,13 @@ c.onLoadError = (e) => print('${e.errorCode} ${e.url}'); c.onConsoleMessage = (m) => print(m.message); c.onJavaScriptConfirmDialog = (req) async => askUser(req.message); // alert/confirm/prompt +// process liveness + paint recovery +c.onProcessGone = (reason) {}; // host died: 'locked' (profile open elsewhere) / 'crashed' +c.onPaintStalled = () {}; // created but never painted — recreate the view to recover + +// pause/resume frame production without tearing down (DOM + JS state kept) +c.setVisible(false); c.setVisible(true); + // cookies + scroll + storage c.setCookie(url: 'https://example.com/', name: 'sid', value: 'abc'); final cookies = await c.getCookies(); // read/enumerate; pass url: to scope @@ -53,6 +61,13 @@ c.onDownload = (suggestedName) {}; // downloads land in ~/Downloads // open the Chrome DevTools inspector for this view in its own window c.openDevTools(); +// Raw Chrome DevTools Protocol over a TCP port — opt in at construction with +// CefWebView(..., enableCdp: true), then connect a CDP client to 127.0.0.1:: +final cdpPort = c.cdpPort.value; // ValueListenable; 0 until created +// NOTE: enableCdp is an UNAUTHENTICATED localhost port and is rejected on a named +// `profile:`. To drive a logged-in tile from an agent, prefer agentControl + +// enableAgentControl() (CDP over a private pipe, token-gated) — see "Agent control". + // open the macOS emoji & symbols picker over the focused page (same as ⌃⌘Space) c.showEmojiPicker(); ``` @@ -78,7 +93,8 @@ Same pattern JCEF (JetBrains) and CefSharp use to render Chromium into a non-nat ## Building -CEF (~200 MB) is **fetched**, not vendored. Build the renderer once: +CEF (~200 MB) is **fetched**, not vendored. Build the renderer once (needs +`cmake` + `ninja` — `brew install cmake ninja`): ```sh # The macOS implementation lives in packages/flutter_cef_macos. @@ -327,6 +343,56 @@ own CDP target, all multiplexed over the single browser-wide `--remote-debugging neither be seen nor driven). See the `CdpRelay` multiplex notes and `CdpRelayFilterTests` for the isolation boundary. +## Troubleshooting + +### Blank / black texture — the page never appears + +`cef_host` couldn't be found, so no renderer subprocess spawned. Build it and make +it discoverable — in a dev checkout export `$FLUTTER_CEF_HOST` (see +[Building](#building)); in a shipped `.app`, `cef_host.app` must live in +`Contents/Frameworks` (run `tool/bundle_cef_host.sh`). Resolution order: +`$FLUTTER_CEF_HOST` → pod resources → `Contents/Frameworks` → `Contents/Helpers`. + +### App crashes shortly after a page touches hardware (SIGABRT) + +`cef_host` runs as a child of your app, so macOS attributes hardware access (e.g. +WebAuthn/passkeys reaching Bluetooth, camera, microphone) to your app and reads +the usage string from **your** app's `Info.plist`. If it's missing, the process is +SIGABRT'd the instant the hardware is touched. Declare +`NSBluetoothAlwaysUsageDescription` / `NSCameraUsageDescription` / +`NSMicrophoneUsageDescription` in your app's `Info.plist` (see +`example/macos/Runner/Info.plist`), plus the matching +`com.apple.security.device.*` entitlements if the access must actually function. + +### A named `profile:` silently behaves as ephemeral (login doesn't persist) + +The default ad-hoc dev build (`CEF_HOST_ADHOC=ON`) has only a mock keychain and +can't encrypt cookies at rest, so it **downgrades a named profile to ephemeral** +rather than persisting a login to a plaintext store (it logs a warning). For real +persistence ship a signed release build (`CEF_HOST_ADHOC=OFF`, real +Keychain/OSCrypt). For dev only, set `FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1` — +**never ship that override.** + +### `onProcessGone` fires with reason `'locked'` + +The persistent profile is already open in another process/instance — a named +profile is one `cef_host` with one cross-process cache lock. Close the other +holder and recreate the view to retry (vs. `'crashed'` for a generic death). + +### Page loads but never paints (permanently blank, no crash) + +The browser was created but never delivered a first frame even after a re-kick. +Wire `controller.onPaintStalled` and recreate the view to recover, rather than +leaving a blank tile. + +### Agent-control CDP client gets `401` on the WebSocket upgrade + +The relay **requires a token**. Present the token from `enableAgentControl()` as +`Authorization: Bearer ` (a `?token=` query is an accepted fallback) on the +upgrade; CDP discovery (`/json/*`) stays token-free. The token is minted per +grant, held only in memory, and embedded in `grant.wsUrl` — deliver it +out-of-band (never on disk/argv/env). See [Agent control](#agent-control). + ## Roadmap Known limitation: the IOSurface is single-buffered, so very fast-updating pages