diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..105d8dc
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+# Native sources are hashed by tool/cef_host_hash.sh to key the prebuilt
+# cef_host artifact in GCS. The hash is over RAW BYTES, so a Windows checkout
+# with core.autocrlf=true would compute a different digest than the macOS
+# publisher for the same commit — forever missing the prebuilt. Pin LF for
+# everything the hash covers (and shell scripts, which break under CRLF).
+packages/**/native/** text eol=lf
+packages/**/tool/*.sh text eol=lf
+*.sh text eol=lf
+# Windows batch files MUST be CRLF (cmd.exe misparses LF-only .bat, dropping
+# characters). This overrides the native/** LF pin above for *.bat only — the
+# hash key stays deterministic because CRLF is pinned on BOTH platforms.
+*.bat text eol=crlf
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index f937cd6..9459b78 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -27,6 +27,7 @@ jobs:
flutter analyze
(cd packages/flutter_cef_platform_interface && flutter pub get && flutter analyze)
(cd packages/flutter_cef_macos && flutter pub get && flutter analyze)
+ (cd packages/flutter_cef_windows && flutter pub get && flutter analyze)
(cd example && flutter pub get && flutter analyze)
- name: Test
run: flutter test
@@ -35,3 +36,33 @@ jobs:
# 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
+
+ # Windows: analyze the endorsed windows implementation + build the example so
+ # the ~5.5k lines of native plugin/cef_host/CDP-relay code (profiles, cookies,
+ # JS bridge, agent control) are actually compiled by CI, not merged blind.
+ windows-build:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: 3.38.8
+ channel: stable
+ - run: flutter --version
+ - name: Analyze (windows implementation package)
+ shell: bash
+ run: |
+ flutter pub get
+ flutter analyze
+ (cd packages/flutter_cef_windows && flutter pub get && flutter analyze)
+ - name: Dart tests (includes the Windows key/accelerator + verb twins)
+ run: flutter test
+ - name: Build example for Windows (compiles the plugin + cef_host)
+ shell: bash
+ # CEF (~200 MB) is fetched by the plugin CMake from the pinned dist; if
+ # the fetch/build is unavailable on the runner this step surfaces it
+ # rather than letting native regressions merge unbuilt.
+ run: |
+ cd example
+ flutter pub get
+ flutter build windows --debug
diff --git a/PORTING.md b/PORTING.md
index 4665088..19027c2 100644
--- a/PORTING.md
+++ b/PORTING.md
@@ -57,7 +57,12 @@ lives in the Flutter app's process and:
- Spawns **one `cef_host` subprocess per profile** (`--ipc --cdp-port
--profile-dir --allowed-schemes --ephemeral` as argv), then creates **N
browsers in that process** via `opCreateBrowser` frames carrying `url, width,
- height, dpr, iosurface-id` + a `browserId`. `--profile-dir` is always passed; a
+ height, dpr` + a `browserId`. **The create carries no surface id** — the
+ surface model is *producer-allocates*: `cef_host` mints (and on resize
+ re-mints) the shared surface itself and announces it to the host via
+ `opPresent` frames carrying `{surface-id, srcW, srcH}` (the host promotes a
+ frame only when its dims match the expected size — see
+ `docs/OSR_SCALE_MISMATCH.md`). `--profile-dir` is always passed; a
persistent (named) profile points it at a stable cache dir, while an ephemeral
profile points it at a unique throwaway temp dir and adds `--ephemeral` (so the
host's CDP / mock-keychain guards fire only for a real persistent profile —
@@ -85,11 +90,11 @@ reused verbatim. Only these seams are macOS-specific (file:line are into
| Seam | macOS reference | What your platform provides |
| --- | --- | --- |
-| **Shared surface** — receive painted frames and present them to the host texture. CEF delivers either software `OnPaint` (CPU buffer) or `OnAcceleratedPaint` (a shared GPU texture handle). | `OnPaint` / `OnAcceleratedPaint` + the `IOSurface*` ops (~lines 346–450); `g_surface` (~133). macOS uses an IOSurface-backed `CVPixelBuffer`. | **Windows**: a shared D3D11 texture / DXGI keyed-mutex handle. **Linux**: shared memory or a DMA-buf, presented via the platform texture. Look the surface up by the `--iosurface-id`-equivalent handle the host passes. |
+| **Shared surface** — receive painted frames and present them to the host texture. CEF delivers either software `OnPaint` (CPU buffer) or `OnAcceleratedPaint` (a shared GPU texture handle). | `OnPaint` / `OnAcceleratedPaint` + the `IOSurface*` ops (~lines 346–450); `g_surface` (~133). macOS uses an IOSurface-backed `CVPixelBuffer`. | **Windows**: a shared D3D11 texture. Note (verified against CEF 144 `cef_types_win.h` + Flutter engine `external_texture_d3d.cc`): CEF's `OnAcceleratedPaint` delivers an **NT** handle (no keyed mutex, non-owning, pool-released when the callback returns — open it synchronously via `ID3D11Device1::OpenSharedResource1`), while Flutter's ANGLE compositor requires a **legacy** `D3D11_RESOURCE_MISC_SHARED` handle (`EGL_D3D_TEXTURE_2D_SHARE_HANDLE_ANGLE`); the two are mutually exclusive on one texture, so exactly one `CopyResource` NT→legacy is structurally mandatory (macOS also blits — `CompositeMetalLocked`). **Linux**: shared memory or a DMA-buf, presented via the platform texture. Either way the model is **producer-allocates**: `cef_host` mints the shared surface and announces it via `opPresent` — the host does NOT pass a surface id in the create (the macOS field name differs too: `shared_texture_io_surface` vs Windows `shared_texture_handle`, so the accelerated-paint glue takes a per-OS `#if`, not a typedef). |
| **IPC transport** — a framed bidirectional byte stream to the host. Wire format: 4-byte big-endian length prefix, then `[u32 browserId][opcode][payload]` (`browserId 0` = process-level: `opReady`, process logs, `opShutdown`). | `WriteAll`/`ReadAll` (207–235), `SendFrame` (~237–249), `ConnectUnixSocket` (1341+), the read loop (~1140). Unix domain socket. | **Windows**: a named pipe. **Linux**: a Unix domain socket (reuse as-is). Keep the same length-prefixed framing and the `browserId` dimension. |
| **App / run loop** — give CEF a host application + message loop, and a per-profile cache dir. | `CefHostApplication : NSApplication` (304–330); `@autoreleasepool` + `sharedApplication` (1420+); `--profile-dir` → `settings.root_cache_path` cache; `_NSGetExecutablePath` (1335). | **Windows/Linux**: the platform's CEF message-loop integration (`CefRunMessageLoop` or OS loop) and the caller-supplied `--profile-dir` (per-profile, persistent) as the cache path. |
-| **Agent-control CDP pipe (optional)** — for `agentControl`, the host launches `cef_host` so Chromium's `--remote-debugging-pipe` (NUL-framed CDP JSON) rides **inherited file descriptors 3=read / 4=write** instead of a TCP port. | `CefProfileHost.launchViaPosixSpawn` (Swift): two `pipe()` pairs `dup2`'d onto fds 3/4 via `posix_spawn_file_actions`, parent ends marked `FD_CLOEXEC`; `cef_host` appends `remote-debugging-pipe` in `OnBeforeCommandLineProcessing`. The relay (`CdpRelay.swift`) + per-tile Target filter are platform-agnostic; only the fd placement is OS-specific. | **Linux**: reuse the `posix_spawn_file_actions` fd-3/4 recipe verbatim. **Windows**: a different model — `CreatePipe` + `STARTUPINFOEX` `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` with `bInheritHandles`, and Chromium takes the two pipe **HANDLEs** as `--remote-debugging-pipe` args (not fds 3/4). The default (non-agent) launch also needs a native `exec`/`CreateProcess` (Foundation.Process is macOS-only). |
-| **Sandbox** — bring the child processes into the Chromium sandbox in a signed/release build. | `process_helper.mm`: `CefScopedSandboxContext` (release only); `settings.no_sandbox` toggled by `CEF_HOST_ADHOC` (1426/1433). | **Windows**: link the `cef_sandbox` static lib + `CefScopedSandboxContext`. **Linux**: the SUID / user-namespace sandbox helper. |
+| **Agent-control CDP pipe (optional)** — for `agentControl`, the host launches `cef_host` so Chromium's `--remote-debugging-pipe` (NUL-framed CDP JSON) rides **inherited file descriptors 3=read / 4=write** instead of a TCP port. | `CefProfileHost.launchViaPosixSpawn` (Swift): two `pipe()` pairs `dup2`'d onto fds 3/4 via `posix_spawn_file_actions`, parent ends marked `FD_CLOEXEC`; `cef_host` appends `remote-debugging-pipe` in `OnBeforeCommandLineProcessing`. The relay (`CdpRelay.swift`) + per-tile Target filter are platform-agnostic; only the fd placement is OS-specific. | **Linux**: reuse the `posix_spawn_file_actions` fd-3/4 recipe verbatim. **Windows**: a different model — `CreatePipe` + `SetHandleInformation(HANDLE_FLAG_INHERIT)` + `STARTUPINFOEX` `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`, and Chromium takes the two inherited pipe **HANDLE values** via the separate switch `--remote-debugging-io-pipes=,` (uint32-serialized HANDLEs, passed **in addition to** the bare `--remote-debugging-pipe` — verified against `content_switches.cc` at chromium 144.0.7559.254; `--remote-debugging-pipe` itself takes no arguments on any platform). The default (non-agent) launch also needs a native `exec`/`CreateProcess` (Foundation.Process is macOS-only). |
+| **Sandbox** — bring the child processes into the Chromium sandbox in a signed/release build. | `process_helper.mm`: `CefScopedSandboxContext` (release only); `settings.no_sandbox` toggled by `CEF_HOST_ADHOC` (1426/1433). | **Windows**: CEF 144 ships **no `cef_sandbox.lib`** — the current model (`cef_sandbox_win.h`, CEF issue #3824) is: build your app as a **DLL** exporting `RunWinMain`/`RunConsoleMain` and launch it through the prebuilt `bootstrap.exe`/`bootstrapc.exe` from the CEF distribution, which establishes the sandbox before entering your code. **Linux**: the SUID / user-namespace sandbox helper. |
| **Framework / resource path** — point CEF at the CEF binary distribution. | `CefScopedLibraryLoader::LoadInMain`/`LoadInHelper`; `framework_dir_path` / `main_bundle_path` (1453/1466). | The equivalent paths for your bundle layout. |
| **Build + bundle + sign** | `native/build_cef_host.sh` (CMake, `CEF_MULTI_PROCESS` / `CEF_HOST_ADHOC` flags), `tool/bundle_cef_host.sh`. | A platform build that produces `cef_host` + the CEF runtime, and a bundling step into the host app. |
diff --git a/README.md b/README.md
index 6b0739c..471b158 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ 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
+c.onDownload = (suggestedName) {}; // a download started (a native Save panel is shown)
// open the Chrome DevTools inspector for this view in its own window
c.openDevTools();
@@ -223,11 +223,15 @@ final c = CefWebController(profile: 'work');
CefWebView(url: startUrl, controller: c);
```
-- **Persistent.** A named profile is stored on disk at
- `//flutter_cef/profiles/` (the directory
- is created `0700`, owner-only, and the profile name is sanitized to
- `[A-Za-z0-9._-]`). CEF is started with `persist_session_cookies` on, so a
- login survives `cef_host` and host-app relaunch.
+- **Persistent.** A named profile is stored on disk — on macOS at
+ `//flutter_cef/profiles/`, on Windows at
+ `%LOCALAPPDATA%\flutter_cef\profiles\`. The directory is created
+ owner-only (`0700` on macOS; a current-user-SID-protected DACL on Windows) and
+ the profile name is sanitized to `[A-Za-z0-9._-]`. CEF is started with
+ `persist_session_cookies` on, so a login survives `cef_host` and host-app
+ relaunch. On Windows the `profiles\` root is **not** namespaced by app, so every
+ flutter_cef app for a user shares it — a `profile: 'work'` in two apps resolves
+ to the same directory (co-locate only mutually-trusting apps on a shared name).
- **Shared.** Every view constructed with the same non-null `profile` is served
by **one `cef_host` process with one cookie jar** — they share one login.
Cookie writes are therefore process-wide: `clearCookies()` /
@@ -273,6 +277,17 @@ 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.
+**Windows is different — no downgrade, and no signing requirement.** On Windows,
+OSCrypt encrypts the cookie store with **DPAPI**, which is **always available and
+independent of code signing**. There is no mock-keychain state, so the
+ad-hoc-downgrade rule above has no Windows analogue: a named `profile:` on Windows
+simply persists, encrypted at rest, in every build. The trade-off is that DPAPI's
+user-tier protection is **same-user-readable** — any process running as the same
+Windows user can decrypt the store (`CryptUnprotectData`). This is **weaker than
+the macOS Keychain**: at-rest protection covers other users and offline disk theft
+(with BitLocker as the full-disk backstop), but **not** other same-user processes.
+As on macOS, `localStorage` / IndexedDB are stored unencrypted regardless.
+
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
@@ -366,12 +381,15 @@ SIGABRT'd the instant the hardware is touched. Declare
### 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.**
+**macOS only.** 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.** On **Windows** this never happens: OSCrypt uses
+DPAPI, which is always available and signing-independent, so a named profile
+persists in every build (see [Secrets at rest](#secrets-at-rest) for the
+same-user-readable caveat).
### `onProcessGone` fires with reason `'locked'`
@@ -438,7 +456,13 @@ Next:
`flutter_cef_platform_interface` + `flutter_cef_macos`); a new platform is a
sibling `flutter_cef_` package. The CEF logic + IPC protocol are portable;
each OS supplies its own host plugin + shared-texture / transport / sandbox
- glue. See [`PORTING.md`](PORTING.md) for the full contract and seam map.
+ glue. See [`PORTING.md`](PORTING.md) for the full contract and seam map. A
+ Windows port (`flutter_cef_windows`) is in progress: the Dart controller /
+ widget surface is already cross-platform (the JS bridge, JS dialogs,
+ find-in-page, content zoom, and downloads all speak the same
+ method-channel/wire protocol on both OSes — see
+ `packages/flutter_cef_windows/native/cef_host/PROTOCOL.md`), with the Windows
+ host's sandbox + code-signing story still pending.
## Credits
diff --git a/example/.metadata b/example/.metadata
new file mode 100644
index 0000000..9547b20
--- /dev/null
+++ b/example/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "bd7a4a6b5576630823ca344e3e684c53aa1a0f46"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46
+ base_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46
+ - platform: windows
+ create_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46
+ base_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/example/lib/agentcontrol_probe.dart b/example/lib/agentcontrol_probe.dart
new file mode 100644
index 0000000..2e41f66
--- /dev/null
+++ b/example/lib/agentcontrol_probe.dart
@@ -0,0 +1,304 @@
+// Agent-control (P9) end-to-end probe — drives the Windows token-gated loopback
+// CDP relay through a real CDP WebSocket client, entirely in-process (no
+// Playwright / node / python needed: dart:io's WebSocket does the RFC-6455
+// handshake and forwards a custom Authorization header).
+//
+// It:
+// 1. creates a CefWebController with agentControl:true, loads a page,
+// 2. calls enableAgentControl() -> {wsUrl, token, port} and writes it to
+// C:\tmp\cef_agentcontrol.json,
+// 3. GATE 2a (401): connects a ws client WITHOUT the token -> asserts 401,
+// 4. GATE 2b (evaluate=2): connects WITH `Authorization: Bearer `,
+// drives Target.getTargets -> attachToTarget(flatten) -> Runtime.evaluate
+// ("1+1") and asserts the result is 2,
+// 5. GATE 3 (teardown): disableAgentControl() then asserts a fresh connect
+// to the port fails (the relay is gone).
+//
+// Run (cef_host must be built + staged; CEF cached):
+// cd example
+// run -d windows -t lib/agentcontrol_probe.dart
+// Result: `CEF_AGENTCONTROL_RESULT …` on stdout + C:\tmp\cef_agentcontrol.json.
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_cef/flutter_cef.dart';
+
+const _resultPath = r'C:\tmp\cef_agentcontrol.json';
+
+void main() => runApp(const ProbeApp());
+
+class ProbeApp extends StatefulWidget {
+ const ProbeApp({super.key});
+ @override
+ State createState() => _ProbeAppState();
+}
+
+class _ProbeAppState extends State {
+ final CefWebController _c = CefWebController();
+ bool _started = false;
+ String _status = 'starting…';
+ final List _log = [];
+
+ void _note(String s) {
+ // ignore: avoid_print
+ print('CEF_AGENTCONTROL $s');
+ _log.add(s);
+ if (mounted) setState(() => _status = s);
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _c.onPageStarted = (url) {
+ _note('onPageStarted url=$url');
+ if (!_started) {
+ _started = true;
+ // Give the page target a beat to commit, then drive the gates.
+ Future.delayed(const Duration(seconds: 2), _run);
+ }
+ };
+ _c.onConsoleMessage = (m) => _note('console: ${m.message}');
+ }
+
+ Future _run() async {
+ final out = {
+ 'enable_ok': false,
+ 'gate_401': false,
+ 'gate_evaluate_2': false,
+ 'gate_teardown': false,
+ 'log': _log,
+ };
+ try {
+ // ---- 1. enableAgentControl -> {wsUrl, token, port} ----
+ final ep = await _c.enableAgentControl();
+ if (ep == null) {
+ _note('FAIL enableAgentControl returned null');
+ _write(out);
+ return;
+ }
+ out['enable_ok'] = true;
+ out['wsUrl'] = ep.wsUrl;
+ out['token'] = ep.token;
+ out['port'] = ep.port;
+ _note('enableAgentControl OK port=${ep.port} wsUrl=${ep.wsUrl}');
+
+ final base = 'ws://127.0.0.1:${ep.port}/devtools/browser';
+
+ // ---- GATE 2a: upgrade WITHOUT a token -> HTTP 401 ----
+ // A raw upgrade request surfaces the exact status line (dart:io's
+ // WebSocketException hides the code). Cross-check that the SAME request
+ // WITH the token upgrades (101), proving the token is what gates it.
+ final statusNoToken = await _rawUpgradeStatusLine(ep.port);
+ final statusWithToken =
+ await _rawUpgradeStatusLine(ep.port, token: ep.token);
+ final is401 = statusNoToken.contains('401');
+ out['gate_401'] = is401 && statusWithToken.contains('101');
+ out['gate_401_no_token_status'] = statusNoToken;
+ out['gate_401_with_token_status'] = statusWithToken;
+ _note('GATE 401 no-token -> "$statusNoToken"');
+ _note('GATE 401 with-token -> "$statusWithToken"');
+ _note('GATE 401 ${out['gate_401'] == true ? 'PASS' : 'FAIL'}');
+
+ // ---- GATE 2b: connect WITH the bearer token, evaluate 1+1 == 2 ----
+ // The with-token raw probe above briefly held the single-client slot;
+ // give the relay a beat to release it so this real upgrade isn't 503'd.
+ await Future.delayed(const Duration(milliseconds: 500));
+ final ws = await WebSocket.connect(
+ base,
+ headers: {'Authorization': 'Bearer ${ep.token}'},
+ );
+ _note('connected with bearer token');
+ final cdp = _CdpConn(ws);
+
+ final targets = await cdp.send('Target.getTargets');
+ final infos = (targets['result']?['targetInfos'] as List?) ?? const [];
+ _note('Target.getTargets -> ${infos.length} target(s)');
+ final page = infos.cast
void dispose() {
_webFocus.dispose();
_urlBar.dispose();
+ _findBar.dispose();
+ _findFocus.dispose();
_controller.dispose();
super.dispose();
}
@@ -243,16 +299,30 @@ and committed text — including emoji — should appear intact.
tooltip: 'getCookies() for the current page',
onPressed: _dumpCookies,
),
+ IconButton(
+ icon: const Icon(Icons.forum_outlined),
+ tooltip: 'Post a message from the page over a JS channel',
+ onPressed: _pingChannel,
+ ),
+ IconButton(
+ icon: const Icon(Icons.search),
+ tooltip: 'Find in page (also ⌘F / Ctrl+F)',
+ onPressed: _openFindBar,
+ ),
IconButton(
icon: const Icon(Icons.bug_report_outlined),
tooltip: 'openDevTools()',
onPressed: _controller.openDevTools,
),
- IconButton(
- icon: const Icon(Icons.emoji_emotions_outlined),
- tooltip: 'showEmojiPicker() (focus a field first)',
- onPressed: _emojiPicker,
- ),
+ // macOS-only: showEmojiPicker drives the AppKit Character
+ // Palette; there is no supported Win32 equivalent (PLAN §6),
+ // so the button is hidden per-platform.
+ if (Platform.isMacOS)
+ IconButton(
+ icon: const Icon(Icons.emoji_emotions_outlined),
+ tooltip: 'showEmojiPicker() (focus a field first)',
+ onPressed: _emojiPicker,
+ ),
IconButton(
icon: const Icon(Icons.keyboard),
tooltip: 'Load the IME / text-input test page',
@@ -294,6 +364,7 @@ and committed text — including emoji — should appear intact.
? const LinearProgressIndicator(minHeight: 2)
: const SizedBox(height: 2),
),
+ if (_findVisible) _buildFindBar(),
ValueListenableBuilder(
valueListenable: _controller.title,
builder: (_, title, _) => Align(
@@ -320,6 +391,9 @@ and committed text — including emoji — should appear intact.
url: _startUrl,
controller: _controller,
focusNode: _webFocus,
+ // ⌘F / Ctrl+F opens the host's find bar (the view has none of
+ // its own); it drives controller.find / stopFind.
+ onFind: _openFindBar,
allowedSchemes: _allowedSchemes,
// Persistent, shared profile: login survives relaunch and is
// shared by every view with the same name. Null (default) is an
@@ -338,6 +412,52 @@ and committed text — including emoji — should appear intact.
);
}
+ /// The host-owned find bar (⌘F / Ctrl+F). Drives [CefWebController.find] /
+ /// `stopFind` and shows the `active/total` match count from `onFindResult`.
+ Widget _buildFindBar() {
+ final r = _findResult;
+ final label = (r == null || _findBar.text.isEmpty)
+ ? ''
+ : '${r.activeMatchOrdinal}/${r.numberOfMatches}';
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ child: Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _findBar,
+ focusNode: _findFocus,
+ autofocus: true,
+ decoration: InputDecoration(
+ isDense: true,
+ border: const OutlineInputBorder(),
+ hintText: 'Find in page',
+ suffixText: label,
+ ),
+ onChanged: (_) => _runFind(),
+ onSubmitted: (_) => _runFind(findNext: true),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.keyboard_arrow_up),
+ tooltip: 'Previous match',
+ onPressed: () => _runFind(findNext: true, forward: false),
+ ),
+ IconButton(
+ icon: const Icon(Icons.keyboard_arrow_down),
+ tooltip: 'Next match',
+ onPressed: () => _runFind(findNext: true),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close),
+ tooltip: 'Close find bar',
+ onPressed: _closeFindBar,
+ ),
+ ],
+ ),
+ );
+ }
+
Widget _navButton(
IconData icon,
ValueListenable enabled,
diff --git a/example/lib/profile_probe.dart b/example/lib/profile_probe.dart
new file mode 100644
index 0000000..292a4d0
--- /dev/null
+++ b/example/lib/profile_probe.dart
@@ -0,0 +1,333 @@
+// Windows profile + cookie END-TO-END probe (P6 foundation + P11 profile slice).
+//
+// Auto-running, no-interaction self-test that drives the REAL integrated stack —
+// the public Dart API (CefWebController) -> the method channel -> the Windows
+// plugin (flutter_cef_plugin.cpp) -> the shipped cef_host.exe beside the app.
+// It proves, via the cookie API alone (no GPU paint required — cookie verbs act
+// on the host's process-wide CefCookieManager jar, so no page needs to load):
+//
+// SHARED JAR — two controllers on ONE named profile ('evi_shared') land on
+// ONE cef_host (plugin ResolveOrSpawnHost de-dups by profile
+// key) -> ONE jar: a cookie set via tile A is read back via B.
+// ISOLATION — a DIFFERENT named profile ('evi_other') and an EPHEMERAL
+// (no-profile) session do NOT see 'evi_shared''s cookie
+// (distinct profile dirs / distinct hosts / distinct jars).
+// PERSISTENCE — a 'marker' cookie in a persistent profile ('evi_persist') is
+// read at startup (the "before"), then rewritten with a fresh
+// nonce (the "after"). Across two launches, launch-2's "before"
+// equals launch-1's "after" -> the jar survived full app exit.
+// (Each run's dispose() sends kOpShutdown -> host flushes the
+// on-disk SQLite cookie store; persist_session_cookies=1.)
+//
+// Results are written to C:\dev\flutter_cef_spikes\profile_evidence\ as a JSON
+// transcript (authoritative) and rendered on screen (for the screenshot), and a
+// `CEF_PROBE_RESULT …` line is printed to stdout.
+//
+// Run (cef_host.exe is copied beside the built example exe):
+// flutter build windows --debug -t lib/profile_probe.dart
+// .\build\windows\x64\runner\Debug\flutter_cef_example.exe
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_cef/flutter_cef.dart';
+
+// A dev probe artifact — write evidence under the OS temp dir, not a
+// maintainer-specific absolute path (PLAN P1: probe outputs -> systemTemp).
+final _evidenceDir =
+ '${Directory.systemTemp.path}${Platform.pathSeparator}flutter_cef_profile_evidence';
+const _sharedProfile = 'evi_shared';
+const _otherProfile = 'evi_other';
+const _persistProfile = 'evi_persist';
+
+// A stable cookie scope. setCookie/getCookies operate on the jar directly, so no
+// navigation to this host ever happens — it is purely a cookie domain/path key.
+const _sharedUrl = 'https://shared.evi.example/';
+const _persistUrl = 'https://persist.evi.example/';
+
+void main() => runApp(const ProbeApp());
+
+class ProbeApp extends StatefulWidget {
+ const ProbeApp({super.key});
+ @override
+ State createState() => _ProbeAppState();
+}
+
+class _ProbeAppState extends State {
+ final Map _checks = {};
+ final List _lines = [];
+ String _status = 'starting…';
+ bool _done = false;
+ bool _pass = false;
+
+ void _check(String name, bool cond) {
+ _checks[name] = cond;
+ _log('${cond ? "PASS" : "FAIL"} $name');
+ // ignore: avoid_print
+ print('CEF_PROBE_CHECK ${cond ? "PASS" : "FAIL"} $name');
+ }
+
+ void _log(String s) {
+ _lines.add(s);
+ // ignore: avoid_print
+ print('CEF_PROBE_LOG $s');
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) => _run());
+ }
+
+ // Create a session for [c] directly through the controller (no CefWebView
+ // needed — we only exercise the cookie API). Cookie verbs issued before the
+ // host is ready are queued plugin-side and flushed on kOpReady, so the futures
+ // below naturally wait for the host to come up.
+ Future _create(CefWebController c) =>
+ c.create(url: 'about:blank', width: 400, height: 300, dpr: 1.0)
+ .timeout(const Duration(seconds: 20));
+
+ // Read cookies, retrying until the host answers. The host's kOpCreateBrowser
+ // registers the browser slot on an ASYNC UI task; a per-slot verb (visitCookies)
+ // that reaches the reader thread before that task runs finds no slot and is
+ // silently dropped (no reply). A real consumer queries cookies long after first
+ // paint, so it never races; this probe fires immediately, so we re-issue the
+ // visit until one round-trips — which also proves the slot is now live.
+ Future> _cookies(CefWebController c, String url) async {
+ Object? lastErr;
+ for (var attempt = 0; attempt < 20; attempt++) {
+ try {
+ return await c
+ .getCookies(url: url)
+ .timeout(const Duration(milliseconds: 1500));
+ } catch (e) {
+ lastErr = e;
+ await Future.delayed(const Duration(milliseconds: 500));
+ }
+ }
+ throw StateError('getCookies never answered (host slot not ready?): $lastErr');
+ }
+
+ // Gate on the browser slot being live before a fire-and-forget write, so the
+ // host doesn't drop the setCookie the same way (an unanswered write is silent).
+ Future _waitReady(CefWebController c) async {
+ await _cookies(c, ''); // a successful visit proves the slot exists
+ }
+
+ String? _valueOf(List cs, String name) {
+ for (final c in cs) {
+ if (c.name == name) return c.value;
+ }
+ return null;
+ }
+
+ Future _run() async {
+ final out = {
+ 'platform': Platform.operatingSystem,
+ 'startedAt': DateTime.now().toIso8601String(),
+ };
+ final nonce = DateTime.now().millisecondsSinceEpoch.toString();
+ out['nonce'] = nonce;
+
+ // Sequentially constructed + created so host allocation is deterministic:
+ // sharedA spawns the 'evi_shared' host; sharedB REUSES it (same profile key).
+ final persist = CefWebController(profile: _persistProfile);
+ final sharedA = CefWebController(profile: _sharedProfile);
+ final sharedB = CefWebController(profile: _sharedProfile);
+ final other = CefWebController(profile: _otherProfile);
+ final ephemeral = CefWebController(); // no profile
+
+ try {
+ // ── PERSISTENCE: read the marker BEFORE (survivor of a prior launch) ──
+ setState(() => _status = 'creating persist host…');
+ await _create(persist);
+ final beforeCookies = await _cookies(persist, _persistUrl);
+ final beforeMarker = _valueOf(beforeCookies, 'marker');
+ out['persist_before'] = beforeMarker;
+ _log('persist BEFORE marker = ${beforeMarker ?? "(none — first launch)"}');
+ // Write a fresh marker (the AFTER). Read it back in-session to confirm the
+ // host committed it before we ask the next launch to find it.
+ await persist.setCookie(
+ url: _persistUrl, name: 'marker', value: nonce, path: '/');
+ final afterCookies = await _cookies(persist, _persistUrl);
+ final afterMarker = _valueOf(afterCookies, 'marker');
+ out['persist_after'] = afterMarker;
+ _log('persist AFTER marker = ${afterMarker ?? "(write failed)"}');
+ _check('PERSIST: marker written + read back this session',
+ afterMarker == nonce);
+ final priorSeen = File('$_evidenceDir\\last_after_nonce.txt');
+ if (beforeMarker != null) {
+ _check('PERSIST: a marker survived a PRIOR full app exit', true);
+ out['persist_survived_prior'] = true;
+ } else {
+ _log('PERSIST: no prior marker (this is launch #1 — rerun to prove '
+ 'cross-launch survival; run #2 BEFORE should equal this nonce)');
+ out['persist_survived_prior'] = false;
+ }
+
+ // ── SHARED JAR: two controllers, one profile, one host, one jar ──
+ setState(() => _status = 'creating shared-jar hosts (A reuses via B)…');
+ await _create(sharedA);
+ await _create(sharedB); // reuses sharedA's host (same profile key)
+ await _waitReady(sharedA); // gate the write on sharedA's slot being live
+ await sharedA.setCookie(
+ url: _sharedUrl, name: 'sjar', value: nonce, path: '/');
+ // Read from B: if B is on A's host + jar, it sees A's write.
+ final bSees = await _cookies(sharedB, _sharedUrl);
+ final bVal = _valueOf(bSees, 'sjar');
+ out['sharedB_sjar'] = bVal;
+ _log('sharedB sees sjar = ${bVal ?? "(NOT VISIBLE)"} (set via sharedA)');
+ _check('SHARED JAR: tile B reads the cookie tile A set (one host, one jar)',
+ bVal == nonce);
+
+ // ── ISOLATION: a different named profile can't see evi_shared's cookie ──
+ setState(() => _status = 'creating isolation hosts…');
+ await _create(other);
+ final otherSees = await _cookies(other, _sharedUrl);
+ final otherVal = _valueOf(otherSees, 'sjar');
+ out['other_sjar'] = otherVal;
+ _log("other-profile sees sjar = ${otherVal ?? "(none — isolated ✓)"}");
+ _check("ISOLATION: a DIFFERENT named profile does NOT see evi_shared's "
+ 'cookie', otherVal == null);
+
+ // ── ISOLATION: an ephemeral (no-profile) session can't see it either ──
+ await _create(ephemeral);
+ final ephSees = await _cookies(ephemeral, _sharedUrl);
+ final ephVal = _valueOf(ephSees, 'sjar');
+ out['ephemeral_sjar'] = ephVal;
+ _log('ephemeral sees sjar = ${ephVal ?? "(none — isolated ✓)"}');
+ _check('ISOLATION: an EPHEMERAL (no-profile) session does NOT see '
+ "evi_shared's cookie", ephVal == null);
+
+ // Record this run's AFTER nonce so a human/manifest can correlate the next
+ // launch's BEFORE with it.
+ try {
+ Directory(_evidenceDir).createSync(recursive: true);
+ priorSeen.writeAsStringSync(nonce);
+ } catch (_) {}
+ } catch (e, st) {
+ out['fatal'] = '$e';
+ out['stack'] = '$st';
+ _log('FATAL: $e');
+ }
+
+ // Dispose every host — dispose() sends kOpShutdown so the persist host FLUSHES
+ // its on-disk cookie store before it exits (what makes the marker survive the
+ // next launch). Tear down the OTHER hosts FIRST and let their whole Chromium
+ // process trees die, so the persist host's clean-shutdown cookie flush isn't
+ // racing ~25 sibling processes for CPU inside the teardown reaper's kill
+ // window — then dispose persist LAST on a quiet machine.
+ setState(() => _status = 'disposing sibling hosts…');
+ for (final c in [sharedA, sharedB, other, ephemeral]) {
+ try {
+ await c.dispose();
+ } catch (_) {}
+ }
+ await Future.delayed(const Duration(seconds: 4));
+ setState(() => _status = 'flushing + disposing persist host…');
+ try {
+ await persist.dispose();
+ } catch (_) {}
+ // Wait for the persist host process tree to fully exit (reaper bound 3s +
+ // margin) so its cookie store is flushed AND its profile lock is released
+ // before we re-open the profile.
+ await Future.delayed(const Duration(seconds: 6));
+
+ // ── PERSISTENCE (in-app): reopen the SAME profile in a FRESH host and read
+ // the marker back. This proves the plugin's dispose()->kOpShutdown flushed
+ // the on-disk store and a new host reloads it — independent of app exit. ──
+ setState(() => _status = 'reopening persist profile in a fresh host…');
+ final persist2 = CefWebController(profile: _persistProfile);
+ try {
+ await _create(persist2);
+ final rebornCookies = await _cookies(persist2, _persistUrl);
+ final rebornMarker = _valueOf(rebornCookies, 'marker');
+ out['persist_reborn'] = rebornMarker;
+ _log('persist REBORN marker = ${rebornMarker ?? "(LOST across host restart)"}');
+ _check('PERSIST: marker survived a host restart on the SAME profile '
+ '(in-app flush + reload)', rebornMarker == nonce);
+ } catch (e) {
+ out['persist_reborn_error'] = '$e';
+ _check('PERSIST: marker survived a host restart on the SAME profile '
+ '(in-app flush + reload)', false);
+ } finally {
+ try {
+ await persist2.dispose();
+ } catch (_) {}
+ }
+ await Future.delayed(const Duration(seconds: 3));
+
+ out['checks'] = _checks;
+ final pass = _checks.isNotEmpty && _checks.values.every((v) => v);
+ out['pass'] = pass;
+ out['finishedAt'] = DateTime.now().toIso8601String();
+ try {
+ Directory(_evidenceDir).createSync(recursive: true);
+ final stamp = DateTime.now()
+ .toIso8601String()
+ .replaceAll(':', '-')
+ .replaceAll('.', '-');
+ File('$_evidenceDir\\profile_probe_$stamp.json')
+ .writeAsStringSync(const JsonEncoder.withIndent(' ').convert(out));
+ File('$_evidenceDir\\profile_probe_latest.json')
+ .writeAsStringSync(const JsonEncoder.withIndent(' ').convert(out));
+ } catch (_) {}
+ // ignore: avoid_print
+ print('CEF_PROBE_RESULT ${jsonEncode(out)}');
+ if (mounted) {
+ setState(() {
+ _done = true;
+ _pass = pass;
+ _status = pass
+ ? 'ALL PASS (${_checks.length} checks)'
+ : 'FAIL — see $_evidenceDir';
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final color = !_done
+ ? Colors.blueGrey
+ : (_pass ? const Color(0xFF1B5E20) : const Color(0xFFB71C1C));
+ return MaterialApp(
+ debugShowCheckedModeBanner: false,
+ home: Scaffold(
+ backgroundColor: color,
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ _done ? (_pass ? 'PROFILE PROBE: ALL PASS' : 'PROFILE PROBE: FAIL')
+ : 'PROFILE PROBE — $_status',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 26,
+ fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 12),
+ Expanded(
+ child: SingleChildScrollView(
+ child: Text(
+ _lines.join('\n'),
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 15,
+ fontFamily: 'monospace',
+ height: 1.5),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 07319cc..6183241 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -91,6 +91,13 @@ packages:
relative: true
source: path
version: "0.1.3"
+ flutter_cef_windows:
+ dependency: transitive
+ description:
+ path: "../packages/flutter_cef_windows"
+ relative: true
+ source: path
+ version: "0.1.0"
flutter_driver:
dependency: transitive
description: flutter
diff --git a/example/windows/.gitignore b/example/windows/.gitignore
new file mode 100644
index 0000000..ec4098a
--- /dev/null
+++ b/example/windows/.gitignore
@@ -0,0 +1,17 @@
+flutter/ephemeral/
+
+# Visual Studio user-specific files.
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# Visual Studio build-related files.
+x64/
+x86/
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!*.[Cc]ache/
diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt
new file mode 100644
index 0000000..689a8de
--- /dev/null
+++ b/example/windows/CMakeLists.txt
@@ -0,0 +1,108 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.14)
+project(flutter_cef_example LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "flutter_cef_example")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(VERSION 3.14...3.25)
+
+# Define build configuration option.
+get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
+if(IS_MULTICONFIG)
+ set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
+ CACHE STRING "" FORCE)
+else()
+ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+ endif()
+endif()
+# Define settings for the Profile build mode.
+set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
+set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
+set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
+set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
+
+# Use Unicode for all projects.
+add_definitions(-DUNICODE -D_UNICODE)
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_17)
+ target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
+ target_compile_options(${TARGET} PRIVATE /EHsc)
+ target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
+ target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# Support files are copied into place next to the executable, so that it can
+# run in place. This is done instead of making a separate bundle (as on Linux)
+# so that building and running from within Visual Studio will work.
+set(BUILD_BUNDLE_DIR "$")
+# Make the "install" step default, as it's required to run.
+set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+if(PLUGIN_BUNDLED_LIBRARIES)
+ install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ CONFIGURATIONS Profile;Release
+ COMPONENT Runtime)
diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt
new file mode 100644
index 0000000..efb62eb
--- /dev/null
+++ b/example/windows/flutter/CMakeLists.txt
@@ -0,0 +1,109 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.14)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
+
+# Set fallback configurations for older versions of the flutter tool.
+if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
+ set(FLUTTER_TARGET_PLATFORM "windows-x64")
+endif()
+
+# === Flutter Library ===
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "flutter_export.h"
+ "flutter_windows.h"
+ "flutter_messenger.h"
+ "flutter_plugin_registrar.h"
+ "flutter_texture_registrar.h"
+)
+list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
+add_dependencies(flutter flutter_assemble)
+
+# === Wrapper ===
+list(APPEND CPP_WRAPPER_SOURCES_CORE
+ "core_implementations.cc"
+ "standard_codec.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
+list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
+ "plugin_registrar.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
+list(APPEND CPP_WRAPPER_SOURCES_APP
+ "flutter_engine.cc"
+ "flutter_view_controller.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
+
+# Wrapper sources needed for a plugin.
+add_library(flutter_wrapper_plugin STATIC
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_PLUGIN}
+)
+apply_standard_settings(flutter_wrapper_plugin)
+set_target_properties(flutter_wrapper_plugin PROPERTIES
+ POSITION_INDEPENDENT_CODE ON)
+set_target_properties(flutter_wrapper_plugin PROPERTIES
+ CXX_VISIBILITY_PRESET hidden)
+target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
+target_include_directories(flutter_wrapper_plugin PUBLIC
+ "${WRAPPER_ROOT}/include"
+)
+add_dependencies(flutter_wrapper_plugin flutter_assemble)
+
+# Wrapper sources needed for the runner.
+add_library(flutter_wrapper_app STATIC
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_APP}
+)
+apply_standard_settings(flutter_wrapper_app)
+target_link_libraries(flutter_wrapper_app PUBLIC flutter)
+target_include_directories(flutter_wrapper_app PUBLIC
+ "${WRAPPER_ROOT}/include"
+)
+add_dependencies(flutter_wrapper_app flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
+set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
+ ${CPP_WRAPPER_SOURCES_APP}
+ ${PHONY_OUTPUT}
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
+ ${FLUTTER_TARGET_PLATFORM} $
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_PLUGIN}
+ ${CPP_WRAPPER_SOURCES_APP}
+)
diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..2f708aa
--- /dev/null
+++ b/example/windows/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,14 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+#include
+
+void RegisterPlugins(flutter::PluginRegistry* registry) {
+ FlutterCefPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("FlutterCefPlugin"));
+}
diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..dc139d8
--- /dev/null
+++ b/example/windows/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void RegisterPlugins(flutter::PluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..2960d35
--- /dev/null
+++ b/example/windows/flutter/generated_plugins.cmake
@@ -0,0 +1,24 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+ flutter_cef_windows
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/example/windows/runner/CMakeLists.txt b/example/windows/runner/CMakeLists.txt
new file mode 100644
index 0000000..2041a04
--- /dev/null
+++ b/example/windows/runner/CMakeLists.txt
@@ -0,0 +1,40 @@
+cmake_minimum_required(VERSION 3.14)
+project(runner LANGUAGES CXX)
+
+# Define the application target. To change its name, change BINARY_NAME in the
+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
+# work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME} WIN32
+ "flutter_window.cpp"
+ "main.cpp"
+ "utils.cpp"
+ "win32_window.cpp"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+ "Runner.rc"
+ "runner.exe.manifest"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add preprocessor definitions for the build version.
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
+
+# Disable Windows macros that collide with C++ standard library functions.
+target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
+
+# Add dependency libraries and include directories. Add any application-specific
+# dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
+target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc
new file mode 100644
index 0000000..fc0a490
--- /dev/null
+++ b/example/windows/runner/Runner.rc
@@ -0,0 +1,121 @@
+// Microsoft Visual C++ generated resource script.
+//
+#pragma code_page(65001)
+#include "resource.h"
+
+#define APSTUDIO_READONLY_SYMBOLS
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 2 resource.
+//
+#include "winres.h"
+
+/////////////////////////////////////////////////////////////////////////////
+#undef APSTUDIO_READONLY_SYMBOLS
+
+/////////////////////////////////////////////////////////////////////////////
+// English (United States) resources
+
+#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
+LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
+
+#ifdef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// TEXTINCLUDE
+//
+
+1 TEXTINCLUDE
+BEGIN
+ "resource.h\0"
+END
+
+2 TEXTINCLUDE
+BEGIN
+ "#include ""winres.h""\r\n"
+ "\0"
+END
+
+3 TEXTINCLUDE
+BEGIN
+ "\r\n"
+ "\0"
+END
+
+#endif // APSTUDIO_INVOKED
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Icon
+//
+
+// Icon with lowest ID value placed first to ensure application icon
+// remains consistent on all systems.
+IDI_APP_ICON ICON "resources\\app_icon.ico"
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Version
+//
+
+#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
+#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
+#else
+#define VERSION_AS_NUMBER 1,0,0,0
+#endif
+
+#if defined(FLUTTER_VERSION)
+#define VERSION_AS_STRING FLUTTER_VERSION
+#else
+#define VERSION_AS_STRING "1.0.0"
+#endif
+
+VS_VERSION_INFO VERSIONINFO
+ FILEVERSION VERSION_AS_NUMBER
+ PRODUCTVERSION VERSION_AS_NUMBER
+ FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
+#ifdef _DEBUG
+ FILEFLAGS VS_FF_DEBUG
+#else
+ FILEFLAGS 0x0L
+#endif
+ FILEOS VOS__WINDOWS32
+ FILETYPE VFT_APP
+ FILESUBTYPE 0x0L
+BEGIN
+ BLOCK "StringFileInfo"
+ BEGIN
+ BLOCK "040904e4"
+ BEGIN
+ VALUE "CompanyName", "com.example" "\0"
+ VALUE "FileDescription", "flutter_cef_example" "\0"
+ VALUE "FileVersion", VERSION_AS_STRING "\0"
+ VALUE "InternalName", "flutter_cef_example" "\0"
+ VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
+ VALUE "OriginalFilename", "flutter_cef_example.exe" "\0"
+ VALUE "ProductName", "flutter_cef_example" "\0"
+ VALUE "ProductVersion", VERSION_AS_STRING "\0"
+ END
+ END
+ BLOCK "VarFileInfo"
+ BEGIN
+ VALUE "Translation", 0x409, 1252
+ END
+END
+
+#endif // English (United States) resources
+/////////////////////////////////////////////////////////////////////////////
+
+
+
+#ifndef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 3 resource.
+//
+
+
+/////////////////////////////////////////////////////////////////////////////
+#endif // not APSTUDIO_INVOKED
diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp
new file mode 100644
index 0000000..c819cb0
--- /dev/null
+++ b/example/windows/runner/flutter_window.cpp
@@ -0,0 +1,71 @@
+#include "flutter_window.h"
+
+#include
+
+#include "flutter/generated_plugin_registrant.h"
+
+FlutterWindow::FlutterWindow(const flutter::DartProject& project)
+ : project_(project) {}
+
+FlutterWindow::~FlutterWindow() {}
+
+bool FlutterWindow::OnCreate() {
+ if (!Win32Window::OnCreate()) {
+ return false;
+ }
+
+ RECT frame = GetClientArea();
+
+ // The size here must match the window dimensions to avoid unnecessary surface
+ // creation / destruction in the startup path.
+ flutter_controller_ = std::make_unique(
+ frame.right - frame.left, frame.bottom - frame.top, project_);
+ // Ensure that basic setup of the controller was successful.
+ if (!flutter_controller_->engine() || !flutter_controller_->view()) {
+ return false;
+ }
+ RegisterPlugins(flutter_controller_->engine());
+ SetChildContent(flutter_controller_->view()->GetNativeWindow());
+
+ flutter_controller_->engine()->SetNextFrameCallback([&]() {
+ this->Show();
+ });
+
+ // Flutter can complete the first frame before the "show window" callback is
+ // registered. The following call ensures a frame is pending to ensure the
+ // window is shown. It is a no-op if the first frame hasn't completed yet.
+ flutter_controller_->ForceRedraw();
+
+ return true;
+}
+
+void FlutterWindow::OnDestroy() {
+ if (flutter_controller_) {
+ flutter_controller_ = nullptr;
+ }
+
+ Win32Window::OnDestroy();
+}
+
+LRESULT
+FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ // Give Flutter, including plugins, an opportunity to handle window messages.
+ if (flutter_controller_) {
+ std::optional result =
+ flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
+ lparam);
+ if (result) {
+ return *result;
+ }
+ }
+
+ switch (message) {
+ case WM_FONTCHANGE:
+ flutter_controller_->engine()->ReloadSystemFonts();
+ break;
+ }
+
+ return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
+}
diff --git a/example/windows/runner/flutter_window.h b/example/windows/runner/flutter_window.h
new file mode 100644
index 0000000..28c2383
--- /dev/null
+++ b/example/windows/runner/flutter_window.h
@@ -0,0 +1,33 @@
+#ifndef RUNNER_FLUTTER_WINDOW_H_
+#define RUNNER_FLUTTER_WINDOW_H_
+
+#include
+#include
+
+#include
+
+#include "win32_window.h"
+
+// A window that does nothing but host a Flutter view.
+class FlutterWindow : public Win32Window {
+ public:
+ // Creates a new FlutterWindow hosting a Flutter view running |project|.
+ explicit FlutterWindow(const flutter::DartProject& project);
+ virtual ~FlutterWindow();
+
+ protected:
+ // Win32Window:
+ bool OnCreate() override;
+ void OnDestroy() override;
+ LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
+ LPARAM const lparam) noexcept override;
+
+ private:
+ // The project to run.
+ flutter::DartProject project_;
+
+ // The Flutter instance hosted by this window.
+ std::unique_ptr flutter_controller_;
+};
+
+#endif // RUNNER_FLUTTER_WINDOW_H_
diff --git a/example/windows/runner/main.cpp b/example/windows/runner/main.cpp
new file mode 100644
index 0000000..59586f7
--- /dev/null
+++ b/example/windows/runner/main.cpp
@@ -0,0 +1,43 @@
+#include
+#include
+#include
+
+#include "flutter_window.h"
+#include "utils.h"
+
+int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
+ _In_ wchar_t *command_line, _In_ int show_command) {
+ // Attach to console when present (e.g., 'flutter run') or create a
+ // new console when running with a debugger.
+ if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
+ CreateAndAttachConsole();
+ }
+
+ // Initialize COM, so that it is available for use in the library and/or
+ // plugins.
+ ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
+
+ flutter::DartProject project(L"data");
+
+ std::vector command_line_arguments =
+ GetCommandLineArguments();
+
+ project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
+
+ FlutterWindow window(project);
+ Win32Window::Point origin(10, 10);
+ Win32Window::Size size(1280, 720);
+ if (!window.Create(L"flutter_cef_example", origin, size)) {
+ return EXIT_FAILURE;
+ }
+ window.SetQuitOnClose(true);
+
+ ::MSG msg;
+ while (::GetMessage(&msg, nullptr, 0, 0)) {
+ ::TranslateMessage(&msg);
+ ::DispatchMessage(&msg);
+ }
+
+ ::CoUninitialize();
+ return EXIT_SUCCESS;
+}
diff --git a/example/windows/runner/resource.h b/example/windows/runner/resource.h
new file mode 100644
index 0000000..ddc7f3e
--- /dev/null
+++ b/example/windows/runner/resource.h
@@ -0,0 +1,16 @@
+//{{NO_DEPENDENCIES}}
+// Microsoft Visual C++ generated include file.
+// Used by Runner.rc
+//
+#define IDI_APP_ICON 101
+
+// Next default values for new objects
+//
+#ifdef APSTUDIO_INVOKED
+#ifndef APSTUDIO_READONLY_SYMBOLS
+#define _APS_NEXT_RESOURCE_VALUE 102
+#define _APS_NEXT_COMMAND_VALUE 40001
+#define _APS_NEXT_CONTROL_VALUE 1001
+#define _APS_NEXT_SYMED_VALUE 101
+#endif
+#endif
diff --git a/example/windows/runner/resources/app_icon.ico b/example/windows/runner/resources/app_icon.ico
new file mode 100644
index 0000000..c04e20c
Binary files /dev/null and b/example/windows/runner/resources/app_icon.ico differ
diff --git a/example/windows/runner/runner.exe.manifest b/example/windows/runner/runner.exe.manifest
new file mode 100644
index 0000000..4b962bb
--- /dev/null
+++ b/example/windows/runner/runner.exe.manifest
@@ -0,0 +1,14 @@
+
+
+
+
+ PerMonitorV2
+
+
+
+
+
+
+
+
+
diff --git a/example/windows/runner/utils.cpp b/example/windows/runner/utils.cpp
new file mode 100644
index 0000000..259d85b
--- /dev/null
+++ b/example/windows/runner/utils.cpp
@@ -0,0 +1,65 @@
+#include "utils.h"
+
+#include
+#include
+#include
+#include
+
+#include
+
+void CreateAndAttachConsole() {
+ if (::AllocConsole()) {
+ FILE *unused;
+ if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
+ _dup2(_fileno(stdout), 1);
+ }
+ if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
+ _dup2(_fileno(stdout), 2);
+ }
+ std::ios::sync_with_stdio();
+ FlutterDesktopResyncOutputStreams();
+ }
+}
+
+std::vector GetCommandLineArguments() {
+ // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
+ int argc;
+ wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
+ if (argv == nullptr) {
+ return std::vector();
+ }
+
+ std::vector command_line_arguments;
+
+ // Skip the first argument as it's the binary name.
+ for (int i = 1; i < argc; i++) {
+ command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
+ }
+
+ ::LocalFree(argv);
+
+ return command_line_arguments;
+}
+
+std::string Utf8FromUtf16(const wchar_t* utf16_string) {
+ if (utf16_string == nullptr) {
+ return std::string();
+ }
+ unsigned int target_length = ::WideCharToMultiByte(
+ CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
+ -1, nullptr, 0, nullptr, nullptr)
+ -1; // remove the trailing null character
+ int input_length = (int)wcslen(utf16_string);
+ std::string utf8_string;
+ if (target_length == 0 || target_length > utf8_string.max_size()) {
+ return utf8_string;
+ }
+ utf8_string.resize(target_length);
+ int converted_length = ::WideCharToMultiByte(
+ CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
+ input_length, utf8_string.data(), target_length, nullptr, nullptr);
+ if (converted_length == 0) {
+ return std::string();
+ }
+ return utf8_string;
+}
diff --git a/example/windows/runner/utils.h b/example/windows/runner/utils.h
new file mode 100644
index 0000000..3f0e05c
--- /dev/null
+++ b/example/windows/runner/utils.h
@@ -0,0 +1,19 @@
+#ifndef RUNNER_UTILS_H_
+#define RUNNER_UTILS_H_
+
+#include
+#include
+
+// Creates a console for the process, and redirects stdout and stderr to
+// it for both the runner and the Flutter library.
+void CreateAndAttachConsole();
+
+// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
+// encoded in UTF-8. Returns an empty std::string on failure.
+std::string Utf8FromUtf16(const wchar_t* utf16_string);
+
+// Gets the command line arguments passed in as a std::vector,
+// encoded in UTF-8. Returns an empty std::vector on failure.
+std::vector GetCommandLineArguments();
+
+#endif // RUNNER_UTILS_H_
diff --git a/example/windows/runner/win32_window.cpp b/example/windows/runner/win32_window.cpp
new file mode 100644
index 0000000..b5ba2a0
--- /dev/null
+++ b/example/windows/runner/win32_window.cpp
@@ -0,0 +1,288 @@
+#include "win32_window.h"
+
+#include
+#include
+
+#include "resource.h"
+
+namespace {
+
+/// Window attribute that enables dark mode window decorations.
+///
+/// Redefined in case the developer's machine has a Windows SDK older than
+/// version 10.0.22000.0.
+/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
+#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
+#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
+#endif
+
+constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
+
+/// Registry key for app theme preference.
+///
+/// A value of 0 indicates apps should use dark mode. A non-zero or missing
+/// value indicates apps should use light mode.
+constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
+ L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
+constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
+
+// The number of Win32Window objects that currently exist.
+static int g_active_window_count = 0;
+
+using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
+
+// Scale helper to convert logical scaler values to physical using passed in
+// scale factor
+int Scale(int source, double scale_factor) {
+ return static_cast(source * scale_factor);
+}
+
+// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
+// This API is only needed for PerMonitor V1 awareness mode.
+void EnableFullDpiSupportIfAvailable(HWND hwnd) {
+ HMODULE user32_module = LoadLibraryA("User32.dll");
+ if (!user32_module) {
+ return;
+ }
+ auto enable_non_client_dpi_scaling =
+ reinterpret_cast(
+ GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
+ if (enable_non_client_dpi_scaling != nullptr) {
+ enable_non_client_dpi_scaling(hwnd);
+ }
+ FreeLibrary(user32_module);
+}
+
+} // namespace
+
+// Manages the Win32Window's window class registration.
+class WindowClassRegistrar {
+ public:
+ ~WindowClassRegistrar() = default;
+
+ // Returns the singleton registrar instance.
+ static WindowClassRegistrar* GetInstance() {
+ if (!instance_) {
+ instance_ = new WindowClassRegistrar();
+ }
+ return instance_;
+ }
+
+ // Returns the name of the window class, registering the class if it hasn't
+ // previously been registered.
+ const wchar_t* GetWindowClass();
+
+ // Unregisters the window class. Should only be called if there are no
+ // instances of the window.
+ void UnregisterWindowClass();
+
+ private:
+ WindowClassRegistrar() = default;
+
+ static WindowClassRegistrar* instance_;
+
+ bool class_registered_ = false;
+};
+
+WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
+
+const wchar_t* WindowClassRegistrar::GetWindowClass() {
+ if (!class_registered_) {
+ WNDCLASS window_class{};
+ window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
+ window_class.lpszClassName = kWindowClassName;
+ window_class.style = CS_HREDRAW | CS_VREDRAW;
+ window_class.cbClsExtra = 0;
+ window_class.cbWndExtra = 0;
+ window_class.hInstance = GetModuleHandle(nullptr);
+ window_class.hIcon =
+ LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
+ window_class.hbrBackground = 0;
+ window_class.lpszMenuName = nullptr;
+ window_class.lpfnWndProc = Win32Window::WndProc;
+ RegisterClass(&window_class);
+ class_registered_ = true;
+ }
+ return kWindowClassName;
+}
+
+void WindowClassRegistrar::UnregisterWindowClass() {
+ UnregisterClass(kWindowClassName, nullptr);
+ class_registered_ = false;
+}
+
+Win32Window::Win32Window() {
+ ++g_active_window_count;
+}
+
+Win32Window::~Win32Window() {
+ --g_active_window_count;
+ Destroy();
+}
+
+bool Win32Window::Create(const std::wstring& title,
+ const Point& origin,
+ const Size& size) {
+ Destroy();
+
+ const wchar_t* window_class =
+ WindowClassRegistrar::GetInstance()->GetWindowClass();
+
+ const POINT target_point = {static_cast(origin.x),
+ static_cast(origin.y)};
+ HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
+ UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
+ double scale_factor = dpi / 96.0;
+
+ HWND window = CreateWindow(
+ window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
+ Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
+ Scale(size.width, scale_factor), Scale(size.height, scale_factor),
+ nullptr, nullptr, GetModuleHandle(nullptr), this);
+
+ if (!window) {
+ return false;
+ }
+
+ UpdateTheme(window);
+
+ return OnCreate();
+}
+
+bool Win32Window::Show() {
+ return ShowWindow(window_handle_, SW_SHOWNORMAL);
+}
+
+// static
+LRESULT CALLBACK Win32Window::WndProc(HWND const window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ if (message == WM_NCCREATE) {
+ auto window_struct = reinterpret_cast(lparam);
+ SetWindowLongPtr(window, GWLP_USERDATA,
+ reinterpret_cast(window_struct->lpCreateParams));
+
+ auto that = static_cast(window_struct->lpCreateParams);
+ EnableFullDpiSupportIfAvailable(window);
+ that->window_handle_ = window;
+ } else if (Win32Window* that = GetThisFromHandle(window)) {
+ return that->MessageHandler(window, message, wparam, lparam);
+ }
+
+ return DefWindowProc(window, message, wparam, lparam);
+}
+
+LRESULT
+Win32Window::MessageHandler(HWND hwnd,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ switch (message) {
+ case WM_DESTROY:
+ window_handle_ = nullptr;
+ Destroy();
+ if (quit_on_close_) {
+ PostQuitMessage(0);
+ }
+ return 0;
+
+ case WM_DPICHANGED: {
+ auto newRectSize = reinterpret_cast(lparam);
+ LONG newWidth = newRectSize->right - newRectSize->left;
+ LONG newHeight = newRectSize->bottom - newRectSize->top;
+
+ SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
+ newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
+
+ return 0;
+ }
+ case WM_SIZE: {
+ RECT rect = GetClientArea();
+ if (child_content_ != nullptr) {
+ // Size and position the child window.
+ MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
+ rect.bottom - rect.top, TRUE);
+ }
+ return 0;
+ }
+
+ case WM_ACTIVATE:
+ if (child_content_ != nullptr) {
+ SetFocus(child_content_);
+ }
+ return 0;
+
+ case WM_DWMCOLORIZATIONCOLORCHANGED:
+ UpdateTheme(hwnd);
+ return 0;
+ }
+
+ return DefWindowProc(window_handle_, message, wparam, lparam);
+}
+
+void Win32Window::Destroy() {
+ OnDestroy();
+
+ if (window_handle_) {
+ DestroyWindow(window_handle_);
+ window_handle_ = nullptr;
+ }
+ if (g_active_window_count == 0) {
+ WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
+ }
+}
+
+Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
+ return reinterpret_cast(
+ GetWindowLongPtr(window, GWLP_USERDATA));
+}
+
+void Win32Window::SetChildContent(HWND content) {
+ child_content_ = content;
+ SetParent(content, window_handle_);
+ RECT frame = GetClientArea();
+
+ MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
+ frame.bottom - frame.top, true);
+
+ SetFocus(child_content_);
+}
+
+RECT Win32Window::GetClientArea() {
+ RECT frame;
+ GetClientRect(window_handle_, &frame);
+ return frame;
+}
+
+HWND Win32Window::GetHandle() {
+ return window_handle_;
+}
+
+void Win32Window::SetQuitOnClose(bool quit_on_close) {
+ quit_on_close_ = quit_on_close;
+}
+
+bool Win32Window::OnCreate() {
+ // No-op; provided for subclasses.
+ return true;
+}
+
+void Win32Window::OnDestroy() {
+ // No-op; provided for subclasses.
+}
+
+void Win32Window::UpdateTheme(HWND const window) {
+ DWORD light_mode;
+ DWORD light_mode_size = sizeof(light_mode);
+ LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
+ kGetPreferredBrightnessRegValue,
+ RRF_RT_REG_DWORD, nullptr, &light_mode,
+ &light_mode_size);
+
+ if (result == ERROR_SUCCESS) {
+ BOOL enable_dark_mode = light_mode == 0;
+ DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
+ &enable_dark_mode, sizeof(enable_dark_mode));
+ }
+}
diff --git a/example/windows/runner/win32_window.h b/example/windows/runner/win32_window.h
new file mode 100644
index 0000000..49b847f
--- /dev/null
+++ b/example/windows/runner/win32_window.h
@@ -0,0 +1,102 @@
+#ifndef RUNNER_WIN32_WINDOW_H_
+#define RUNNER_WIN32_WINDOW_H_
+
+#include
+
+#include
+#include
+#include
+
+// A class abstraction for a high DPI-aware Win32 Window. Intended to be
+// inherited from by classes that wish to specialize with custom
+// rendering and input handling
+class Win32Window {
+ public:
+ struct Point {
+ unsigned int x;
+ unsigned int y;
+ Point(unsigned int x, unsigned int y) : x(x), y(y) {}
+ };
+
+ struct Size {
+ unsigned int width;
+ unsigned int height;
+ Size(unsigned int width, unsigned int height)
+ : width(width), height(height) {}
+ };
+
+ Win32Window();
+ virtual ~Win32Window();
+
+ // Creates a win32 window with |title| that is positioned and sized using
+ // |origin| and |size|. New windows are created on the default monitor. Window
+ // sizes are specified to the OS in physical pixels, hence to ensure a
+ // consistent size this function will scale the inputted width and height as
+ // as appropriate for the default monitor. The window is invisible until
+ // |Show| is called. Returns true if the window was created successfully.
+ bool Create(const std::wstring& title, const Point& origin, const Size& size);
+
+ // Show the current window. Returns true if the window was successfully shown.
+ bool Show();
+
+ // Release OS resources associated with window.
+ void Destroy();
+
+ // Inserts |content| into the window tree.
+ void SetChildContent(HWND content);
+
+ // Returns the backing Window handle to enable clients to set icon and other
+ // window properties. Returns nullptr if the window has been destroyed.
+ HWND GetHandle();
+
+ // If true, closing this window will quit the application.
+ void SetQuitOnClose(bool quit_on_close);
+
+ // Return a RECT representing the bounds of the current client area.
+ RECT GetClientArea();
+
+ protected:
+ // Processes and route salient window messages for mouse handling,
+ // size change and DPI. Delegates handling of these to member overloads that
+ // inheriting classes can handle.
+ virtual LRESULT MessageHandler(HWND window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept;
+
+ // Called when CreateAndShow is called, allowing subclass window-related
+ // setup. Subclasses should return false if setup fails.
+ virtual bool OnCreate();
+
+ // Called when Destroy is called.
+ virtual void OnDestroy();
+
+ private:
+ friend class WindowClassRegistrar;
+
+ // OS callback called by message pump. Handles the WM_NCCREATE message which
+ // is passed when the non-client area is being created and enables automatic
+ // non-client DPI scaling so that the non-client area automatically
+ // responds to changes in DPI. All other messages are handled by
+ // MessageHandler.
+ static LRESULT CALLBACK WndProc(HWND const window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept;
+
+ // Retrieves a class instance pointer for |window|
+ static Win32Window* GetThisFromHandle(HWND const window) noexcept;
+
+ // Update the window frame's theme to match the system theme.
+ static void UpdateTheme(HWND const window);
+
+ bool quit_on_close_ = false;
+
+ // window handle for top level window.
+ HWND window_handle_ = nullptr;
+
+ // window handle for hosted content.
+ HWND child_content_ = nullptr;
+};
+
+#endif // RUNNER_WIN32_WINDOW_H_
diff --git a/lib/flutter_cef.dart b/lib/flutter_cef.dart
index cc7c7b3..c5236bb 100644
--- a/lib/flutter_cef.dart
+++ b/lib/flutter_cef.dart
@@ -1,10 +1,11 @@
/// flutter_cef — embed a live Chromium (CEF) browser as a Flutter widget.
///
/// The page renders off-screen in a `cef_host` subprocess into a shared
-/// IOSurface and is shown as a [Texture] (so it composites/transforms/clips
-/// like any widget). Pointer + keyboard input is forwarded by coordinate, and
-/// the page cursor drives a [MouseRegion]. macOS only, for now (the package is
-/// federated — see `flutter_cef_macos` and `PORTING.md`).
+/// surface (an IOSurface on macOS, a DXGI shared texture on Windows) and is
+/// shown as a [Texture] (so it composites/transforms/clips like any widget).
+/// Pointer + keyboard input is forwarded by coordinate, and the page cursor
+/// drives a [MouseRegion]. macOS and Windows (the package is federated — see
+/// `flutter_cef_macos`, `flutter_cef_windows`, and `PORTING.md`).
library;
export 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.dart'
diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart
index a6733dd..dcdb669 100644
--- a/lib/src/cef_web_controller.dart
+++ b/lib/src/cef_web_controller.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
+import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
@@ -808,8 +809,13 @@ class CefWebController {
///
/// Host-trusted content: rendered regardless of the view's `allowedSchemes`
/// (so `file:` need not be in the allowlist to use this).
- Future loadFile(String absolutePath) =>
- _loadTrusted('file://$absolutePath');
+ Future loadFile(String absolutePath) => _loadTrusted(
+ // A Windows path (drive letter + backslashes, e.g. `C:\a\b.html`) needs
+ // Uri.file to become a valid `file:///C:/a/b.html`; the POSIX branch keeps
+ // the historical byte-identical `file://` form macOS ships today.
+ defaultTargetPlatform == TargetPlatform.windows
+ ? Uri.file(absolutePath, windows: true).toString()
+ : 'file://$absolutePath');
/// Set the page content zoom. `level` is a Chromium zoom *level*; the zoom
/// *factor* is `1.2^level` (0 = 100%, 1 ≈ 120%, -1 ≈ 83%).
diff --git a/lib/src/cef_web_view.dart b/lib/src/cef_web_view.dart
index 031efe3..4a22c06 100644
--- a/lib/src/cef_web_view.dart
+++ b/lib/src/cef_web_view.dart
@@ -1,6 +1,7 @@
import 'dart:async';
+import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -25,7 +26,7 @@ const double _kZoomMax = 4.0;
/// The page renders off-screen in a `cef_host` subprocess and is shown here as
/// a texture (so it composites, transforms, and clips like any widget — unlike
/// a platform view). Pointer + keyboard input is forwarded by coordinate, and
-/// the page's cursor drives a [MouseRegion]. macOS only.
+/// the page's cursor drives a [MouseRegion]. macOS and Windows.
///
/// Keyboard input reaches the page as real `keydown → keypress → keyup` events,
/// so a focused control activates from the keyboard (Enter submits / clicks,
@@ -97,7 +98,8 @@ class CefWebView extends StatefulWidget {
/// Shown until the first frame arrives. Defaults to a dark blank box.
final Widget? placeholder;
- /// Invoked when the user presses ⌘F in the focused view. The view has no
+ /// Invoked when the user presses ⌘F (Ctrl+F on Windows) in the focused view.
+ /// The view has no
/// find-bar UI of its own — a host that wants find-in-page provides this to
/// open its own bar (which then drives [CefWebController.find] / `stopFind`
/// and reads `onFindResult`). When null, ⌘F falls through to the page as an
@@ -164,6 +166,13 @@ class CefWebView extends StatefulWidget {
class _CefWebViewState extends State
implements DeltaTextInputClient {
+ /// Windows uses Ctrl for the browser accelerators (Ctrl+C/V/X/A/Z, Ctrl+/-/0,
+ /// Ctrl+F) where macOS uses ⌘, and its key events carry Windows codes rather
+ /// than macOS NSEvent codes. Everything else in this file is cross-platform.
+ /// (In `flutter test` [defaultTargetPlatform] is android, which takes the
+ /// non-Windows — i.e. existing macOS — branches.)
+ static bool get _isWindows =>
+ defaultTargetPlatform == TargetPlatform.windows;
late final CefWebController _controller =
widget.controller ?? CefWebController(profile: widget.profile);
bool _ownsController = false;
@@ -483,8 +492,11 @@ class _CefWebViewState extends State
// the picker never opens. Flutter's own plugin documents this exact case.
// skipRemainingHandlers stops Flutter ancestors from eating it but still
// hands it to the platform; don't forward it to the page either.
+ // macOS-specific: on Windows Ctrl+Win+Space is not an emoji-picker chord
+ // (the Win key is an OS-level modifier), so it takes the ordinary key path.
final keys = HardwareKeyboard.instance;
- if (event.logicalKey == LogicalKeyboardKey.space &&
+ if (!_isWindows &&
+ event.logicalKey == LogicalKeyboardKey.space &&
keys.isControlPressed &&
keys.isMetaPressed) {
return KeyEventResult.skipRemainingHandlers;
@@ -495,11 +507,16 @@ class _CefWebViewState extends State
// ones to explicit controller calls so a focused webview behaves like a real
// browser (⌘C/X/V/A/Z, ⌘+/-/0). Handled on key-down; zoom also on repeat
// (hold to keep zooming). Returning `handled` keeps the raw combo off the page
- // AND off Flutter's own shortcuts.
- final isMetaOnly = keys.isMetaPressed &&
- !keys.isControlPressed &&
- !keys.isAltPressed;
- if (isMetaOnly && (event is KeyDownEvent || event is KeyRepeatEvent)) {
+ // AND off Flutter's own shortcuts. The accelerator modifier is ⌘ on macOS
+ // and Ctrl on Windows (Ctrl+C/V/X/A/Z, Ctrl+/-/0, Ctrl+F).
+ final isAccelOnly = _isWindows
+ ? (keys.isControlPressed &&
+ !keys.isMetaPressed &&
+ !keys.isAltPressed)
+ : (keys.isMetaPressed &&
+ !keys.isControlPressed &&
+ !keys.isAltPressed);
+ if (isAccelOnly && (event is KeyDownEvent || event is KeyRepeatEvent)) {
final k = event.logicalKey;
// Editing commands: key-down only (repeat would re-cut/re-paste).
if (event is KeyDownEvent && !keys.isShiftPressed) {
@@ -530,6 +547,14 @@ class _CefWebViewState extends State
unawaited(_controller.redo());
return KeyEventResult.handled;
}
+ // Windows convention: Ctrl+Y is redo (alongside Ctrl+Shift+Z above).
+ if (_isWindows &&
+ event is KeyDownEvent &&
+ !keys.isShiftPressed &&
+ k == LogicalKeyboardKey.keyY) {
+ unawaited(_controller.redo());
+ return KeyEventResult.handled;
+ }
// Content zoom (⌘+/-/0). `=`/`+` in, `-` in, `0` reset. Repeat-friendly.
if (k == LogicalKeyboardKey.equal || k == LogicalKeyboardKey.add) {
_applyZoom((_zoomLevel + _kZoomStep).clamp(_kZoomMin, _kZoomMax));
@@ -555,16 +580,29 @@ class _CefWebViewState extends State
}
final mods = _cefModifiers();
- final wkc = cefWindowsKeyCode(event.logicalKey);
+ // On Windows CEF resolves the key entirely from windows_key_code, so the VK
+ // must cover the full keyboard — the function row, Insert, numpad, and OEM
+ // punctuation included (else those send 0 and the page sees dead keys). The
+ // macOS path keeps the leaner [cefWindowsKeyCode] so its wire bytes are
+ // unchanged.
+ final wkc = _isWindows
+ ? cefWindowsKeyCodeForEvent(event.logicalKey, event.physicalKey)
+ : cefWindowsKeyCode(event.logicalKey);
// native_key_code MUST be the macOS keycode for the physical key — CEF on
// macOS keys editing/navigation off it. Deriving it from the Windows VK
// collides (e.g. 0 -> VK 0x30 == macOS keycode 48 == Tab, moving focus).
- final nkc = cefMacNativeKeyCode(event.physicalKey) ?? wkc;
+ // On Windows CEF keys everything off windows_key_code, so the VK is the
+ // right native code there (and the macOS table would be wrong).
+ final nkc =
+ _isWindows ? wkc : (cefMacNativeKeyCode(event.physicalKey) ?? wkc);
final ch = event.character;
final isText = ch != null && _isPrintable(ch);
// Editing / navigation keys MUST carry the macOS NSEvent character or CEF
// OSR double-applies them (one Backspace deletes two, one arrow moves two).
- final keyChar = cefMacCharForKey(event.logicalKey);
+ // macOS-only: the table holds NSEvent codepoints (incl. private-use
+ // 0xF7xx function-key values) that would corrupt a Windows key event —
+ // Windows CEF derives the character from the VK itself, so send 0 there.
+ final keyChar = _isWindows ? 0 : cefMacCharForKey(event.logicalKey);
// The page should see keydown→keypress→keyup for every character, like a
// browser. We always send RAWKEYDOWN/KEYUP; the keypress (CHAR) is
// synthesized by [_commitText] when the IME's insertText delivers a typed
diff --git a/packages/flutter_cef_platform_interface/lib/src/cef_input.dart b/packages/flutter_cef_platform_interface/lib/src/cef_input.dart
index 8a977fa..fe152e0 100644
--- a/packages/flutter_cef_platform_interface/lib/src/cef_input.dart
+++ b/packages/flutter_cef_platform_interface/lib/src/cef_input.dart
@@ -177,6 +177,10 @@ int cefMacCharForKey(LogicalKeyboardKey key) => kCefMacKeyChars[key] ?? 0;
/// The Windows virtual-key code for [key]: the special-key table first, then
/// a→VK_A..z→VK_Z, A–Z, and 0–9. 0 if unmapped (a printable that rides CHAR).
+///
+/// Shared with the macOS wire path (which sends this as `windows_key_code`), so
+/// its output must stay stable — the fuller Windows-only set that covers the
+/// function row, numpad, and OEM punctuation lives in [cefWindowsKeyCodeForEvent].
int cefWindowsKeyCode(LogicalKeyboardKey key) {
final special = kCefSpecialWindowsKeyCodes[key];
if (special != null) return special;
@@ -187,6 +191,75 @@ int cefWindowsKeyCode(LogicalKeyboardKey key) {
return 0;
}
+/// Windows virtual-key codes for keys that carry a fixed VK by **logical**
+/// identity but ride the CHAR path (so [cefWindowsKeyCode] leaves them 0): the
+/// function row, Insert, and the numpad. CEF on Windows resolves these by
+/// `windows_key_code`, so without a real VK the page sees dead keys (F1-F12,
+/// Insert, numpad digits/operators all send 0). Layered on Windows only — see
+/// [cefWindowsKeyCodeForEvent] — so the shared [cefWindowsKeyCode] (and the
+/// macOS wire path that reads it) stays byte-identical.
+final Map kCefWindowsExtraKeyCodes =
+ {
+ // Function row (VK_F1..VK_F12).
+ LogicalKeyboardKey.f1: 0x70, LogicalKeyboardKey.f2: 0x71,
+ LogicalKeyboardKey.f3: 0x72, LogicalKeyboardKey.f4: 0x73,
+ LogicalKeyboardKey.f5: 0x74, LogicalKeyboardKey.f6: 0x75,
+ LogicalKeyboardKey.f7: 0x76, LogicalKeyboardKey.f8: 0x77,
+ LogicalKeyboardKey.f9: 0x78, LogicalKeyboardKey.f10: 0x79,
+ LogicalKeyboardKey.f11: 0x7A, LogicalKeyboardKey.f12: 0x7B,
+ // Insert (Delete is in kCefSpecialWindowsKeyCodes).
+ LogicalKeyboardKey.insert: 0x2D,
+ // Numpad digits (VK_NUMPAD0..VK_NUMPAD9).
+ LogicalKeyboardKey.numpad0: 0x60, LogicalKeyboardKey.numpad1: 0x61,
+ LogicalKeyboardKey.numpad2: 0x62, LogicalKeyboardKey.numpad3: 0x63,
+ LogicalKeyboardKey.numpad4: 0x64, LogicalKeyboardKey.numpad5: 0x65,
+ LogicalKeyboardKey.numpad6: 0x66, LogicalKeyboardKey.numpad7: 0x67,
+ LogicalKeyboardKey.numpad8: 0x68, LogicalKeyboardKey.numpad9: 0x69,
+ // Numpad operators.
+ LogicalKeyboardKey.numpadMultiply: 0x6A, // VK_MULTIPLY
+ LogicalKeyboardKey.numpadAdd: 0x6B, // VK_ADD
+ LogicalKeyboardKey.numpadSubtract: 0x6D, // VK_SUBTRACT
+ LogicalKeyboardKey.numpadDecimal: 0x6E, // VK_DECIMAL
+ LogicalKeyboardKey.numpadDivide: 0x6F, // VK_DIVIDE
+};
+
+/// Windows virtual-key codes for the OEM **punctuation** keys, keyed by the
+/// **physical** key so the VK is layout-independent (mirroring
+/// [kCefMacKeyCodesByPhysical]): the physical `;` key reports VK_OEM_1 on any
+/// layout even when it produces a different character — the produced text rides
+/// the CHAR / IME path, while the raw key event needs the position's VK. Keying
+/// off the logical character instead would miss these on non-US layouts. Windows
+/// only — see [cefWindowsKeyCodeForEvent].
+final Map kCefWindowsKeyCodesByPhysical =
+ {
+ PhysicalKeyboardKey.semicolon: 0xBA, // VK_OEM_1 ;:
+ PhysicalKeyboardKey.equal: 0xBB, // VK_OEM_PLUS =+
+ PhysicalKeyboardKey.comma: 0xBC, // VK_OEM_COMMA ,<
+ PhysicalKeyboardKey.minus: 0xBD, // VK_OEM_MINUS -_
+ PhysicalKeyboardKey.period: 0xBE, // VK_OEM_PERIOD .>
+ PhysicalKeyboardKey.slash: 0xBF, // VK_OEM_2 /?
+ PhysicalKeyboardKey.backquote: 0xC0, // VK_OEM_3 `~
+ PhysicalKeyboardKey.bracketLeft: 0xDB, // VK_OEM_4 [{
+ PhysicalKeyboardKey.backslash: 0xDC, // VK_OEM_5 \|
+ PhysicalKeyboardKey.bracketRight: 0xDD, // VK_OEM_6 ]}
+ PhysicalKeyboardKey.quote: 0xDE, // VK_OEM_7 '"
+};
+
+/// The Windows virtual-key code for a key event on the **Windows** key path.
+/// Layers the fuller Windows-only set — function row, Insert, numpad, and the
+/// OEM punctuation (resolved by physical key, layout-independent) — on top of
+/// the shared [cefWindowsKeyCode]. CEF on Windows keys everything off
+/// `windows_key_code`, so a 0 here makes the page see a dead key. The macOS path
+/// keeps calling [cefWindowsKeyCode] directly, so its wire bytes don't move.
+int cefWindowsKeyCodeForEvent(
+ LogicalKeyboardKey logical, PhysicalKeyboardKey physical) {
+ final base = cefWindowsKeyCode(logical);
+ if (base != 0) return base;
+ final extra = kCefWindowsExtraKeyCodes[logical];
+ if (extra != null) return extra;
+ return kCefWindowsKeyCodesByPhysical[physical] ?? 0;
+}
+
// ── Cursor ───────────────────────────────────────────────────────────────
/// Map a CEF `cef_cursor_type_t` to a Flutter [MouseCursor] (the common ones;
diff --git a/packages/flutter_cef_windows/.gitignore b/packages/flutter_cef_windows/.gitignore
new file mode 100644
index 0000000..c822747
--- /dev/null
+++ b/packages/flutter_cef_windows/.gitignore
@@ -0,0 +1,3 @@
+# Standalone cef_host build output (build_cef_host.bat's default BUILD_DIR;
+# the flutter-driven build uses the example's binary dir instead).
+native/cef_host/build/
diff --git a/packages/flutter_cef_windows/CHANGELOG.md b/packages/flutter_cef_windows/CHANGELOG.md
new file mode 100644
index 0000000..5706268
--- /dev/null
+++ b/packages/flutter_cef_windows/CHANGELOG.md
@@ -0,0 +1,7 @@
+# 0.1.0
+
+- Initial Windows package skeleton (Phase 1 of the Windows-port vertical
+ slice): endorsed federated plugin, stub C++ plugin answering every
+ `flutter_cef` channel verb, `cef_host` Windows host skeleton
+ (pipe connect + `kOpReady`), and `native/cef_host/PROTOCOL.md` — the
+ transcribed wire/channel contract builders implement against.
diff --git a/packages/flutter_cef_windows/LICENSE b/packages/flutter_cef_windows/LICENSE
new file mode 100644
index 0000000..3f83b7d
--- /dev/null
+++ b/packages/flutter_cef_windows/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 the flutter_cef authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/flutter_cef_windows/README.md b/packages/flutter_cef_windows/README.md
new file mode 100644
index 0000000..e3dc0f2
--- /dev/null
+++ b/packages/flutter_cef_windows/README.md
@@ -0,0 +1,35 @@
+# flutter_cef_windows
+
+The endorsed Windows implementation of [`flutter_cef`](../../README.md): a
+live Chromium (CEF) browser rendered off-screen in a `cef_host` subprocess
+and composited into the Flutter scene as a `Texture` (DXGI shared-texture
+path).
+
+**Status: Phase-1 skeleton of the Windows-port vertical slice.** The plugin
+registers the `flutter_cef` channel and answers every verb (stubbed), the
+`cef_host` skeleton connects the IPC pipe and announces `kOpReady`, and the
+example app builds/runs with a blank tile. The authoritative plan is
+`specs/windows-port/PLAN.md` + `SPIKES.md`; the wire/channel contract the
+implementation follows is [`native/cef_host/PROTOCOL.md`](native/cef_host/PROTOCOL.md).
+
+## Layout
+
+- `lib/flutter_cef_windows.dart` — `registerWith()` endorsing the shared
+ method-channel platform implementation.
+- `windows/` — the C++ plugin: channel dispatch (`flutter_cef_plugin.*`),
+ `texture_bridge.*` (Flutter texture side), `host_process.*` (cef_host
+ spawn/lifecycle), `ipc_pipe.*` (named-pipe framing).
+- `native/cef_host/` — the standalone CEF OSR host: `cef_host_win.cc`
+ builds as `cef_host.dll` (exports `RunConsoleMain`), shipped beside CEF's
+ `bootstrapc.exe` renamed to `cef_host.exe`. `build_cef_host.bat` builds it
+ (driven from the plugin CMake during `flutter build windows`);
+ `fetch_cef.ps1` resolves the pinned CEF distribution.
+
+## Build prerequisites (dev box)
+
+- VS2022 (MSVC + the bundled CMake/Ninja; they are NOT on PATH — the build
+ scripts use absolute paths), Flutter 3.38.8, Developer Mode ON (plugin
+ symlinks are true symlinks).
+- The pinned CEF distribution
+ `cef_binary_144.0.27+g3fae261+chromium-144.0.7559.254_windows64_minimal`,
+ resolved via the `CEF_ROOT` env var (see `native/cef_host/fetch_cef.ps1`).
diff --git a/packages/flutter_cef_windows/lib/flutter_cef_windows.dart b/packages/flutter_cef_windows/lib/flutter_cef_windows.dart
new file mode 100644
index 0000000..eb11ee5
--- /dev/null
+++ b/packages/flutter_cef_windows/lib/flutter_cef_windows.dart
@@ -0,0 +1,19 @@
+import 'package:flutter_cef_platform_interface/flutter_cef_platform_interface.dart';
+
+/// The Windows implementation of `flutter_cef`.
+///
+/// The Windows plugin is native-only: the C++ `FlutterCefPlugin` (see
+/// `windows/`) spawns and talks to a per-profile `cef_host` subprocess over a
+/// named-pipe IPC and answers the `flutter_cef` method channel. This Dart
+/// class exists only to **endorse** the default method-channel platform
+/// instance at registration time; there is no Windows-specific Dart behavior.
+///
+/// Registered via `dartPluginClass: FlutterCefWindows` in this package's
+/// pubspec — the Flutter tool calls [registerWith] during plugin registration.
+class FlutterCefWindows {
+ /// Sets the [FlutterCefPlatform] instance to the method-channel
+ /// implementation (the contract the native Windows plugin speaks).
+ static void registerWith() {
+ FlutterCefPlatform.instance = MethodChannelFlutterCef();
+ }
+}
diff --git a/packages/flutter_cef_windows/native/cef_host/CMakeLists.txt b/packages/flutter_cef_windows/native/cef_host/CMakeLists.txt
new file mode 100644
index 0000000..7cb6ae7
--- /dev/null
+++ b/packages/flutter_cef_windows/native/cef_host/CMakeLists.txt
@@ -0,0 +1,108 @@
+# cef_host (Windows) — standalone CEF OSR host, built OUTSIDE the Flutter
+# plugin build (driven by build_cef_host.bat, which the plugin CMakeLists
+# invokes via add_custom_command).
+#
+# Ship shape (LAW 8, S2): the host is a client DLL `cef_host.dll` exporting
+# RunConsoleMain, loaded by CEF's prebuilt bootstrap. We ship bootstrapc.exe
+# RENAMED to cef_host.exe beside the DLL (the rename selects the module —
+# no --module flag needed; clean Task Manager names). The slice runs
+# no_sandbox=1 (sandbox is P11), but the bootstrap layout is the shipping
+# contract from day one so P11 is a settings flip, not a restructure.
+#
+# CEF's own cmake config (find_package(CEF)) drives compiler flags (/MT):
+# this target never links against Flutter, so the plugin's /MD rule (S6a)
+# does not apply here. The client DLL must link delayimp.lib (CEF's
+# /DELAYLOAD list — SPIKES.md S2).
+
+cmake_minimum_required(VERSION 3.21)
+project(cef_host_win LANGUAGES CXX)
+
+# CEF_DIST_NAME: the pinned distribution (keep in lockstep with fetch_cef.ps1,
+# ../../windows/CMakeLists.txt, build_cef_host.sh:17 and SPIKES.md).
+set(CEF_DIST_NAME
+ "cef_binary_144.0.27+g3fae261+chromium-144.0.7559.254_windows64_minimal")
+
+# CEF_ROOT resolution — ONE shared order, mirrored in ../../windows/CMakeLists.txt
+# and fetch_cef.ps1:
+# -DCEF_ROOT=... > env CEF_ROOT > %LOCALAPPDATA%/flutter_cef/. If none
+# exists, ask fetch_cef.ps1 to resolve/print it; still unresolved -> fail.
+if(NOT DEFINED CEF_ROOT)
+ if(DEFINED ENV{CEF_ROOT})
+ set(CEF_ROOT "$ENV{CEF_ROOT}")
+ elseif(EXISTS "$ENV{LOCALAPPDATA}/flutter_cef/${CEF_DIST_NAME}/cmake")
+ set(CEF_ROOT "$ENV{LOCALAPPDATA}/flutter_cef/${CEF_DIST_NAME}")
+ else()
+ execute_process(
+ COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File
+ "${CMAKE_CURRENT_SOURCE_DIR}/fetch_cef.ps1"
+ OUTPUT_VARIABLE CEF_ROOT
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ RESULT_VARIABLE _fetch_cef_result)
+ if(NOT _fetch_cef_result EQUAL 0)
+ set(CEF_ROOT "")
+ endif()
+ endif()
+endif()
+# Normalize to forward slashes: %LOCALAPPDATA% (and env CEF_ROOT) come back with
+# backslashes, which CMake reads as escape sequences in later string references.
+file(TO_CMAKE_PATH "${CEF_ROOT}" CEF_ROOT)
+if(NOT EXISTS "${CEF_ROOT}/cmake")
+ message(FATAL_ERROR
+ "CEF_ROOT not found (resolved '${CEF_ROOT}'). Run fetch_cef.ps1 to stage "
+ "'${CEF_DIST_NAME}' under %LOCALAPPDATA%/flutter_cef/, or set CEF_ROOT "
+ "(env or -DCEF_ROOT=) to an extracted copy.")
+endif()
+message(STATUS "cef_host: resolved CEF_ROOT = ${CEF_ROOT}")
+list(APPEND CMAKE_MODULE_PATH "${CEF_ROOT}/cmake")
+find_package(CEF REQUIRED)
+
+# libcef_dll_wrapper: reuse a prebuilt (/MT, matching CEF's default flags)
+# when provided, else build it in-tree. Resolution mirrors the CEF_ROOT
+# env-first chain: -DCEF_WRAPPER_LIB= > env CEF_WRAPPER_LIB.
+if(NOT DEFINED CEF_WRAPPER_LIB AND DEFINED ENV{CEF_WRAPPER_LIB})
+ set(CEF_WRAPPER_LIB "$ENV{CEF_WRAPPER_LIB}")
+endif()
+if(DEFINED CEF_WRAPPER_LIB AND EXISTS "${CEF_WRAPPER_LIB}")
+ set(WRAPPER_LIB "${CEF_WRAPPER_LIB}")
+else()
+ add_subdirectory("${CEF_LIBCEF_DLL_WRAPPER_PATH}" libcef_dll_wrapper)
+ set(WRAPPER_LIB libcef_dll_wrapper)
+endif()
+
+# The client DLL (loaded by the renamed bootstrapc.exe).
+add_library(cef_host SHARED
+ cef_host_win.cc
+ cef_host_protocol.h
+)
+SET_LIBRARY_TARGET_PROPERTIES(cef_host)
+# NOMINMAX: cef_types_win.h pulls windows.h; without it std::min/max -> C2589
+# (S6a). CEF's own configs may set it too — harmless twice.
+target_compile_definitions(cef_host PRIVATE NOMINMAX)
+target_include_directories(cef_host PRIVATE ${CEF_ROOT})
+target_link_libraries(cef_host
+ ${WRAPPER_LIB}
+ "${CEF_ROOT}/Release/libcef.lib"
+ delayimp.lib
+ # The OnAcceleratedPaint NT->legacy bridge blit (S1 pixel path).
+ d3d11.lib
+ dxgi.lib
+)
+
+# ---- pipe_probe: standalone IPC gate test (native/cef_host/test/) ----
+# Plain Win32 + cef_host_protocol.h, NO CEF link. Acts as the plugin side of
+# the named pipe: spawns cef_host.exe, drives kOpCreateBrowser/kOpNavigate/
+# kOpShutdown and asserts kOpReady/kOpCreated/kOpPresent/events. Built on
+# demand (`--target pipe_probe`); build_cef_host.bat builds only cef_host, so
+# plugin builds never compile this.
+add_executable(pipe_probe EXCLUDE_FROM_ALL test/pipe_probe.cc)
+target_include_directories(pipe_probe PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
+target_compile_definitions(pipe_probe PRIVATE NOMINMAX)
+
+# Stage the ship pair beside the build output: cef_host.dll + cef_host.exe
+# (bootstrapc.exe renamed — LAW 8).
+add_custom_command(TARGET cef_host POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "${CEF_ROOT}/Release/bootstrapc.exe"
+ "$/cef_host.exe"
+ COMMENT "Staging bootstrapc.exe as cef_host.exe beside cef_host.dll"
+)
diff --git a/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md b/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md
new file mode 100644
index 0000000..f11ff8e
--- /dev/null
+++ b/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md
@@ -0,0 +1,568 @@
+# flutter_cef Windows wire + channel contract (slice)
+
+TRANSCRIBED from the macOS reference implementation — do not invent. Sources
+(line numbers as of branch `feat/windows-port-p0`):
+
+- `packages/flutter_cef_macos/native/cef_host/main.mm` — opcode table
+ (`kOp*`, main.mm:111-164), framing (main.mm:40-45, 416-446), read-loop
+ payload decoding (main.mm:2338-2603).
+- `packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift` — channel
+ verb dispatch (`handle`, FlutterCefPlugin.swift:111-242) and native->Dart
+ events (`emit` sites, FlutterCefPlugin.swift:359-430, 490, 521, 531, 541,
+ 554-558).
+- `packages/flutter_cef_macos/macos/Classes/CefWebSession.swift:28-73` and
+ `CefProfileHost.swift:21-42` — the Swift copies of the opcode table (must
+ match main.mm; they do).
+- `lib/src/cef_web_controller.dart` — the exact channel-arg maps Dart sends
+ (cited per verb below).
+
+The Windows host + plugin speak EXACTLY this protocol with **one payload
+difference**: `kOpPresent` (see below). Opcode numbers, framing, byte order,
+and every other payload are copied verbatim. Protocol version byte = **3**
+(main.mm:108, CefProfileHost.swift:42).
+
+---
+
+## 1. IPC framing (byte stream over the named pipe)
+
+Identical to macOS (main.mm:40-45, SendFrame main.mm:416-446, reader
+main.mm:2338-2357):
+
+```
+[u32 bodyLen BE][u32 browserId BE][u8 opcode][payload...]
+```
+
+- `bodyLen` = 4 (browserId) + 1 (opcode) + payloadLen — counts every byte
+ after the length prefix.
+- Guard on read: `5 <= bodyLen <= 64 MiB`, else the stream is desynced —
+ log + tear down the whole process (main.mm:2343-2351).
+- `browserId` is the PLUGIN-assigned wire id (>= 1). `browserId 0` =
+ process/profile level (`kOpReady`, process-level `kOpLog`, inbound
+ `kOpShutdown`) (main.mm:41-43).
+- ALL multi-byte integers are BIG-ENDIAN, including `f64` (IEEE-754 double,
+ BE byte order — `ReadF64BE`/`WriteF64BE`, see main.mm ReadU32BE:476).
+- Writes are assembled into one contiguous frame and written atomically
+ under a write mutex, so a partial write never desyncs the peer
+ (main.mm:431-445).
+- Transport on Windows: named pipe `\\.\pipe\flutter_cef__`,
+ `PIPE_TYPE_BYTE | PIPE_READMODE_BYTE`, single instance; plugin is the
+ server (`CreateNamedPipeW`), cef_host connects with `CreateFileW`
+ (+ `SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS`). Same framing on top.
+- **OVERLAPPED I/O is REQUIRED on any pipe end that reads and writes from
+ different threads** (empirical, cef_host + pipe_probe 2026-07-20): Windows
+ serializes I/O on a synchronous pipe file object, so a dedicated reader
+ thread's pending blocking `ReadFile` makes every `WriteFile` on the same
+ handle queue behind it. In cef_host that froze the CEF UI thread inside
+ `SendFrame(kOpCreated)`, stalled `CefRunMessageLoop`, and every Chromium
+ child process died with "Terminating current process after 15 seconds with
+ no connection" (no renderer/GPU/network, browsers never created). Fix:
+ open/create the handle with `FILE_FLAG_OVERLAPPED` and run all reads AND
+ writes as event-based overlapped ops (`cef_host_win.cc` `OverlappedIo`).
+ The plugin's server end (`CreateNamedPipeW` + reader thread + writes from
+ the platform thread) has the identical shape and MUST also pass
+ `FILE_FLAG_OVERLAPPED`. A Unix socket fd is full-duplex, so the macOS
+ reference never encounters this.
+- Unknown opcode at either end: log ONCE per opcode value and drop the
+ frame — never kill the stream (main.mm:2583-2598).
+
+## 2. Opcode table (numbers verbatim from main.mm:111-164)
+
+Direction `H<-C` = cef_host -> plugin (event), `H->C` = plugin -> cef_host
+(command). Payload layouts are exactly the macOS ones except where marked
+**WINDOWS**.
+
+### cef_host -> plugin (0x01-0x1d)
+
+| Op | Name | Payload | Source |
+|---|---|---|---|
+| 0x01 | kOpPresent | **WINDOWS**: `{u64 bridgeHandle BE}{u32 srcW BE}{u32 srcH BE}` = 16 bytes. bridgeHandle = the DXGI **legacy** shared handle (`IDXGIResource::GetSharedHandle`) of the host-minted `MISC_SHARED` bridge texture; srcW/srcH = the PHYSICAL px dims of the frame actually composited (the size-gate signal). macOS reference is 12 bytes `{u32 iosurfaceId}{u32 srcW}{u32 srcH}` — same semantics, different token width. | main.mm:654-665 (semantics + size gate), SPIKES.md S1/S4, LAW 10 |
+| 0x02 | kOpReady | `{u8 readyFlags}{u8 protocolVersion}` on browserId 0. readyFlags bit0 = ad-hoc/mock-keychain build (macOS-only concern; Windows sends 0). protocolVersion = 3. Sent from `OnContextInitialized`, BEFORE any browser exists. | main.mm:1664-1682 |
+| 0x03 | kOpCursor | `{u32 cef_cursor_type_t}` | main.mm:1456-1466 |
+| 0x04 | kOpLog | `{utf8 message}` (browserId 0 = process-level) | main.mm:448-450 |
+| 0x05 | kOpLoadState | `{u8 loading}{u8 canGoBack}{u8 canGoForward}` | main.mm:115, 456-462 |
+| 0x06 | kOpTitle | `{utf8 title}` | main.mm:116 |
+| 0x07 | kOpUrl | `{utf8 main-frame url}` | main.mm:117 |
+| 0x08 | kOpLoadErr | `{u32 code}{utf8 "url\ntext"}` | main.mm:118, 464-474 |
+| 0x09 | kOpConsole | `{u32 level}{utf8 "source:line\tmsg"}` | main.mm:119 |
+| 0x0a | kOpPageStart | `{utf8 url}` main frame load started | main.mm:120 |
+| 0x0b | kOpPageFinish | `{utf8 url}` main frame load finished | main.mm:121 |
+| 0x0c | kOpProgress | `{u32 percent 0-100}` | main.mm:122, 1396 |
+| 0x0d | kOpNewWindow | `{utf8 url}` popup / target=_blank | main.mm:123 |
+| 0x0e | kOpFindResult | `{u32 count}{u32 activeOrdinal}{u8 final}` = 9 bytes | main.mm:124, 1274 |
+| 0x0f | kOpJsDialog | `{u32 id}{u32 type}{u32 msgLen}{msg utf8}{defaultText utf8}` | main.mm:125, 1300 |
+| 0x16 | kOpEvalResult | `{utf8 "id:json"}` (runJavaScriptReturningResult) | main.mm:126 |
+| 0x17 | kOpChannelMsg | `{utf8 "name:message"}` JS channel -> host | main.mm:127 |
+| 0x18 | kOpDownload | `{utf8 suggestedName}` a download started | main.mm:128 |
+| 0x19 | kOpImeBounds | `{u32 x}{u32 y}{u32 w}{u32 h}` caret rect (DIP) | main.mm:129, 1050 |
+| 0x1a | kOpCookies | `{u32 id}{utf8 json-array}` visitAllCookies result | main.mm:130 |
+| 0x1b | kOpTargetId | `{utf8 targetId}` this browser's CDP targetId (reply to 0x36) | main.mm:131 |
+| 0x1c | kOpCreated | `{}` OnAfterCreated — browser is up (create pacer advance) | main.mm:132, 1406 |
+| 0x1d | kOpCreateFailed | `{}` async CreateBrowser dispatch failed — drop the session | main.mm:133, 1705 |
+
+### plugin -> cef_host (0x10-0x38)
+
+Payload minimums are enforced host-side exactly as the macOS read loop does
+(cited); short frames are dropped per-op, not fatal.
+
+| Op | Name | Payload | Source |
+|---|---|---|---|
+| 0x10 | kOpPointer | `{u8 type}{u8 button}{u8 clickCount}{u8 pad}{u32 modifiers}{f64 x}{f64 y}{f64 dx}{f64 dy}` = 40 bytes. type: 0=move 1=down 2=up 3=wheel 4=leave; button: 0=left 1=middle 2=right. x/y logical (DIP). | main.mm:2560-2570 |
+| 0x11 | kOpResize | `{u32 w}{u32 h}[{f64 dpr}]` — plen>=8; dpr present iff plen>=16, `0`/absent = unchanged; guard `0 < dpr <= 8` else treat as 0. Producer-allocates: no surface id. EVERY WasResized discards CEF's frame pool (LAW 4). | main.mm:135, 2384-2394 |
+| 0x12 | kOpKey | `{u8 type}{u8 pad×3}{u32 modifiers}{u32 windowsKeyCode}{u32 nativeKeyCode}{u32 character}` = 20 bytes. type: 0=rawkeydown 2=keyup 3=char. wkc/nkc are i32 stored as u32 BE. | main.mm:136, 2571-2582 |
+| 0x13 | kOpCreateBrowser | `{u32 w}{u32 h}{f64 dpr}{utf8 url}` (plen>=16); frame browserId = the NEW wire id; producer-allocates (no surface id); empty url -> about:blank; guard `0 < dpr <= 8` else 1.0. | main.mm:137, 2364-2376 |
+| 0x14 | kOpShutdown | `{}` tear down the whole PROCESS (all browsers); browserId 0. | main.mm:138, 2381-2383 |
+| 0x15 | kOpDisposeBrowser | `{}` close ONE browser (target = frame browserId); process survives. | main.mm:139, 2377-2380 |
+| 0x20 | kOpNavigate | `{utf8 url}` — do NOT require a bound slot; resolve by wire id on the UI thread (a nav right behind a queued create must not drop). | main.mm:140, 2395-2402 |
+| 0x21 | kOpReload | `{}` | main.mm:141 |
+| 0x22 | kOpStop | `{}` | main.mm:142 |
+| 0x23 | kOpBack | `{}` | main.mm:143 |
+| 0x24 | kOpForward | `{}` | main.mm:144 |
+| 0x25 | kOpExecuteJs | `{utf8 code}` | main.mm:145 |
+| 0x26 | kOpSetZoom | `{f64 level}` (factor = 1.2^level) | main.mm:146, 2434-2439 |
+| 0x27 | kOpFind | `{u8 fwd}{u8 matchCase}{u8 findNext}{utf8 text}` (plen>=3) | main.mm:147, 2452-2459 |
+| 0x28 | kOpStopFind | `{u8 clearSelection}` (absent = 1) | main.mm:148, 2460-2465 |
+| 0x29 | kOpJsDialogResp | `{u32 id}{u8 ok}{utf8 text}` (plen>=5) | main.mm:149, 2466-2474 |
+| 0x2a | kOpEvalReturning | `{u32 id}{utf8 code}` (plen>=4) | main.mm:150, 2475-2482 |
+| 0x2b | kOpAddChannel | `{utf8 name}` — do NOT require a bound slot (registers process-global; injected on load). | main.mm:151, 2483-2494 |
+| 0x2c | kOpSetCookie | `{utf8 url\0name\0value\0domain\0path}` (NUL-separated, pad missing fields to 5) | main.mm:152, 2495-2510 |
+| 0x2d | kOpClearCookies | `{}` delete all cookies | main.mm:153 |
+| 0x2e | kOpVisitCookies | `{u32 id}{utf8 url}` enumerate (url empty = all) | main.mm:154, 2515-2522 |
+| 0x2f | kOpDeleteCookie | `{utf8 url\0name}` delete one | main.mm:155, 2523-2531 |
+| 0x30 | kOpImeSetComp | `{utf8 text}` IME composition update | main.mm:156 |
+| 0x31 | kOpImeCommit | `{utf8 text}` commit composed text | main.mm:157 |
+| 0x32 | kOpImeCancel | `{}` cancel composition | main.mm:158 |
+| 0x33 | kOpShowDevTools | `{}` open DevTools in a window | main.mm:159 |
+| 0x34 | kOpLoadTrusted | `{utf8 url}` host content-load, exempt from allowlist; do NOT require a bound slot (same as 0x20). | main.mm:160, 2403-2411 |
+| 0x35 | kOpSetVisible | `{u8 visible}` (absent = 1) -> `WasHidden(!visible)` | main.mm:161, 2446-2451 |
+| 0x36 | kOpResolveTargetId | `{}` resolve this browser's CDP targetId -> kOpTargetId | main.mm:162 |
+| 0x37 | kOpInvalidate | `{}` force a repaint (`Invalidate(PET_VIEW)`) to re-kick a stalled first frame | main.mm:163 |
+| 0x38 | kOpEditCommand | `{u8 cmd}` focused-frame edit command: 0=copy 1=cut 2=paste 3=selectAll 4=undo 5=redo | main.mm:164, 2440-2445 |
+
+Reserved (do NOT reuse): `0x1e` was earmarked `kOpPresentV2` by PLAN §4.3
+stage-1; the slice instead reuses `kOpPresent 0x01` with the Windows payload
+(LAW 10) because the Windows plugin is the only peer of the Windows host.
+
+## 3. Method-channel verbs (Dart -> plugin), channel `flutter_cef`
+
+Verb names + arg keys verbatim from FlutterCefPlugin.swift:113-241 and
+cef_web_controller.dart (invokeMethod sites). Every arg map carries
+`sessionId` (String). SLICE = functional since the P1–P4 vertical slice;
+P6 = functional since the profiles/cookies slice; P7 = functional since the
+JS-bridge/dialogs/find/zoom/downloads slice (see §7); IMPL\* = native
+implementation present but not yet verified on Windows OSR; STUB = still a
+reply success/null + `OutputDebugString` warning, never an error.
+
+| Verb | Args (beyond sessionId) | Returns | Maps to | Slice? | Source |
+|---|---|---|---|---|---|
+| create | url:String, width:int, height:int, dpr:double, allowedSchemes:String? (csv, omit-when-empty), enableCdp:bool? (omit-when-false), agentControl:bool? (omit-when-false), profile:String? (omit-when-empty) | `{textureId:int, width:int, height:int, cdpPort:int}` | spawn host (if needed) + kOpCreateBrowser 0x13 | SLICE | Swift:255-446, controller:506-523 |
+| navigate | url:String | null | 0x20 | SLICE | Swift:617-622, controller:566 |
+| loadTrusted | url:String | null | 0x34 | SLICE (stub-ok) | Swift:626-631, controller:634 |
+| resize | width:int, height:int, dpr:double | `{textureId:int}` (or null if unknown session) | 0x11 | SLICE | Swift:633-641, controller:874 |
+| getFrameSurface | — | `{surfaceId:int, width:int, height:int}` (physical px) or null | plugin-local | STUB | Swift:649-658 |
+| dispose | — | null | 0x15 (last browser: 0x14 + host teardown) | SLICE | Swift:660-663, controller:530/948 |
+| pointer | type:int, button:int, clickCount:int, modifiers:int, x:double, y:double, dx:double, dy:double | null | 0x10 | SLICE | Swift:730-740, controller:895 |
+| key | type:int, modifiers:int, windowsKeyCode:int, nativeKeyCode:int, character:int | null | 0x12 | SLICE | Swift:742-752, controller:919 |
+| reload | — | null | 0x21 | SLICE | Swift:122 |
+| stop | — | null | 0x22 | SLICE | Swift:123 |
+| goBack | — | null | 0x23 | SLICE | Swift:124 |
+| goForward | — | null | 0x24 | SLICE | Swift:125 |
+| executeJavaScript | code:String | null | 0x25 | SLICE | Swift:126-130 |
+| setZoomLevel | level:double | null | 0x26 | P7 | Swift:131-133 |
+| editCommand | command:int | null | 0x38 | SLICE | Swift:134-136 |
+| setVisible | visible:bool | null | 0x35 | SLICE | Swift:137-139 |
+| find | text:String, forward:bool, matchCase:bool, findNext:bool | null | 0x27 | P7 | Swift:140-148 |
+| stopFind | clearSelection:bool | null | 0x28 | P7 | Swift:149-151 |
+| respondJsDialog | id:int, ok:bool, text:String | null | 0x29 | P7 | Swift:152-158 |
+| evalReturning | id:int, code:String | null | 0x2a | P7 | Swift:159-165 |
+| addJavaScriptChannel | name:String | null | 0x2b | P7 | Swift:166-170 |
+| setCookie | url, name, value, domain, path : String | null | 0x2c | P6 | Swift:171-179 |
+| clearCookies | — | null | 0x2d | P6 | Swift:180-182 |
+| visitCookies | id:int, url:String | null | 0x2e | P6 | Swift:183-188 |
+| deleteCookie | url:String, name:String | null | 0x2f | P6 | Swift:189-194 |
+| showDevTools | — | null | 0x33 | IMPL\* | Swift:195-197 |
+| enableAgentControl | — | `{wsUrl:String, token:String, port:int}` or FlutterError (`no_agent_control` when the session wasn't created with `agentControl:true`) | CDP relay (§8) | P9 | Swift:198-217, controller:595-605 |
+| disableAgentControl | — | null | CDP relay (§8) | P9 | Swift:218-225, controller:609-610 |
+| showEmojiPicker | — | null | macOS-only (Character Palette) | STUB | Swift:226-230 |
+| imeSetComposition | text:String | null | 0x30 | IMPL\* | Swift:231-233 |
+| imeCommitText | text:String | null | 0x31 | IMPL\* | Swift:234-236 |
+| imeCancelComposition | — | null | 0x32 | IMPL\* | Swift:237-239 |
+
+macOS replies `FlutterMethodNotImplemented` for unknown verbs
+(Swift:240); the WINDOWS SLICE deviates deliberately: unknown/unimplemented
+verbs reply success(null) + `OutputDebugString` so the example app never sees
+a MissingPluginException-style error (slice contract).
+
+## 4. Events (plugin -> Dart), channel `flutter_cef`
+
+Method names + payload keys verbatim from the Swift `emit` sites. Every
+payload carries `sessionId:String`. `invokeMethod` MUST run on the platform
+thread (marshal from the reader thread).
+
+| Method | Payload (beyond sessionId) | From opcode | Source |
+|---|---|---|---|
+| cursor | cursor:int (cef_cursor_type_t) | 0x03 | Swift:359-361 |
+| loadingState | isLoading:bool, canGoBack:bool, canGoForward:bool | 0x05 | Swift:362-367 |
+| title | title:String | 0x06 | Swift:368-370 |
+| url | url:String | 0x07 | Swift:371-373 |
+| loadError | code:int, url:String, text:String (split payload at first '\n') | 0x08 | Swift:374-378 |
+| consoleMessage | level:int, message:String | 0x09 | Swift:379-383 |
+| pageStarted | url:String | 0x0a | Swift:384-386 |
+| pageFinished | url:String | 0x0b | Swift:387-389 |
+| progress | progress:int | 0x0c | Swift:390-392 |
+| newWindow | url:String | 0x0d | Swift:393-395 |
+| findResult | count:int, activeMatchOrdinal:int, isFinal:bool | 0x0e | Swift:396-401 |
+| jsDialog | id:int, type:int, message:String, defaultText:String | 0x0f | Swift:402-407 |
+| evalResult | payload:String ("id:json") | 0x16 | Swift:408-410 |
+| channelMessage | payload:String ("name:message") | 0x17 | Swift:411-413 |
+| download | suggestedName:String | 0x18 | Swift:414-416 |
+| imeCompositionBounds | x:int, y:int, w:int, h:int | 0x19 | Swift:417-421 |
+| cookies | id:int, json:String | 0x1a | Swift:422-424 |
+| onSurface | surfaceId:int, width:int, height:int (physical px) — Windows: surfaceId = the bridge-handle token as int64 | 0x01 (on surface (re)alloc) | Swift:425-433 |
+| processGone | reason:String — "crashed" \| "locked" (host exit code 2) \| "createFailed" (0x1d) \| "respawnFailed" \| "protocolMismatch(host=vN)" | host death / 0x1d / handshake | Swift:490, 521, 531, 541, 601 |
+| paintStalled | — | watchdog (no 0x01 after create + 0x37 re-kick) | Swift:554-558 |
+
+## 5. Handshake + lifecycle rules (carry-over)
+
+- Plugin sends NOTHING until it receives `kOpReady`; it then checks
+ `protocolVersion == 3` and refuses (teardown + `processGone
+ protocolMismatch`) on skew (main.mm:100-108, Swift:528-533).
+- Host exit code 2 after a `kOpLog "profile-locked"` = profile already open
+ elsewhere -> `processGone reason:"locked"` (main.mm:2786-2806, Swift:521).
+- Present size-gate (LAW 4): the plugin promotes a presented bridge
+ handle to the Flutter texture ONLY when `{srcW,srcH}` matches the expected
+ `round(logical*dpr)` for the current size (±1 px); until then it keeps
+ serving the previous texture (main.mm:640-665 rationale).
+- Bridge-handle identity (LAW 3): the host-minted legacy handle is the
+ identity Flutter sees; never key anything on CEF's per-callback
+ `shared_texture_handle` values (SPIKES.md S4).
+- The plugin holds an opened `ID3D11Texture2D` ComPtr on the current bridge
+ handle for as long as it feeds it to Flutter (LAW 6 / S1 belt-1).
+- cef_host args: `--ipc=` `--profile-dir=` `--ephemeral`
+ `--allowed-schemes=` (§6) and — for agent control (§8) — `--cdp-io-pipes=
+ ,` (cf. macOS args main.mm:32-38; `--cdp-port` TCP CDP is still
+ post-slice — the Dart-side `enableCdp`+named-profile assert already blocks the
+ unsafe combination, so `cdpPort` stays 0 on Windows).
+
+## 6. Profile model (P6 foundation + P11 profile slice)
+
+The plugin owns ONE `cef_host` process per **profile key** and multiplexes N
+browsers over it (one wire browserId each) — the macOS
+`CefProfileHost`/`FlutterCefPlugin` model transcribed to Windows. Key =
+the sanitized `profile` name for a named profile, or `"~ephemeral~"+sessionId`
+for the default (throwaway) case, so every ephemeral session gets its own host
+and every view with the same non-null `profile` shares one host → one cookie
+jar → one login (macOS: FlutterCefPlugin.swift:326-327, CefProfileHost.swift:1-12).
+
+### 6.1 Profile-dir resolution (plugin side)
+
+The `create` verb's `profile` arg (String, omit-when-empty — §3) selects the
+mode. The plugin resolves an on-disk cache dir and always passes it as
+`--profile-dir=` (macOS `resolveProfileDir`,
+FlutterCefPlugin.swift:697-728):
+
+- **Ephemeral** (`profile` absent/empty): a unique throwaway dir
+ `%TEMP%\flutter_cef_ephem_`, created + removed on host shutdown, and the
+ host is launched WITH `--ephemeral` (macOS uses `flutter_cef_ephem_` +
+ `--ephemeral=1`, FlutterCefPlugin.swift:704-708 / CefProfileHost.swift:286-288).
+- **Named / persistent** (`profile` non-empty): a stable dir
+ `%LOCALAPPDATA%\flutter_cef\profiles\`, launched WITHOUT
+ `--ephemeral`. Sanitize the name to `[A-Za-z0-9._-]` (every other char → `_`),
+ and neutralize an all-dots leaf (`.`/`..`/`...`) to `_` so it can't escape the
+ `profiles\` container (macOS FlutterCefPlugin.swift:710-717 — mirror this
+ exactly, including the all-dots guard).
+
+ NOTE — Windows path root differs from macOS DELIBERATELY: macOS uses
+ `//flutter_cef/profiles/`; Windows uses
+ `%LOCALAPPDATA%\flutter_cef\profiles\` (no per-app bundleId segment).
+ **Multi-app shared-profiles-root caveat**: every flutter_cef app for a given
+ Windows user therefore shares this one `profiles\` root, so a `profile: 'work'`
+ in app A and app B resolve to the SAME dir (analogous to the macOS
+ shared-"Chromium Safe Storage"-keychain caveat). Co-locate only
+ mutually-trusting apps on a shared profile name.
+
+- **DACL**: create the named-profile dir (and its `profiles\` ancestor) with a
+ current-user-SID-protected DACL — the same pattern `ipc_pipe.cpp` uses for the
+ pipe (audit fix #3). This is the Windows analogue of macOS's `0700`
+ owner-only chmod (FlutterCefPlugin.swift:706/722/726). Re-apply on an existing
+ leaf from a prior run (macOS re-chmods at :726).
+
+### 6.2 Host side (`--profile-dir` → `root_cache_path`)
+
+`cef_host` maps `--profile-dir` to `CefSettings.root_cache_path` and sets
+`settings.persist_session_cookies = true` (macOS main.mm:2854-2869). One
+`root_cache_path` is shared by every browser in the process — that is what makes
+the login shared. `persist_session_cookies` keeps session cookies across relaunch
+(harmless for ephemeral, required for "stay signed in" on a named profile). The
+`--ephemeral` flag (main.mm:2726, `is_ephemeral`) marks the throwaway case so the
+host's guards (CDP-on-named-profile refusal, and on macOS the mock-keychain
+downgrade) fire only for a REAL persistent profile — `--profile-dir` is set for
+both.
+
+### 6.3 At-rest encryption — Windows DPAPI (NO macOS-style downgrade)
+
+**KEY Windows security fact (SPIKES.md S2):** OSCrypt on Windows encrypts the
+cookie store with **DPAPI**, which is **always available and
+signing-independent**. So the macOS rule "ad-hoc build → mock keychain → downgrade
+a named profile to ephemeral (unless `FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1`)"
+has **NO Windows analogue** — there is no mock-keystore state and no downgrade.
+A named profile on Windows simply persists, encrypted at rest, regardless of code
+signing. Concretely: `kOpReady`'s `readyFlags` bit0 (ad-hoc/mock-keychain build)
+is **macOS-only; the Windows host always sends 0** (§2, main.mm:1664-1682), and
+there is no `onInsecureProfileRefused` / re-home-to-ephemeral path on Windows.
+
+**Caveat (state it, do not hide it):** DPAPI's user-tier protection is
+**same-user-readable** — any process running as the same Windows user can call
+`CryptUnprotectData` and decrypt the store. This is **weaker than the macOS
+Keychain**, which can prompt / ACL-scope access. So on Windows the at-rest
+guarantee is "protected against other users / offline disk theft, NOT against
+other same-user processes." `localStorage`/IndexedDB are plaintext on both
+platforms (FileVault/BitLocker is the backstop).
+
+### 6.4 Cookies API (the four verbs + the result event)
+
+Cookies act on the profile's ONE process-wide `CefCookieManager` (the shared
+jar §6.1), so a write/clear is visible to every browser in the host. The four
+command opcodes and the one result event are fully specified in §2 (byte layouts,
+with main.mm cites) and reached from Dart via the verbs in §3/§4 — summarized here:
+
+| Dart (controller) | Verb (§3) | Opcode (§2) | main.mm |
+|---|---|---|---|
+| `setCookie(url,name,value,domain,path)` | `setCookie` | 0x2c `{utf8 url\0name\0value\0domain\0path}` (pad to 5) | 2495-2510 |
+| `clearCookies()` | `clearCookies` | 0x2d `{}` | 2511-2514 |
+| `getCookies({url})` → `List` | `visitCookies` | 0x2e `{u32 id}{utf8 url}` (empty = all) → **0x1a** `{u32 id}{utf8 json-array}` event | 2515-2522 |
+| `deleteCookie(url,name)` | `deleteCookie` | 0x2f `{utf8 url\0name}` | 2523-2531 |
+
+`getCookies` is the only round-trip: the plugin assigns `id`, sends `visitCookies`,
+and resolves the Dart `Future` when the `cookies` event (from opcode 0x1a — §4)
+arrives carrying the same `id` and a JSON array (`CefCookie.fromJson` per element,
+cef_web_controller.dart:741-767). The JSON array shape MUST match macOS
+byte-for-byte (main.mm's `DoVisitCookies` serializer) so a page cannot detect a
+Windows-vs-macOS divergence.
+
+## 7. JS bridge, JS dialogs, find, zoom, downloads (P7 verb parity)
+
+All opcode numbers and byte layouts are in §2; this section documents the
+**string packings** and the **per-session message-router routing** that the P7
+verbs depend on, transcribed from main.mm with line cites. The Dart side
+(cef_web_controller.dart, cross-platform, unchanged for Windows) parses exactly
+these shapes; the Windows host MUST reproduce them byte-for-byte so a page cannot
+detect a Windows-vs-macOS divergence.
+
+### 7.1 The message router (CefMessageRouter) — renderer + browser halves
+
+The JS bridge (channels + `runJavaScriptReturningResult`) rides one
+`CefMessageRouter` created with the **DEFAULT** `CefMessageRouterConfig`
+(`window.cefQuery` / `window.cefQueryCancel`). Browser-side and renderer-side
+MUST use the SAME config or queries never route.
+
+- **macOS reference:** browser-side router lives in the `Handler` (main.mm —
+ `router_->OnProcessMessageReceived`, main.mm:1495-1501; `OnQuery`,
+ main.mm:1472-1494). The renderer half is a SEPARATE process
+ (`process_helper.mm`): `CefMessageRouterRendererSide` with the default config,
+ wired in `OnContextCreated` / `OnContextReleased` / `OnProcessMessageReceived`.
+- **Windows:** there is no separate helper exe — the SAME `cef_host.exe` is
+ re-executed as the render subprocess via `CefExecuteProcess`, so the
+ renderer-side router lives in cef_host's `CefApp::GetRenderProcessHandler`
+ (cef_host_win.cc), branched by process type. Both halves still use the DEFAULT
+ config (contract, not Dart-visible).
+
+### 7.2 JS channels — `addJavaScriptChannel` / `removeJavaScriptChannel`
+
+Page → host. The shim is injected NATIVELY (there is no Dart-injected shim):
+
+- `addJavaScriptChannel(name)` → verb `addJavaScriptChannel` → **0x2b
+ kOpAddChannel** `{utf8 name}` (§2). The host validates `name` is a JS
+ identifier (`IsValidChannelName`, main.mm:362-375; ≤64 chars,
+ `[A-Za-z_$][A-Za-z0-9_$]*`) and registers it process-globally in `g_channels`
+ (`DoAddChannel`, main.mm:2019-2035). Invalid names are dropped + logged, never
+ fatal. Dart pre-validates with the same regex (cef_web_controller.dart:165,
+ 669-671) and re-registers every channel on `create()`
+ (cef_web_controller.dart:541-544) so add-before-mount works.
+- **Shim injection** (`InjectChannelShim`, main.mm:377-384) — injected into the
+ **MAIN frame ONLY** on every `OnLoadStart` (main.mm:1337-1343) and into the
+ current frame at registration time (main.mm:2034). The injected source is
+ exactly:
+ ```js
+ window['']={postMessage:function(m){window.cefQuery({request:'ch::'+String(m),
+ persistent:false,onSuccess:function(){},onFailure:function(){}});}};
+ ```
+- **Page → host delivery.** `window..postMessage(m)` calls
+ `window.cefQuery` with `request = "ch::"`. `OnQuery`
+ (main.mm:1487-1492) refuses subframe queries (privileged bridge; 403), strips
+ the `"ch:"` prefix (3 bytes), and sends **0x17 kOpChannelMsg** `{utf8
+ "name:message"}` (main.mm:1489). The plugin emits `channelMessage
+ {payload:"name:message"}` (§4); Dart splits at the FIRST `:` and dispatches to
+ the registered handler (`_handleChannelMessage`, cef_web_controller.dart:336-340)
+ — colons in the message body are preserved.
+- **Per-session routing (mandatory — channel_probe_shared).** `OnQuery` stamps
+ `slot_->browser_id` (the originating browser) on kOpChannelMsg
+ (main.mm:1489), and the plugin fans the event to only that session's Dart
+ channel handler. A message from tile A's page reaches A's handler, never B's,
+ even on a shared host. This is an information-sharing boundary (the channel
+ NAME is process-global), not a message-spoofing one.
+- `removeJavaScriptChannel(name)` is **Dart-local** (cef_web_controller.dart:683-685):
+ it stops delivery to the handler but does NOT tear down the page-side shim
+ (which is process-global on a shared profile), so the page may still post — those
+ messages are dropped. No opcode.
+
+### 7.3 `runJavaScriptReturningResult` — the eval round-trip
+
+- `runJavaScriptReturningResult(code)` assigns an `id` and sends verb
+ `evalReturning` → **0x2a kOpEvalReturning** `{u32 id}{utf8 code}` (§2,
+ cef_web_controller.dart:655-662). The host (`DoEvalReturning`,
+ main.mm:2001-2018) SPLICES `code` into a `window.cefQuery` call (not `eval()`,
+ so it survives a strict page CSP) that JSON-stringifies
+ `{ok:true,v:()}` on success or `{ok:false,v:String(e)}` on throw, with
+ `request = "eval::"`.
+- `OnQuery` (main.mm:1481-1486) refuses subframe queries (403), strips the
+ `"eval:"` prefix (5 bytes), and sends **0x16 kOpEvalResult** `{utf8
+ "id:json"}` (main.mm:1483). The plugin emits `evalResult
+ {payload:"id:json"}` (§4); Dart splits at the FIRST `:`, matches the pending
+ completer by `id`, and JSON-decodes the tail: `ok:true` completes with `v`,
+ `ok:false` completes with an `Exception('')` (`_handleEvalResult`,
+ cef_web_controller.dart:297-313).
+- In-flight evals are failed (never leaked) on `pageStarted`
+ (cef_web_controller.dart:224-228), `processGone`, and `dispose`
+ (cef_web_controller.dart:317-324).
+- `executeJavaScript(code)` is the fire-and-forget sibling → **0x25
+ kOpExecuteJs** `{utf8 code}` (§2), no result event.
+
+### 7.4 JS dialogs — alert / confirm / prompt
+
+- Host → page request: `CefJSDialogHandler::OnJSDialog` (main.mm:1279-1303)
+ assigns a per-slot `id`, stashes the `CefJSDialogCallback`, and sends **0x0f
+ kOpJsDialog** `{u32 id}{u32 type}{u32 msgLen}{msg utf8}{defaultText utf8}`
+ (main.mm:1291-1301). `type`: 0=alert, 1=confirm, 2=prompt (main.mm:1286-1288).
+ The plugin emits `jsDialog {id,type,message,defaultText}` (§4).
+- Dart (`_handleJsDialog`, cef_web_controller.dart:345-382) routes by `type` to
+ `onJavaScriptAlertDialog` / `onJavaScriptConfirmDialog` /
+ `onJavaScriptTextInputDialog`, then replies verb `respondJsDialog
+ {id, ok, text}` → **0x29 kOpJsDialogResp** `{u32 id}{u8 ok}{utf8 text}` (§2).
+ Unset handlers fall closed to sensible defaults (alert dismissed, confirm→OK,
+ prompt→defaultText). A throwing handler fails closed (`ok=false`) but is
+ reported via `FlutterError.reportError` (not silently swallowed).
+- Host applies the answer: `DoJsDialogResp` (main.mm:1994-2000) looks up the
+ stashed callback by `id` and calls `Continue(ok, text)`, which returns the
+ page's `alert`/`confirm`/`prompt`. `OnBeforeUnloadDialog` always allows
+ navigation (main.mm:1304-1309).
+
+### 7.5 Find-in-page
+
+- `find(text, forward, matchCase, findNext)` → verb `find` → **0x27 kOpFind**
+ `{u8 fwd}{u8 matchCase}{u8 findNext}{utf8 text}` (§2,
+ cef_web_controller.dart:858-868; host read main.mm:2452-2459 → `DoFind`).
+- `stopFind(clearSelection)` → verb `stopFind` → **0x28 kOpStopFind** `{u8
+ clearSelection}` (absent = 1) (§2, cef_web_controller.dart:871-872; host
+ main.mm:2460-2465 → `DoStopFind`, main.mm:1991-1993).
+- Result event: `CefFindHandler::OnFindResult` (main.mm:1260-1275) sends **0x0e
+ kOpFindResult** `{u32 count}{u32 activeOrdinal}{u8 final}` = 9 bytes
+ (main.mm:1265-1274). The plugin emits `findResult
+ {count, activeMatchOrdinal, isFinal}` (§4); Dart delivers a `CefFindResult`
+ to `onFindResult` (cef_web_controller.dart:239-245). ⌘F/Ctrl+F is surfaced to
+ the host via `CefWebView.onFind` (cef_web_view.dart:571-579) — the widget has
+ no find bar of its own.
+
+### 7.6 Content zoom
+
+- `setZoomLevel(level)` → verb `setZoomLevel` → **0x26 kOpSetZoom** `{f64
+ level}` (§2, cef_web_controller.dart:822-823; host read main.mm:2434-2438 →
+ `DoSetZoom`). `level` is a Chromium zoom LEVEL; the factor is `1.2^level`
+ (0 = 100%). The view wires Ctrl/⌘ +/-/0 to step it (cef_web_view.dart:559-570).
+ No result event.
+
+### 7.7 Downloads
+
+- `CefDownloadHandler::OnBeforeDownload` (main.mm:1251-1257) allows the download
+ (CEF blocks downloads without a handler), continues with an empty path +
+ `show_dialog=true` (native Save panel), and sends **0x18 kOpDownload** `{utf8
+ suggestedName}` (main.mm:1254). The plugin emits `download {suggestedName}`
+ (§4); Dart invokes `onDownload(suggestedName)`
+ (cef_web_controller.dart:255-257). Informational only (no reply verb).
+
+## 8. Agent control — CDP-over-pipe + the token-gated loopback relay (P9)
+
+An external CDP client (Playwright via `connectOverCDP`, agent-browser) drives a
+live, logged-in tile **without** an open debug port: Chromium speaks CDP over an
+inherited pipe (`--remote-debugging-pipe` + `--remote-debugging-io-pipes`,
+NUL-framed JSON), and a small token-gated LOOPBACK HTTP+WebSocket relay bridges a
+standard CDP client to that pipe. Transcribed from the macOS reference
+`CdpRelay.swift` (the canonical relay) + `CefProfileHost.swift`
+(launchViaPosixSpawn / readCdpLoop / enableAgentControl); the Windows spawn is
+the S3 recipe (`flutter_cef_spikes/s3`). **SINGLE-TILE scope:** one relay per
+host (raw browser-level passthrough — the pipe carries exactly one page target);
+the per-tile Target-domain filter + N-relay CDP-id multiplex (CEF-2b,
+`CdpRelay.swift:560-884`) is a documented follow-up (the `scope_target_id` seam
+in `windows/cdp_relay.h`).
+
+### 8.1 Launch — the CDP pipe (S3 recipe)
+
+The `create` arg `agentControl:true` (§3) switches the host launch mechanism.
+`HostProcess::Spawn(agent_control=true)` (windows/host_process.cpp):
+
+- `CreatePipe` × 2 (anonymous). `cmd_pipe`: parent writes CDP → child reads.
+ `out_pipe`: child writes CDP → parent reads.
+- `SetHandleInformation(HANDLE_FLAG_INHERIT)` on **only the two child-side ends**;
+ a `STARTUPINFOEX` `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` naming exactly those two
+ (so nothing else leaks). This **composes** with the existing spawn, which
+ inherits nothing: the IPC pipe is connected by NAME (`CreateFileW`) and the Job
+ Object is assigned post-spawn — neither is an inherited handle. `bInheritHandles
+ = TRUE` + `EXTENDED_STARTUPINFO_PRESENT` are added only on this path; the
+ non-agent spawn stays byte-identical (S2/S3 confirm bootstrap preserves the
+ handle list).
+- The child gets `--cdp-io-pipes=,` (decimal HANDLE
+ values). cef_host's `OnBeforeCommandLineProcessing` (browser process only)
+ translates it into Chromium's `--remote-debugging-pipe` +
+ `--remote-debugging-io-pipes=,`. Mirrors macOS main.mm's
+ `--cdp-pipe` → `remote-debugging-pipe` injection (fds 3/4 there; explicit HANDLE
+ values here).
+- The plugin keeps the two PARENT-side ends (`CdpTransport::read`/`write`) and
+ runs an always-on `CdpReadLoop` that splits the NUL-framed CDP stream and fans
+ each message to the current relay (mirrors `CefProfileHost.readCdpLoop` +
+ `deliverCdpToRelays`). `cdpPort` stays 0 — there is no listening socket.
+
+### 8.2 The relay (`windows/cdp_relay.{h,cpp}`, winsock + bcrypt)
+
+A loopback HTTP+WS server (`socket`/`bind` `127.0.0.1:0` → OS-assigned ephemeral
+port, accept thread, detached per-connection handlers), a direct winsock port of
+`CdpRelay.swift`:
+
+- **Discovery (token-free):** `GET /json/version` · `/json` · `/json/list`
+ advertise `webSocketDebuggerUrl = ws://127.0.0.1:/devtools/browser` (the
+ token is NOT in the discovery response).
+- **Upgrade (token-REQUIRED):** RFC-6455 handshake; `Sec-WebSocket-Accept =
+ base64(SHA-1(key + GUID))` via BCrypt. The upgrade is rejected **401** without a
+ valid `Authorization: Bearer ` header (a `?token=` query is an accepted
+ fallback); constant-time compared. A second concurrent client is **503**'d
+ (single active client).
+- **Bridge:** masked client text frames → `send_to_pipe` (NUL-framed CDP command
+ onto `cmd_pipe`); pipe messages → `DeliverToClient` (unmasked text frame to the
+ client). Frame/message cap 64 MiB; ping→pong; close handled.
+- **Token:** 24 CSPRNG bytes (`BCryptGenRandom`) hex-encoded (48 chars).
+
+Security posture (matches macOS): loopback only, ephemeral unadvertised port,
+mandatory token, single client, and the relay exists **only while the grant is
+active** (created by `enableAgentControl`, torn down by `disableAgentControl` /
+dispose / host-death). Strictly better than raw Chrome's fixed, always-open,
+multi-client `--remote-debugging-port`.
+
+### 8.3 Verbs
+
+- `enableAgentControl` → starts (idempotently) the relay for the session's host
+ and returns `{wsUrl, token, port}` — the macOS return shape **exactly**:
+ `wsUrl = ws://127.0.0.1:/devtools/browser?token=`
+ (`CefProfileHost.endpoint`). Errors `no_agent_control` if the session was not
+ created with `agentControl:true`.
+- `disableAgentControl` → stops the relay (closes the listener + any client,
+ invalidates the token); the tile keeps running. Idempotent. Also torn down on
+ `dispose` / host death (the reaper stops the relay, joins the CDP reader, and
+ closes the pipe ends).
+
+### 8.4 Deferred (N-tile Target multiplex)
+
+Not implemented in the slice (single-tile is passthrough): `Target.getTargetInfo`
+targetId resolution (`kOpResolveTargetId 0x36` → `kOpTargetId 0x1b`, still a
+logged drop host-side), the deny-by-default / fail-closed / flatten-only
+Target-domain filter, and the per-relay CDP-id rewrite/demux that lets N relays
+share one browser-wide pipe. See `CdpRelay.swift:560-884` +
+`CefProfileHost.swift:1460-1596` and the filter test vectors
+`packages/flutter_cef_macos/macos/Classes/test/CdpRelayFilterTests.swift`.
diff --git a/packages/flutter_cef_windows/native/cef_host/build_cef_host.bat b/packages/flutter_cef_windows/native/cef_host/build_cef_host.bat
new file mode 100644
index 0000000..b8cb402
--- /dev/null
+++ b/packages/flutter_cef_windows/native/cef_host/build_cef_host.bat
@@ -0,0 +1,75 @@
+@echo off
+rem build_cef_host.bat — build cef_host.dll + stage cef_host.exe (renamed
+rem bootstrapc.exe, LAW 8). Invoked standalone by developers AND from the
+rem plugin's windows/CMakeLists.txt add_custom_command during
+rem `flutter build windows`.
+rem
+rem Usage: build_cef_host.bat [BUILD_DIR] [OUT_DIR]
+rem BUILD_DIR cmake binary dir (default: %~dp0build)
+rem OUT_DIR where cef_host.dll + cef_host.exe land (default: BUILD_DIR)
+rem Env overrides:
+rem CEF_ROOT extracted cef_binary_144.0.27 windows64_minimal dir
+rem CEF_WRAPPER_LIB prebuilt /MT libcef_dll_wrapper.lib to reuse
+rem VSINSTALLDIR Visual Studio install root (skips the vswhere lookup)
+rem CMAKE cmake.exe to use (default: VS-bundled, else on PATH)
+rem NINJA ninja.exe to use (default: VS-bundled, else on PATH)
+rem
+rem Deterministic + incremental-safe: configure only when build.ninja is
+rem missing; ninja no-ops when nothing changed.
+
+setlocal enabledelayedexpansion
+set "SRC_DIR=%~dp0"
+if "%~1"=="" (set "BUILD_DIR=%SRC_DIR%build") else (set "BUILD_DIR=%~1")
+if "%~2"=="" (set "OUT_DIR=%BUILD_DIR%") else (set "OUT_DIR=%~2")
+
+rem --- MSVC toolchain: locate Visual Studio via vswhere (honor VSINSTALLDIR).
+rem No hardcoded edition/year — any install with the VC++ x64 tools works.
+if defined VSINSTALLDIR (
+ set "VS_INSTALL=%VSINSTALLDIR%"
+) else (
+ set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
+ if not exist "!VSWHERE!" (
+ echo build_cef_host: vswhere.exe not found - install Visual Studio or set VSINSTALLDIR 1>&2
+ exit /b 1
+ )
+ for /f "usebackq tokens=*" %%i in (`"!VSWHERE!" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VS_INSTALL=%%i"
+)
+if not defined VS_INSTALL (
+ echo build_cef_host: no Visual Studio with the VC++ x64 tools found - set VSINSTALLDIR 1>&2
+ exit /b 1
+)
+call "!VS_INSTALL!\VC\Auxiliary\Build\vcvars64.bat" >nul
+if errorlevel 1 exit /b 1
+
+rem --- cmake: honor a pre-set CMAKE, else the VS-bundled copy, else PATH.
+if not defined CMAKE (
+ set "CMAKE=!VS_INSTALL!\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe"
+ if not exist "!CMAKE!" set "CMAKE=cmake"
+)
+
+rem --- ninja: honor a pre-set NINJA (pass it to cmake), else prepend the
+rem VS-bundled ninja dir when present, else rely on ninja on PATH.
+set "MAKE_PROG_ARG="
+if defined NINJA (
+ set MAKE_PROG_ARG=-DCMAKE_MAKE_PROGRAM="!NINJA!"
+) else (
+ set "NINJA_DIR=!VS_INSTALL!\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja"
+ if exist "!NINJA_DIR!\ninja.exe" set "PATH=!NINJA_DIR!;%PATH%"
+)
+
+if not exist "%BUILD_DIR%\build.ninja" (
+ "!CMAKE!" -G Ninja -DCMAKE_BUILD_TYPE=Release !MAKE_PROG_ARG! -S "%SRC_DIR%." -B "%BUILD_DIR%"
+ if errorlevel 1 exit /b 2
+)
+"!CMAKE!" --build "%BUILD_DIR%" --target cef_host
+if errorlevel 1 exit /b 3
+
+if /i not "%OUT_DIR%"=="%BUILD_DIR%" (
+ if not exist "%OUT_DIR%" mkdir "%OUT_DIR%"
+ copy /y "%BUILD_DIR%\cef_host.dll" "%OUT_DIR%\cef_host.dll" >nul
+ if errorlevel 1 exit /b 4
+ copy /y "%BUILD_DIR%\cef_host.exe" "%OUT_DIR%\cef_host.exe" >nul
+ if errorlevel 1 exit /b 5
+)
+echo cef_host staged: %OUT_DIR%\cef_host.dll + cef_host.exe
+exit /b 0
diff --git a/packages/flutter_cef_windows/native/cef_host/cef_host_protocol.h b/packages/flutter_cef_windows/native/cef_host/cef_host_protocol.h
new file mode 100644
index 0000000..8d58f31
--- /dev/null
+++ b/packages/flutter_cef_windows/native/cef_host/cef_host_protocol.h
@@ -0,0 +1,139 @@
+// flutter_cef Windows — wire protocol constants + big-endian codecs.
+//
+// Shared by the cef_host process (native/cef_host/cef_host_win.cc) and the
+// Flutter plugin (windows/ipc_pipe.cpp): plain C++/Win32, no CEF includes, so
+// both builds can consume it.
+//
+// THE CONTRACT IS PROTOCOL.md (this directory) — transcribed from the macOS
+// reference `packages/flutter_cef_macos/native/cef_host/main.mm:111-164`.
+// Opcode numbers are copied VERBATIM from main.mm. Framing:
+// [u32 bodyLen BE][u32 browserId BE][u8 opcode][payload]
+// bodyLen = 4 + 1 + payloadLen; guard 5 <= bodyLen <= 64 MiB; browserId 0 =
+// process-level (kOpReady, process-level kOpLog, inbound kOpShutdown).
+//
+// ONE payload difference vs macOS (LAW 10): kOpPresent on Windows carries
+// {u64 bridgeHandle BE}{u32 srcW BE}{u32 srcH BE} (16 bytes)
+// instead of macOS's {u32 iosurfaceId}{u32 srcW}{u32 srcH} (12 bytes).
+// bridgeHandle is the DXGI LEGACY shared handle (IDXGIResource::
+// GetSharedHandle) of the host-minted D3D11_RESOURCE_MISC_SHARED bridge
+// texture — the identity Flutter sees (LAW 3). srcW/srcH are the PHYSICAL
+// pixel dims of the frame actually composited (the size-gate signal, LAW 4).
+
+#ifndef FLUTTER_CEF_WINDOWS_NATIVE_CEF_HOST_CEF_HOST_PROTOCOL_H_
+#define FLUTTER_CEF_WINDOWS_NATIVE_CEF_HOST_CEF_HOST_PROTOCOL_H_
+
+#include
+#include
+
+namespace flutter_cef {
+
+// ---- Wire protocol version ----
+// Announced in kOpReady's payload byte 1 (byte 0 is the ready-flags byte).
+// Must stay equal to main.mm:108 kCefHostProtocolVersion and
+// CefProfileHost.swift:42 — the wire is shared cross-platform.
+constexpr uint8_t kCefHostProtocolVersion = 3;
+
+// Framing guard (main.mm:2347): minimum body = 4 (browserId) + 1 (op).
+constexpr uint32_t kMinBodyLen = 5;
+constexpr uint32_t kMaxBodyLen = 64u << 20; // 64 MiB
+
+// ---- Opcodes: cef_host -> plugin (main.mm:111-133) ----
+constexpr uint8_t kOpPresent = 0x01; // WINDOWS: {u64 bridgeHandle}{u32 srcW}{u32 srcH}
+constexpr uint8_t kOpReady = 0x02; // {u8 readyFlags}{u8 protocolVersion} browserId 0
+constexpr uint8_t kOpCursor = 0x03; // {u32 cef_cursor_type_t}
+constexpr uint8_t kOpLog = 0x04; // {utf8}
+constexpr uint8_t kOpLoadState = 0x05; // {loading,back,forward : u8}
+constexpr uint8_t kOpTitle = 0x06; // {utf8}
+constexpr uint8_t kOpUrl = 0x07; // {utf8} main-frame address
+constexpr uint8_t kOpLoadErr = 0x08; // {code:u32}{utf8 "url\ntext"}
+constexpr uint8_t kOpConsole = 0x09; // {level:u32}{utf8 "source:line\tmsg"}
+constexpr uint8_t kOpPageStart = 0x0a; // {utf8 url} main frame load started
+constexpr uint8_t kOpPageFinish = 0x0b; // {utf8 url} main frame load finished
+constexpr uint8_t kOpProgress = 0x0c; // {u32 percent 0-100}
+constexpr uint8_t kOpNewWindow = 0x0d; // {utf8 url} popup / target=_blank
+constexpr uint8_t kOpFindResult = 0x0e; // {u32 count}{u32 activeOrdinal}{u8 final}
+constexpr uint8_t kOpJsDialog = 0x0f; // {u32 id}{u32 type}{u32 msgLen}{msg}{default}
+constexpr uint8_t kOpEvalResult = 0x16; // {utf8 "id:json"} runJavaScriptReturningResult
+constexpr uint8_t kOpChannelMsg = 0x17; // {utf8 "name:message"} JS channel -> host
+constexpr uint8_t kOpDownload = 0x18; // {utf8 suggestedName} a download started
+constexpr uint8_t kOpImeBounds = 0x19; // {u32 x}{u32 y}{u32 w}{u32 h} caret rect (DIP)
+constexpr uint8_t kOpCookies = 0x1a; // {u32 id}{utf8 json-array} visitAllCookies result
+constexpr uint8_t kOpTargetId = 0x1b; // {utf8 targetId} this browser's CDP targetId
+constexpr uint8_t kOpCreated = 0x1c; // {} OnAfterCreated — browser is up
+constexpr uint8_t kOpCreateFailed = 0x1d; // {} async CreateBrowser dispatch failed
+
+// ---- Opcodes: plugin -> cef_host (main.mm:134-164) ----
+constexpr uint8_t kOpPointer = 0x10; // {u8 type}{u8 btn}{u8 clicks}{u8 pad}{u32 mods}{f64 x}{f64 y}{f64 dx}{f64 dy}
+constexpr uint8_t kOpResize = 0x11; // {u32 w}{u32 h}{f64 dpr} — producer-allocates: no sid
+constexpr uint8_t kOpKey = 0x12; // {u8 type}{pad*3}{u32 mods}{u32 wkc}{u32 nkc}{u32 char}
+constexpr uint8_t kOpCreateBrowser = 0x13; // {u32 w}{u32 h}{f64 dpr}{utf8 url}; frame browserId = NEW id
+constexpr uint8_t kOpShutdown = 0x14; // {} tear down the whole PROCESS; frame browserId 0
+constexpr uint8_t kOpDisposeBrowser = 0x15; // {} close ONE browser; process survives
+constexpr uint8_t kOpNavigate = 0x20; // {utf8 url}
+constexpr uint8_t kOpReload = 0x21;
+constexpr uint8_t kOpStop = 0x22;
+constexpr uint8_t kOpBack = 0x23;
+constexpr uint8_t kOpForward = 0x24;
+constexpr uint8_t kOpExecuteJs = 0x25; // {utf8 code}
+constexpr uint8_t kOpSetZoom = 0x26; // {f64 level} (factor = 1.2^level)
+constexpr uint8_t kOpFind = 0x27; // {u8 fwd}{u8 matchCase}{u8 findNext}{utf8}
+constexpr uint8_t kOpStopFind = 0x28; // {u8 clearSelection}
+constexpr uint8_t kOpJsDialogResp = 0x29; // {u32 id}{u8 ok}{utf8 text}
+constexpr uint8_t kOpEvalReturning = 0x2a; // {u32 id}{utf8 code}
+constexpr uint8_t kOpAddChannel = 0x2b; // {utf8 name} register a JS channel
+constexpr uint8_t kOpSetCookie = 0x2c; // {utf8 url\0name\0value\0domain\0path}
+constexpr uint8_t kOpClearCookies = 0x2d; // {} delete all cookies
+constexpr uint8_t kOpVisitCookies = 0x2e; // {u32 id}{utf8 url} enumerate (url empty = all)
+constexpr uint8_t kOpDeleteCookie = 0x2f; // {utf8 url\0name} delete one
+constexpr uint8_t kOpImeSetComp = 0x30; // {utf8 text} IME composition update
+constexpr uint8_t kOpImeCommit = 0x31; // {utf8 text} commit composed text
+constexpr uint8_t kOpImeCancel = 0x32; // {} cancel composition
+constexpr uint8_t kOpShowDevTools = 0x33; // {} open DevTools in a window
+constexpr uint8_t kOpLoadTrusted = 0x34; // {utf8 url} host content-load, exempt from allowlist
+constexpr uint8_t kOpSetVisible = 0x35; // {u8 visible} -> CefBrowserHost::WasHidden(!visible)
+constexpr uint8_t kOpResolveTargetId = 0x36;// {} resolve CDP targetId -> kOpTargetId
+constexpr uint8_t kOpInvalidate = 0x37; // {} force a repaint (re-kick a stalled first frame)
+constexpr uint8_t kOpEditCommand = 0x38; // {u8 cmd} 0=copy 1=cut 2=paste 3=selectAll 4=undo 5=redo
+
+// 0x1e is RESERVED (PLAN §4.3's kOpPresentV2 earmark) — do not assign.
+
+// ---- Big-endian codecs (mirror main.mm ReadU32BE/WriteU32BE/ReadF64BE) ----
+
+inline uint32_t ReadU32BE(const uint8_t* p) {
+ return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) |
+ (uint32_t(p[2]) << 8) | uint32_t(p[3]);
+}
+
+inline void WriteU32BE(uint8_t* p, uint32_t v) {
+ p[0] = static_cast((v >> 24) & 0xff);
+ p[1] = static_cast((v >> 16) & 0xff);
+ p[2] = static_cast((v >> 8) & 0xff);
+ p[3] = static_cast(v & 0xff);
+}
+
+inline uint64_t ReadU64BE(const uint8_t* p) {
+ return (uint64_t(ReadU32BE(p)) << 32) | uint64_t(ReadU32BE(p + 4));
+}
+
+inline void WriteU64BE(uint8_t* p, uint64_t v) {
+ WriteU32BE(p, static_cast(v >> 32));
+ WriteU32BE(p + 4, static_cast(v & 0xffffffffu));
+}
+
+inline double ReadF64BE(const uint8_t* p) {
+ uint64_t bits = ReadU64BE(p);
+ double d;
+ static_assert(sizeof(d) == sizeof(bits), "double must be 64-bit");
+ std::memcpy(&d, &bits, sizeof(d));
+ return d;
+}
+
+inline void WriteF64BE(uint8_t* p, double d) {
+ uint64_t bits;
+ std::memcpy(&bits, &d, sizeof(bits));
+ WriteU64BE(p, bits);
+}
+
+} // namespace flutter_cef
+
+#endif // FLUTTER_CEF_WINDOWS_NATIVE_CEF_HOST_CEF_HOST_PROTOCOL_H_
diff --git a/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc b/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc
new file mode 100644
index 0000000..0bb5c69
--- /dev/null
+++ b/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc
@@ -0,0 +1,2195 @@
+// cef_host (Windows) — a standalone CEF off-screen-rendering subprocess.
+//
+// The Flutter plugin (packages/flutter_cef_windows/windows/) spawns one
+// cef_host per profile and drives N browsers in it over a named-pipe IPC.
+// The wire contract is PROTOCOL.md + cef_host_protocol.h in this directory —
+// transcribed verbatim from the macOS reference
+// packages/flutter_cef_macos/native/cef_host/main.mm, with ONE payload
+// difference (LAW 10): kOpPresent carries {u64 bridgeHandle BE}{u32 srcW BE}
+// {u32 srcH BE} where bridgeHandle is the DXGI LEGACY shared handle of the
+// host-minted D3D11_RESOURCE_MISC_SHARED bridge texture.
+//
+// Ship shape (LAW 8, SPIKES.md S2): this builds as cef_host.dll exporting
+// RunConsoleMain, loaded by CEF's prebuilt bootstrapc.exe shipped RENAMED to
+// cef_host.exe beside it. sandbox_info is forwarded to BOTH CefExecuteProcess
+// and CefInitialize. The slice runs settings.no_sandbox = 1 (sandbox is P11).
+//
+// THE LAWS this file keeps (specs/windows-port/SPIKES.md):
+// 1. external_begin_frame_enabled = FALSE; windowless_frame_rate = 60.
+// (S4: with the external pump only the FIRST browser in the process ever
+// paints. The macOS PumpBeginFrame pacer is NOT ported; everywhere main.mm
+// calls SendExternalBeginFrame we use Invalidate(PET_VIEW) — with the
+// internal frame timer ON that is sufficient to drive a repaint.)
+// 2. OnAcceleratedPaint's NT handle is valid ONLY inside the callback:
+// OpenSharedResource1 + CopyResource to the legacy bridge INSIDE the
+// callback, synchronously. The NT handle is never stored.
+// 3. Identity is never keyed on shared_texture_handle VALUES (they alias
+// across sizes and browsers). The bridge texture handle WE mint is the
+// identity Flutter sees.
+// 4. Every WasResized discards CEF's pool; late frames at the OLD size still
+// arrive — every kOpPresent carries the TRUTHFUL composited dims
+// (srcW/srcH) so the plugin's size-gate (the macOS SendPresentLocked
+// consumer contract, PROTOCOL.md §5) can refuse stale-size frames.
+// 5. Bridge textures are D3D11_RESOURCE_MISC_SHARED (LEGACY handle via
+// IDXGIResource::GetSharedHandle, not NT) — what ANGLE/Flutter accepts.
+// 6. (Plugin-side; supported here by keeping the retired bridge alive until
+// the present announcing its replacement has been written to the pipe.)
+//
+// Args (per-PROCESS / per-profile, mirroring main.mm:32-38):
+// --ipc= the plugin's already-created named pipe
+// --profile-dir= -> settings.root_cache_path
+// --ephemeral marks the profile dir throwaway
+// --allowed-schemes= optional navigation scheme allowlist
+// (empty/omitted = allow all; main.mm:2727-2737)
+
+#include
+
+#include
+#include
+#include // SHGetKnownFolderPath / FOLDERID_Downloads (downloads)
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "include/base/cef_bind.h"
+#include "include/base/cef_callback.h"
+#include "include/cef_app.h"
+#include "include/cef_browser.h"
+#include "include/cef_client.h"
+#include "include/cef_command_line.h"
+#include "include/cef_cookie.h"
+#include "include/cef_download_handler.h"
+#include "include/cef_find_handler.h"
+#include "include/cef_jsdialog_handler.h"
+#include "include/cef_life_span_handler.h"
+#include "include/cef_permission_handler.h"
+#include "include/cef_render_handler.h"
+#include "include/cef_render_process_handler.h"
+#include "include/cef_request_handler.h"
+#include "include/cef_sandbox_win.h"
+#include "include/cef_task.h"
+#include "include/cef_v8.h"
+#include "include/wrapper/cef_closure_task.h"
+#include "include/wrapper/cef_helpers.h"
+#include "include/wrapper/cef_message_router.h"
+
+#include "cef_host_protocol.h"
+
+// SHGetKnownFolderPath (downloads dir) + CoTaskMemFree live in shell32/ole32.
+// These pragmas keep the TU self-linking without touching CMake.
+#pragma comment(lib, "shell32.lib")
+#pragma comment(lib, "ole32.lib")
+
+namespace {
+
+using namespace flutter_cef; // opcodes + BE codecs (cef_host_protocol.h)
+using Microsoft::WRL::ComPtr;
+
+// ---- Shared runtime state ----
+// The IPC pipe handle. Atomic for the same reason main.mm:166-172 makes the
+// fd atomic: the reader thread, SendFrame (any CEF thread), and teardown all
+// touch it.
+std::atomic g_ipc_pipe{INVALID_HANDLE_VALUE};
+std::mutex g_ipc_write_mutex;
+
+// One hidden window per host process, passed to SetAsWindowless(parent) so
+// dialogs/menus/IMM degrade gracefully (PLAN §4.3; S5 works either way but
+// an HWND is the better default).
+HWND g_hidden_hwnd = nullptr;
+
+// Host-set navigation scheme allowlist (lowercased; --allowed-schemes=a,b).
+// Empty = allow all. `about` is always allowed. Enforced in
+// HostClient::OnBeforeBrowse exactly like main.mm:335-342/1528-1565.
+std::set g_allowed_schemes;
+
+// Agent-control CDP-over-pipe (P9). "," decimal inherited-HANDLE
+// values from the plugin's --cdp-io-pipes= switch (the S3 recipe): the browser
+// READS CDP commands from and WRITES responses/events to . Set in
+// RunConsoleMain (browser process only) BEFORE CefInitialize; OnBeforeCommand-
+// LineProcessing then injects Chromium's --remote-debugging-pipe +
+// --remote-debugging-io-pipes. Empty = agent control off (byte-identical to the
+// pre-P9 launch). Mirrors macOS main.mm's --cdp-pipe translation.
+std::string g_cdp_io_pipes;
+
+// Registered JS channel names (UI-thread-only; mirrors main.mm:352-356). On
+// each MAIN-frame load OnLoadStart injects a window..postMessage shim
+// that routes to the browser process over window.cefQuery (the
+// CefMessageRouter channel; the renderer half lives in HostApp below).
+std::set g_channels;
+
+// A channel name is spliced into the injected shim's source, so it MUST be a
+// plain JS identifier — else a crafted name could break out of the string
+// literal and run arbitrary script on every page load (main.mm:362-375,
+// verbatim). DoAddChannel drops invalid names.
+bool IsValidChannelName(const std::string& n) {
+ if (n.empty() || n.size() > 64) return false;
+ auto isFirst = [](unsigned char c) {
+ return std::isalpha(c) || c == '_' || c == '$';
+ };
+ auto isRest = [](unsigned char c) {
+ return std::isalnum(c) || c == '_' || c == '$';
+ };
+ if (!isFirst(static_cast(n[0]))) return false;
+ for (size_t i = 1; i < n.size(); ++i) {
+ if (!isRest(static_cast(n[i]))) return false;
+ }
+ return true;
+}
+
+// Inject the per-channel page-side shim (window..postMessage ->
+// window.cefQuery 'ch::'). BYTE-for-byte identical to main.mm:
+// 377-384 so a page cannot detect a Windows-vs-macOS divergence.
+void InjectChannelShim(CefRefPtr frame, const std::string& name) {
+ if (!frame) return;
+ std::string js = "window['" + name +
+ "']={postMessage:function(m){window.cefQuery({request:'ch:" +
+ name + ":'+String(m),persistent:false,"
+ "onSuccess:function(){},onFailure:function(){}});}};";
+ frame->ExecuteJavaScript(js, "", 0);
+}
+
+// The user's Downloads folder (Windows analogue of macOS's native save panel).
+// Empty on failure — OnBeforeDownload then falls back to a Save-As dialog.
+std::wstring GetDownloadsDir() {
+ PWSTR path = nullptr;
+ if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Downloads, 0, nullptr, &path)) &&
+ path) {
+ std::wstring dir(path);
+ CoTaskMemFree(path);
+ return dir;
+ }
+ if (path) CoTaskMemFree(path);
+ // Fallback: %USERPROFILE%\Downloads.
+ wchar_t up[MAX_PATH] = {};
+ const DWORD n = GetEnvironmentVariableW(L"USERPROFILE", up, MAX_PATH);
+ if (n > 0 && n < MAX_PATH) return std::wstring(up) + L"\\Downloads";
+ return std::wstring();
+}
+
+void LogErr(const char* fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ fprintf(stderr, "\n");
+ fflush(stderr);
+ va_end(ap);
+}
+
+// ---- Pipe I/O (framing per PROTOCOL.md §1 / main.mm:416-446) ----
+//
+// OVERLAPPED, EMPIRICALLY REQUIRED (found via pipe_probe, 2026-07-20): on a
+// SYNCHRONOUS pipe handle Windows serializes all I/O on the file object, so
+// the reader thread's pending blocking ReadFile makes any WriteFile from a
+// CEF thread queue behind it — the UI thread froze inside
+// SendFrame(kOpCreated), CefRunMessageLoop stalled, and every child process
+// then died with "Terminating current process after 15 seconds with no
+// connection" (no renderer/GPU/network — browsers never came up). A Unix
+// socket fd is full-duplex so macOS never sees this. The pipe is therefore
+// opened with FILE_FLAG_OVERLAPPED and every read/write runs event-based
+// overlapped I/O; concurrent read+write on the one handle is then legal.
+// (The plugin side of the pipe needs the same treatment — ipc_pipe.cpp.)
+
+bool OverlappedIo(HANDLE pipe, void* buf, size_t len, bool write) {
+ uint8_t* p = static_cast(buf);
+ HANDLE ev = CreateEventW(nullptr, TRUE, FALSE, nullptr);
+ if (!ev) return false;
+ bool ok = true;
+ size_t off = 0;
+ while (off < len) {
+ OVERLAPPED ov = {};
+ ov.hEvent = ev;
+ DWORD n = 0;
+ BOOL res = write ? WriteFile(pipe, p + off,
+ static_cast(len - off), nullptr, &ov)
+ : ReadFile(pipe, p + off, static_cast(len - off),
+ nullptr, &ov);
+ if (!res && GetLastError() != ERROR_IO_PENDING) {
+ ok = false; // broken pipe / cancelled / error
+ break;
+ }
+ if (!GetOverlappedResult(pipe, &ov, &n, TRUE)) {
+ ok = false;
+ break;
+ }
+ if (n == 0) {
+ ok = false; // peer closed
+ break;
+ }
+ off += n;
+ }
+ CloseHandle(ev);
+ return ok;
+}
+
+bool ReadAllPipe(HANDLE pipe, void* buf, size_t len) {
+ return OverlappedIo(pipe, buf, len, /*write=*/false);
+}
+
+bool WriteAllPipe(HANDLE pipe, const void* buf, size_t len) {
+ return OverlappedIo(pipe, const_cast(buf), len, /*write=*/true);
+}
+
+// Frame layout: [u32 bodyLen BE][u32 browserId BE][u8 opcode][payload].
+// bodyLen = 4 + 1 + payloadLen. Assembled whole + written under the write
+// mutex so a partial write never desyncs the peer (mirrors main.mm SendFrame).
+// C3 ordering: the handle is snapshotted UNDER the write lock; teardown
+// exchanges INVALID_HANDLE_VALUE + closes under this same lock, so a late
+// paint-thread send can never write into a recycled handle.
+void SendFrame(uint32_t browser_id, uint8_t opcode, const void* payload,
+ uint32_t payload_len) {
+ if (g_ipc_pipe.load() == INVALID_HANDLE_VALUE) return; // racy early-out
+ std::lock_guard lock(g_ipc_write_mutex);
+ HANDLE pipe = g_ipc_pipe.load();
+ if (pipe == INVALID_HANDLE_VALUE) return;
+ uint32_t body_len = 4 + 1 + payload_len;
+ std::vector frame(4 + body_len);
+ WriteU32BE(frame.data(), body_len);
+ WriteU32BE(frame.data() + 4, browser_id);
+ frame[8] = opcode;
+ if (payload_len) memcpy(frame.data() + 9, payload, payload_len);
+ WriteAllPipe(pipe, frame.data(), frame.size());
+}
+
+void SendLog(uint32_t browser_id, const std::string& msg) {
+ SendFrame(browser_id, kOpLog, msg.data(),
+ static_cast(msg.size()));
+}
+
+void SendUtf8(uint32_t browser_id, uint8_t op, const std::string& s) {
+ SendFrame(browser_id, op, s.data(), static_cast(s.size()));
+}
+
+void SendLoadState(uint32_t browser_id, bool loading, bool back, bool forward) {
+ uint8_t p[3];
+ p[0] = loading ? 1 : 0;
+ p[1] = back ? 1 : 0;
+ p[2] = forward ? 1 : 0;
+ SendFrame(browser_id, kOpLoadState, p, 3);
+}
+
+// op payload: [u32 BE code][utf8 body]. Used for load-error, console, cookies.
+void SendCodePlusUtf8(uint32_t browser_id, uint8_t op, uint32_t code,
+ const std::string& body) {
+ std::vector p(4 + body.size());
+ WriteU32BE(p.data(), code);
+ memcpy(p.data() + 4, body.data(), body.size());
+ SendFrame(browser_id, op, p.data(), static_cast(p.size()));
+}
+
+// ---- argv helpers ----
+
+std::string GetSwitch(int argc, char* argv[], const char* prefix) {
+ size_t n = strlen(prefix);
+ for (int i = 0; i < argc; ++i) {
+ if (strncmp(argv[i], prefix, n) == 0) return std::string(argv[i] + n);
+ }
+ return std::string();
+}
+
+bool HasFlag(int argc, char* argv[], const char* flag) {
+ for (int i = 0; i < argc; ++i) {
+ if (strcmp(argv[i], flag) == 0) return true;
+ }
+ return false;
+}
+
+std::wstring Widen(const std::string& s) {
+ if (s.empty()) return std::wstring();
+ int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0);
+ std::wstring w(n > 0 ? n - 1 : 0, L'\0');
+ if (n > 1)
+ MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &w[0], n);
+ return w;
+}
+
+// ---- Process-wide D3D11 device for the bridge-blit present path ----
+// One device + immediate context for the whole cef_host process, created
+// lazily on the first accelerated paint (mirrors main.mm's g_mtl_device
+// singleton, main.mm:320-333). The immediate context is NOT thread-safe;
+// OnAcceleratedPaint arrives on the CEF UI thread only, but g_d3d_mutex
+// serializes it anyway (belt + suspenders, and future-proof).
+ComPtr g_d3d_device;
+ComPtr g_d3d_device1;
+ComPtr g_d3d_ctx;
+std::mutex g_d3d_mutex;
+// EnsureD3D's "already tried and failed" latch. A file-global (not a function
+// static) so device-loss recovery can clear it; touched only on the CEF UI
+// thread (the sole OnAcceleratedPaint thread).
+bool g_d3d_tried = false;
+// Bumped on every device (re)create / loss so a Slot can tell its bridge was
+// minted on a now-dead device and re-mint (see EnsureBridgeForPaintLocked).
+// Atomic because Slots read it while only the UI thread writes it.
+std::atomic g_d3d_epoch{0};
+
+// Returns false (once, then cached until a device-loss reset) if D3D11 is
+// unavailable — the pixel path then degrades to the logged software OnPaint
+// fallback.
+bool EnsureD3D() {
+ if (g_d3d_device1) return true;
+ if (g_d3d_tried) return false;
+ g_d3d_tried = true;
+ UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
+ D3D_FEATURE_LEVEL fl;
+ HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr,
+ flags, nullptr, 0, D3D11_SDK_VERSION,
+ &g_d3d_device, &fl, &g_d3d_ctx);
+ if (FAILED(hr)) {
+ LogErr("[cef_host] D3D11CreateDevice failed 0x%08lx", hr);
+ return false;
+ }
+ hr = g_d3d_device.As(&g_d3d_device1);
+ if (FAILED(hr)) {
+ LogErr("[cef_host] ID3D11Device1 unavailable 0x%08lx", hr);
+ g_d3d_device.Reset();
+ g_d3d_ctx.Reset();
+ return false;
+ }
+ g_d3d_epoch.fetch_add(1); // a fresh device — all bridges must re-mint on it
+ return true;
+}
+
+// True if the cached device has been removed/reset (any GetDeviceRemovedReason
+// failure). Caller holds g_d3d_mutex.
+bool D3DDeviceLostLocked() {
+ return g_d3d_device && FAILED(g_d3d_device->GetDeviceRemovedReason());
+}
+
+// Drop the cached device/context so the NEXT EnsureD3D re-creates from scratch,
+// and bump the epoch so every Slot re-mints its bridge on the new device (their
+// old bridge textures died with the removed device). Caller holds g_d3d_mutex.
+void ResetD3DDeviceLocked() {
+ g_d3d_device1.Reset();
+ g_d3d_device.Reset();
+ g_d3d_ctx.Reset();
+ g_d3d_tried = false;
+ g_d3d_epoch.fetch_add(1);
+}
+
+// Per-browser state: one cef_host process multiplexes N browsers, one Slot
+// per plugin-assigned wire id (mirrors main.mm's Slot, main.mm:182-274, with
+// the IOSurface/Metal fields swapped for the D3D11 bridge and the macOS-only
+// begin-frame-pump fields dropped per LAW 1).
+struct Slot {
+ uint32_t browser_id = 0; // plugin-assigned wire id (>=1); NOT GetIdentifier().
+ CefRefPtr browser;
+ // H3 async-create dispose-loss guard (main.mm:186-191): a dispose arriving
+ // while CreateBrowser is in flight records intent here; OnAfterCreated
+ // honors it the instant the browser binds. UI-thread-confined.
+ bool close_requested = false;
+
+ // Guards bridge / width / height / dpr for THIS browser. Per-slot so paints
+ // on independent browsers don't contend (main.mm surface_mutex).
+ std::mutex surface_mutex;
+ // The host-minted legacy MISC_SHARED bridge texture (LAWs 3/5) — the
+ // identity Flutter sees. Re-minted whenever CEF paints at a new size
+ // (producer-allocates: the bridge always matches the painted frame, so the
+ // CopyResource below is always 1:1 — the wrong-size class is structurally
+ // gone, same rationale as main.mm EnsureSurfaceForPaint:749-785).
+ ComPtr bridge;
+ uint64_t bridge_handle = 0; // IDXGIResource::GetSharedHandle of `bridge`
+ int bridge_w = 0;
+ int bridge_h = 0;
+ // g_d3d_epoch the current `bridge` was minted at. A mismatch means the device
+ // it lives on was lost/reset, so the bridge must be re-minted (#9).
+ uint64_t bridge_epoch = 0;
+ // Belt-1 friendliness: the OLD bridge is kept alive here across a re-mint
+ // until the kOpPresent announcing its replacement has been written to the
+ // pipe (the plugin also holds its own opened D3D reference — LAW 6 — this
+ // is the producer-side half of that belt).
+ ComPtr retired_bridge;
+ // Set under surface_mutex in OnBeforeClose BEFORE releasing `bridge`, so a
+ // paint racing teardown doesn't re-mint a bridge for a closing browser.
+ bool closing = false;
+
+ int width = 800; // logical (DIP) — GetViewRect; CEF scales by dpr.
+ int height = 600;
+ double dpr = 1.0;
+
+ // Exact URLs armed for a host-trusted content load (kOpLoadTrusted /
+ // data:-file: create). Exact-URL matched + consumed in OnBeforeBrowse —
+ // see main.mm:222-233 for why it is URL-bound, not a one-shot flag.
+ // UI-thread only.
+ std::multiset trusted_pending;
+
+ // Pending JS dialog callbacks, keyed by id. UI-thread-only.
+ std::map> dialogs;
+ uint32_t dialog_next = 1;
+
+ // Visibility (kOpSetVisible -> WasHidden). UI-thread only. On Windows there
+ // is no begin-frame pump to gate (LAW 1); this drives WasHidden plus the
+ // hidden->visible repaint kick (the F-1 keystone, main.mm:1962-1985).
+ bool visible = true;
+ // F-1/F-2: a dpr change landing while hidden defers its screen-info
+ // re-assert to the hidden->visible edge. UI-thread only.
+ bool needs_screen_info_on_show = false;
+
+ // The URL to navigate to once the browser binds (a navigate that raced a
+ // still-queued create — main.mm:1876-1888 deferral). UI-thread only.
+ std::string pending_nav_url;
+
+ uint64_t diag_paint_count = 0; // DIAG (FLUTTER_CEF_DEBUG logging)
+};
+
+// Routing map from a wire browser id to its Slot. MUTATED ONLY ON THE CEF UI
+// THREAD (insert in DoCreateBrowser, erase in OnBeforeClose). The IPC reader
+// thread takes g_slots_mutex, copies the shared_ptr, releases the lock, then
+// operates — a slot stays alive for an in-flight op even if disposed
+// (main.mm:276-293).
+std::mutex g_slots_mutex;
+std::map> g_slots_by_wire_id;
+
+std::shared_ptr LookupWireId(uint32_t wire_id) {
+ if (wire_id == 0) return nullptr;
+ std::lock_guard lock(g_slots_mutex);
+ auto it = g_slots_by_wire_id.find(wire_id);
+ return it == g_slots_by_wire_id.end() ? nullptr : it->second;
+}
+
+// ---- Render handler: OSR -> legacy shared bridge texture ----
+// One handler per browser; holds a shared_ptr to that browser's Slot. All
+// bridge access is under slot_->surface_mutex; paints re-check slot_->closing
+// after taking the lock since OnBeforeClose releases the bridge under the
+// same lock.
+class HostRenderHandler : public CefRenderHandler {
+ public:
+ explicit HostRenderHandler(std::shared_ptr slot)
+ : slot_(std::move(slot)) {}
+
+ void GetViewRect(CefRefPtr, CefRect& rect) override {
+ std::lock_guard lock(slot_->surface_mutex);
+ rect = CefRect(0, 0, slot_->width, slot_->height);
+ }
+
+ // The REAL display (DIP), not the tile: `window.screen == innerWidth` is a
+ // textbook headless/OSR fingerprint (see main.mm RealScreenDip:586-609 and
+ // commit 855042d). GetSystemMetrics returns px in this process's DPI
+ // context; divide by dpr for DIP. Falls back to a plausible frame.
+ static void RealScreenDip(double dpr, CefRect& full, CefRect& work) {
+ const double s = dpr > 0.0 ? dpr : 1.0;
+ const int pw = GetSystemMetrics(SM_CXSCREEN);
+ const int ph = GetSystemMetrics(SM_CYSCREEN);
+ RECT wa = {};
+ if (pw <= 0 || ph <= 0 ||
+ !SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0)) {
+ full = CefRect(0, 0, 1920, 1080); // headless fallback: common 24"
+ work = CefRect(0, 0, 1920, 1080 - 48); // minus a taskbar
+ return;
+ }
+ full = CefRect(0, 0, static_cast(pw / s), static_cast(ph / s));
+ work = CefRect(static_cast(wa.left / s), static_cast(wa.top / s),
+ static_cast((wa.right - wa.left) / s),
+ static_cast((wa.bottom - wa.top) / s));
+ }
+
+ // Device scale so CEF renders logical*dpr (HiDPI-native) + real screen
+ // bounds and color depth (mirrors main.mm GetScreenInfo:614-625).
+ bool GetScreenInfo(CefRefPtr, CefScreenInfo& info) override {
+ std::lock_guard lock(slot_->surface_mutex);
+ info.device_scale_factor = static_cast(slot_->dpr);
+ info.depth = 24; // screen.colorDepth — 0 was a headless tell
+ info.depth_per_component = 8;
+ info.is_monochrome = 0;
+ CefRect full, work;
+ RealScreenDip(slot_->dpr, full, work);
+ info.rect = full;
+ info.available_rect = work;
+ return true;
+ }
+
+ // Plausible window frame at a non-zero offset, taller than the view by
+ // typical browser chrome (outerHeight > innerHeight like a real window) —
+ // main.mm GetRootScreenRect:633-638.
+ bool GetRootScreenRect(CefRefPtr, CefRect& rect) override {
+ std::lock_guard lock(slot_->surface_mutex);
+ constexpr int kChromeH = 87;
+ rect = CefRect(100, 80, slot_->width, slot_->height + kChromeH);
+ return true;
+ }
+
+ // PRODUCER-ALLOCATES: ensure the bridge is EXACTLY sw x sh — the dims CEF
+ // actually painted. Because the CopyResource dst is then the same size as
+ // the src, the copy is 1:1 and can never crop or leave stale margins
+ // (main.mm EnsureSurfaceForPaint rationale). Re-mints on first paint or any
+ // size change; the OLD bridge is parked in retired_bridge until the present
+ // that announces its replacement is on the wire. Caller holds
+ // slot_->surface_mutex AND g_d3d_mutex.
+ bool EnsureBridgeForPaintLocked(int sw, int sh) {
+ if (sw < 1 || sh < 1) return false;
+ if (slot_->closing) return false; // paint racing teardown: no re-mint
+ if (slot_->bridge && slot_->bridge_w == sw && slot_->bridge_h == sh &&
+ slot_->bridge_epoch == g_d3d_epoch.load())
+ return true; // steady state: zero allocation (same device + same size)
+ D3D11_TEXTURE2D_DESC d = {};
+ d.Width = static_cast(sw);
+ d.Height = static_cast(sh);
+ d.MipLevels = 1;
+ d.ArraySize = 1;
+ d.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
+ d.SampleDesc.Count = 1;
+ d.Usage = D3D11_USAGE_DEFAULT;
+ d.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
+ d.MiscFlags = D3D11_RESOURCE_MISC_SHARED; // LEGACY handle (LAW 5)
+ ComPtr fresh;
+ HRESULT hr = g_d3d_device->CreateTexture2D(&d, nullptr, &fresh);
+ if (FAILED(hr)) {
+ SendLog(slot_->browser_id,
+ "EnsureBridgeForPaint: CreateTexture2D failed hr=" +
+ std::to_string(static_cast(hr)));
+ return slot_->bridge != nullptr; // keep the old bridge; retry next paint
+ }
+ ComPtr res;
+ HANDLE legacy = nullptr;
+ if (FAILED(fresh.As(&res)) || FAILED(res->GetSharedHandle(&legacy)) ||
+ !legacy) {
+ SendLog(slot_->browser_id,
+ "EnsureBridgeForPaint: GetSharedHandle failed");
+ return slot_->bridge != nullptr;
+ }
+ // Park the old bridge until the present carrying the NEW handle is sent
+ // (belt-1 friendliness — the plugin's own opened ref is the primary belt).
+ slot_->retired_bridge = slot_->bridge;
+ slot_->bridge = fresh;
+ slot_->bridge_handle =
+ static_cast(reinterpret_cast(legacy));
+ slot_->bridge_w = sw;
+ slot_->bridge_h = sh;
+ slot_->bridge_epoch = g_d3d_epoch.load();
+ return true;
+ }
+
+ // Present the just-blitted bridge, tagging the frame with the bridge handle
+ // (the identity, LAW 3) and the PHYSICAL px dims of the frame actually
+ // composited (the size-gate signal, LAW 4 / PROTOCOL.md §5). Caller holds
+ // slot_->surface_mutex. Windows payload (LAW 10):
+ // {u64 bridgeHandle BE}{u32 srcW BE}{u32 srcH BE} = 16 bytes.
+ void SendPresentLocked(int srcW, int srcH) {
+ uint8_t p[16];
+ WriteU64BE(p, slot_->bridge_handle);
+ WriteU32BE(p + 8, static_cast(srcW < 0 ? 0 : srcW));
+ WriteU32BE(p + 12, static_cast(srcH < 0 ? 0 : srcH));
+ SendFrame(slot_->browser_id, kOpPresent, p, 16);
+ // The present announcing the new bridge is on the wire — the old bridge
+ // may now die (the plugin holds its own reference to it, LAW 6).
+ slot_->retired_bridge.Reset();
+ }
+
+ // GPU pixel path (LAW 2, the S1 cef_leg.cpp recipe): CEF's GPU process
+ // composites the page and hands an NT shared handle valid ONLY inside this
+ // callback. Open it on our device, CopyResource into the legacy bridge,
+ // Flush — all synchronously, never storing the NT handle.
+ void OnAcceleratedPaint(CefRefPtr, PaintElementType type,
+ const RectList&,
+ const CefAcceleratedPaintInfo& info) override {
+ slot_->diag_paint_count++;
+ // Deferred navigate (a navigate that raced the async create — the browser
+ // has certainly bound by first paint). Mirrors main.mm:1004-1008.
+ if (!slot_->pending_nav_url.empty() && slot_->browser) {
+ std::string nav = slot_->pending_nav_url;
+ slot_->pending_nav_url.clear();
+ if (auto frame = slot_->browser->GetMainFrame()) frame->LoadURL(nav);
+ }
+ if (type == PET_POPUP) {
+ //