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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
185 changes: 185 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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_<os>` 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="<Developer 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.
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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:<port>:
final cdpPort = c.cdpPort.value; // ValueListenable<int>; 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();
```
Expand All @@ -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.
Expand Down Expand Up @@ -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 <token>` (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
Expand Down
26 changes: 11 additions & 15 deletions example/macos/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
Loading
Loading