From 4dd74ab743f8904e6142c9df1b747bed109d8162 Mon Sep 17 00:00:00 2001 From: wenkai fan Date: Mon, 20 Jul 2026 21:32:02 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat(windows):=20P0=20=E2=80=94=20plan,=20s?= =?UTF-8?q?pikes=20(7/7=20PASS),=20gate=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs/windows-port/PLAN.md: the full port plan (verified-vs-assumed table, architecture, phases, security deltas, open decisions). specs/windows-port/SPIKES.md: Phase-0 spike report — S1 cross-process GPU handle->ANGLE/Flutter (tearing 1.81%, copy ~0.5ms), S2 bootstrapc sandbox model, S3 CDP io-pipes, S4 handle-identity laws (external-begin-frame refuted on CEF 144), S5 WebAuthn viable in OSR, S6 toolchain recipe. .gitattributes: LF-pin native/** + *.sh so cef_host_hash.sh digests match across OSes. PORTING.md: correct producer-allocates inversion, --remote-debugging-io-pipes, and the CEF 144 bootstrap sandbox model. Co-Authored-By: Claude Fable 5 --- .gitattributes | 8 + PORTING.md | 13 +- specs/windows-port/PLAN.md | 902 +++++++++++++++++++++++++++++++++++ specs/windows-port/SPIKES.md | 179 +++++++ 4 files changed, 1098 insertions(+), 4 deletions(-) create mode 100644 .gitattributes create mode 100644 specs/windows-port/PLAN.md create mode 100644 specs/windows-port/SPIKES.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fddab01 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# 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 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/specs/windows-port/PLAN.md b/specs/windows-port/PLAN.md new file mode 100644 index 0000000..b418a8f --- /dev/null +++ b/specs/windows-port/PLAN.md @@ -0,0 +1,902 @@ +# flutter_cef — Windows port + +Goal: `CefWebView` renders, composites, and behaves on Windows as it does on +macOS — same app-facing API, same method-channel contract, same wire protocol +(with one lockstep bump), same test gates — via a new endorsed +`flutter_cef_windows` package and a Windows build of `cef_host`. This is a +second platform, not a porting chore. + +## 1. Bottom line + +**Feasible. No fundamental blocker.** Every load-bearing unknown is either +CONFIRMED against primary sources (§2) or has a named Phase-0 spike with a +documented fallback (§3). + +- **Cost: ~30 engineer-weeks to full parity** (honest range 26–34; the three + competing plans priced 23.5/26/31.5 and the low ones underpriced their + riskiest phases). One senior C++/Win32/D3D11 engineer ≈ 6–8 months calendar; + a second engineer parallelizes only after first pixel (~4.5–5.5 months for + two). +- **First demonstrable value at ~week 5**: a live, interactive web page inside + a Flutter `Texture` widget on Windows, software-rendered, macOS untouched by + construction. That milestone is the go/no-go checkpoint; abandoning there + costs ~6 weeks and leaves macOS byte-identical. +- **Reuse ledger** (wc -l, verified): Dart carries over ~wholesale — 2,287 + lines with ~250 lines of platform-conditional edits. Native host: ~2,100 of + main.mm's 2,924 lines are already plain C++ and move into a shared core; + ~800–900 lines get a Windows twin. The 4,226 lines of Swift plugin/relay + (CefProfileHost 1,710 + CdpRelay 919 + FlutterCefPlugin 780 + CefWebSession + 727 + policies 90) do **not** carry — Windows plugins are C++; expect + ~5,500–6,500 C++ lines out (EncodableMap extraction is 3–6 lines where Swift + needs 1). All ~1,050 lines of build/bundle/sign tooling (podspec, codesign, + fetch/publish scripts) are macOS-shaped and get Windows rewrites. +- **One genuinely novel problem with zero prior art anywhere**: the shared GPU + texture crosses *three* processes (CEF GPU proc → cef_host → Flutter/ANGLE). + Every existing Windows CEF-in-Flutter project (webview_cef, + flutter-webview-windows, CefSharp, JCEF) runs the browser in-process. macOS + gets the extra hop free via `IOSurfaceIsGlobal` (main.mm:773); Windows has no + equivalent primitive that is both cross-process and acceptable to ANGLE. + Spiked in week 1 with an **animated** pass criterion and a pre-designed + fallback (§3 S1). + +Before committing, answer the buy-vs-build question in §10 honestly: if +Windows users only need "a webview", WebView2 delivers 80% of the value for +~5% of this cost. This port is justified only by what WebView2 cannot do: +multi-tile OSR compositing into Flutter's scene, per-tile CDP token isolation, +out-of-process crash isolation, shared profile hosts. + +## 2. Verified vs. assumed + +Every claim below was checked against primary sources (CEF branch 7559 = +CEF 144, Chromium tag 144.0.7559.254, the CEF builds CDN, ANGLE extension +specs, local Flutter engine source). Anything UNVERIFIED maps to a §3 spike. + +| # | Claim | Status | Evidence | +|---|---|---|---| +| 1 | The pinned CEF build (`144.0.27+g3fae261+chromium-144.0.7559.254`, build_cef_host.sh:17) ships windows64/windowsarm64/windows32 minimal+standard tarballs, channel stable | **CONFIRMED** | cef-builds.spotifycdn.com index.json; windows64_minimal built 2026-06-03 | +| 2 | Windows `OnAcceleratedPaint` delivers an **NT** `HANDLE`, created **without a keyed mutex**, non-owning, pool-released when the callback returns; must be opened via `ID3D11Device1::OpenSharedResource1` synchronously inside the callback | **CONFIRMED** | include/internal/cef_types_win.h + include/cef_render_handler.h (branch 7559), verbatim; CEF #4027 | +| 3 | macOS struct field is *named differently* (`shared_texture_io_surface` vs `shared_texture_handle`) — a `#if` on the field is mandatory, a typedef is not enough | **CONFIRMED** | cef_types_mac.h vs cef_types_win.h | +| 4 | CEF-143 null-shared-handle regression (#4057) is absent from our 144.0.27 pin | **CONFIRMED** | issue closed 2025-12-19 via PR #4059; pinned build dated 2026-06-03 | +| 5 | Flutter's DXGI path binds via `EGL_D3D_TEXTURE_2D_SHARE_HANDLE_ANGLE`, which requires a **legacy** `D3D11_RESOURCE_MISC_SHARED` handle; NT and legacy are mutually exclusive on one texture ⇒ **exactly one `CopyResource` is structurally mandatory** (note: macOS is not zero-copy either — CompositeMetalLocked blits, main.mm:850-985) | **CONFIRMED** | engine external_texture_d3d.cc:98-106 (local source); ANGLE EGL_ANGLE_d3d_share_handle_client_buffer spec | +| 6 | `kFlutterDesktopGpuSurfaceTypeD3d11Texture2D` is unusable from a plugin (ANGLE rejects textures not created on its own, unexposed, device) | **CONFIRMED** | ANGLE EGL_ANGLE_d3d_texture_client_buffer spec; Renderer11::getD3DTextureInfo | +| 7 | `TextureRegistrar::UnregisterTexture` is **asynchronous** on Windows; the engine may invoke the surface callback after it returns | **CONFIRMED** | flutter_texture_registrar.h:165 + flutter_windows_texture_registrar.cc:79-101 | +| 8 | `release_callback` fires **every frame**; EGL surface is re-imported only when `handle` changes; omitted `struct_size` ⇒ SAFE_ACCESS reads `handle` as null ⇒ silent black texture | **CONFIRMED** | external_texture_d3d.cc:83-115 | +| 9 | If ANGLE fails to init, `RegisterTexture` still returns a valid id and the callback is never invoked (blank, no log) | **CONFIRMED (substance)** | flutter_windows_texture_registrar.cc:26-30 guards only `!gl_` | +| 10 | CEF 144 ships **no `cef_sandbox.lib`** for Windows; the model is a client DLL exporting `RunWinMain`/`RunConsoleMain` + prebuilt `bootstrap.exe`/`bootstrapc.exe`; PORTING.md:92 and specs/cross-platform/PLAN.md:53 are **stale** | **CONFIRMED (header) / corroborated (tarball)** | cef_sandbox_win.h declares the bootstrap entry points; CEF #3824. Tarball enumeration = spike S2 | +| 11 | `--remote-debugging-pipe` takes **no** handle arguments; the handle-passing switch is the separate `--remote-debugging-io-pipes=,` (uint32-serialized inheritable HANDLEs), used in addition. **PORTING.md:91 is REFUTED** | **CONFIRMED** | content_switches.cc at tag 144.0.7559.254 | +| 12 | Windows OSCrypt = DPAPI-wrapped AES-256-GCM key in Local State (CEF: `LocalPrefs.json` in the cache dir); always available, signing-independent, decryptable by **any same-user process**. The macOS ad-hoc→mock-keychain→ephemeral-downgrade rail (main.mm:1598-1599, :2761-2768) has nothing to trigger on Windows | **CONFIRMED (substance)** | components/os_crypt behavior; `--use-mock-keychain` is Apple-only, `--password-store` Linux-only | +| 13 | `kOpPresent` is already 12 bytes `{u32 sid}{u32 srcW}{u32 srcH}` — the OSR_SCALE_MISMATCH size-gate (Fix-1) is **landed**; and `kOpCreateBrowser` carries **no** surface id (producer-allocates). PORTING.md's seam table says the opposite and would lead a porter into a deadlocking consumer-allocates model | **CONFIRMED** | main.mm:654-665, main.mm:137 | +| 14 | No `.gitattributes` exists; `tool/cef_host_hash.sh` hashes raw bytes ⇒ an `autocrlf=true` Windows checkout computes a different prebuilt digest than the macOS publisher forever | **CONFIRMED** | repo inspection | +| 15 | A legacy `MISC_SHARED` handle minted in cef_host opens via ANGLE in the **separate** Flutter process, tear-acceptably, at 12 tiles/60fps | **UNVERIFIED — no prior art exists** | → spike **S1** | +| 16 | `OnBeforeCommandLineProcessing` lands before Chromium reads `--remote-debugging-io-pipes`; a bootstrap-launched process inherits pipe handles + forwards argv into `RunConsoleMain` | **UNVERIFIED (inferred from the working macOS `--cdp-pipe` pattern)** | → spike **S3** | +| 17 | CEF's shared handle value stability across frames/resizes; whether mutating descriptor `handle` reproduces flutter/flutter#154716's engine GPU leak; whether size-bucketing suppresses it | **UNVERIFIED** | → spike **S4** | +| 18 | WebAuthn/passkeys complete in a windowless browser on Windows (webauthn.dll's Windows Hello modal takes an HWND; OSR `GetWindowHandle()` is NULL; main.mm:1059-1067 deliberately exempts WebAuthn from the permission gate) | **RESOLVED (SPIKES.md S5): the native passkey modal appears even with `SetAsWindowless(nullptr)` — Windows brokers WebAuthn UI out-of-process via CredentialUIBroker.exe. v1 supports passkeys; plumb the runner HWND into `SetAsWindowless` for dialog placement** | spike **S5** — PASS | +| 19 | `PluginRegistrarWindows::GetGraphicsAdapter` exists at plugin-registrar level | **VERSION-DEPENDENT** — 3.38.8+ only; on 3.32.8 it is `FlutterView::GetGraphicsAdapter`. CI pins 3.38.8 (ci.yaml:20); local box is 3.32.8 | → all spikes standardize on 3.38.8 | +| 20 | CEF headers compile under Flutter's `apply_standard_settings` (`_HAS_EXCEPTIONS=0` + `/W4 /WX`); plugin symlinks + out-of-tree native sources resolve under `generated_plugins.cmake` | **UNVERIFIED (webview_cef's workaround taken on faith)** | → spike **S6** | +| 21 | A Windows build of the consumer's custom Flutter engine (run_conformance_oracle.sh:25 → `work_canvas/scripts/campus-flutter-engine.sh`; the ci.yaml 3.38.8 pin rationale) exists, and whether its patches touch ExternalTextureD3d/ANGLE | **UNVERIFIED — gates the entire oracle strategy** | → §10 decision D5, resolved before P8 | + +## 3. Phase 0 — spikes (throwaway code, outside the package tree) + +All spikes on Flutter **3.38.8** + VS2022. Output is a written PASS/FAIL +report per spike with the fallback promoted into the architecture on FAIL. +Repo changes in P0 are limited to: `.gitattributes` (`packages/**/native/** +text eol=lf`), and the PORTING.md corrections (seam-table producer-allocates +inversion; line drift; the :91 CDP claim). + +- **S1 — Cross-process legacy handle → ANGLE, ANIMATED.** Process A creates + an `ID3D11Texture2D` (B8G8R8A8, `MISC_SHARED`) on the LUID adapter reported + by the Flutter engine, renders a **rolling frame counter + moving pattern at + 60fps across 12 tiles**, ships `IDXGIResource::GetSharedHandle()` values + over a pipe. Process B is a minimal Flutter app feeding the raw u64 into + `FlutterDesktopGpuSurfaceDescriptor.handle`. Also: feed a real CEF 144 + `OnAcceleratedPaint` NT handle → `OpenSharedResource1` → `CopyResource` → + legacy handle, measuring copy cost. **PASS = per-frame content assertion + holds, a measured tearing/partial-frame rate is reported (not just "it lit + up"), and a destroy-while-bound probe demonstrates what the consumer + observes when the producer frees the texture** (this drives the lifetime + protocol, §4.4). *Fallback A*: plugin-side bridge — cef_host + `DuplicateHandle`s the NT handle into the plugin (plugin passes its process + HANDLE at spawn); plugin opens + copies (one extra hop, a D3D device in the + plugin). *Fallback B*: permanent software path only (works — shipped in P3 + anyway — more CPU, no 12-tile/60fps bar). +- **S2 — Sandbox/bootstrap model + tarball enumeration.** Download the actual + windows64 minimal tarball for the pin; enumerate it (confirm no + cef_sandbox.lib, confirm bootstrap.exe/bootstrapc.exe, read + cef_sandbox_win.h as shipped). Build the smallest sandboxed OSR frame as + cef_host.dll + `RunConsoleMain` + bootstrapc.exe; verify inherited HANDLEs + and argv survive the bootstrap entry; verify the `icacls … *S-1-15-2-2` + LPAC grant requirement. *Fallback*: `no_sandbox=true` v1 with the posture + bit surfaced (§7) and a hard pre-GA deadline. +- **S3 — CDP io-pipes.** `CreatePipe` ×2 + `SetHandleInformation` + + `STARTUPINFOEX` `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` + `CreateProcessW`; + child injects `--remote-debugging-pipe` **and** + `--remote-debugging-io-pipes=,` in `OnBeforeCommandLineProcessing`; + drive one NUL-framed `Target.getTargets` round trip, including through the + bootstrap entry from S2. *Fallback*: CRT lpReserved2 fd-3/4 block (last + resort — Chromium issue 40259890 documents file corruption when fds 3/4 are + not real pipes); second fallback: loopback `--remote-debugging-port` behind + the relay, accepting the unauthenticated-port window. +- **S4 — Handle identity + resize.** Log CEF's `shared_texture_handle` across + 5,000 frames and across resizes at 12 tiles (stable / recycled / aliased?). + Determine whether mutating the descriptor `handle` reproduces + flutter/flutter#154716's GPU leak and whether a 64-px size lattice (crop via + `visible_width/height`) suppresses it. *Fallback*: media_kit's + unregister/re-register-new-texture-id strategy — which needs a new + `textureChanged` event, since Dart ignores `resize`'s return today + (cef_web_controller.dart:873-879). +- **S5 — WebAuthn/passkeys.** Load a WebAuthn test page in a windowless CEF + 144 browser with (a) a hidden `WS_CHILD` parent HWND, (b) a visible owner. + Does the Windows Hello modal appear, and parented to what? *Fallback*: + document passkeys as unsupported on Windows v1 (they are already + entitlement-gated and out of scope on macOS per + specs/persistent-profiles/PLAN.md:7-9), or require a visible owner HWND + plumbed from the runner. +- **S6 — Toolchain probes** (half-day each): compile one CEF header under + `apply_standard_settings` (`_HAS_EXCEPTIONS=0` `/W4 /WX`; **RESULT + (SPIKES.md S6a): headers compile CLEAN under stock flags — the entire fix + is `NOMINMAX` per target + wrapper rebuilt per-config with + `-DCEF_RUNTIME_LIBRARY_FLAG=/MD` (/MDd for Debug); no `cxx_std_20`, no + warning relaxations**); `add_subdirectory` through + `flutter/ephemeral/.plugin_symlinks` into out-of-tree native sources + (Developer-Mode symlink requirement?); confirm native tar.exe (bsdtar) + decompresses the .tar.bz2 on the CI image; `Get-Command cmake` on a stock + VS2022 box (it is NOT on PATH — the dev-setup doc must say so). + +**P0 gate:** spike report committed to `specs/windows-port/SPIKES.md` with a +verdict per spike and, for every FAIL, the fallback promoted into §4; +`.gitattributes` landed; PORTING.md corrected; empty diff elsewhere. + +## 4. Architecture + +### 4.1 Dart (`packages/flutter_cef_windows` + shared-Dart edits) + +New package mirrors flutter_cef_macos exactly: `implements: flutter_cef`, +`platforms: windows: {pluginClass: FlutterCefPlugin, dartPluginClass: +FlutterCefWindows}`, a 19-line `registerWith()` setting +`FlutterCefPlatform.instance = MethodChannelFlutterCef()`. Root pubspec gains +`windows: default_package: flutter_cef_windows` + path dep (today only +`macos:` is declared — a Windows app gets `MissingPluginException` on every +call). `FlutterCefPlatform` stays channel-only; no new interface members. + +Shared-Dart changes (app-facing API frozen; all additive/conditional): + +- **`CefKeyProfile`** (`enum {mac, win}` in platform_interface, derived from + `defaultTargetPlatform`, `@visibleForTesting` override so both suites run on + one host). Drives: + - Accelerator: `isMetaOnly` (cef_web_view.dart:499-555) → `isAcceleratorOnly` + keyed on the profile — Ctrl on Windows for copy/cut/paste/selectAll/undo, + Ctrl+Y **and** Ctrl+Shift+Z for redo, Ctrl+`=`/`-`/`0` zoom, Ctrl+F. The + ⌃⌘Space branch (:487-491) and `_seedCaretRect` become mac-only. Do **not** + set `kCefEventFlagCommandDown` from `isMetaPressed` on Windows (:358-366) + — the Win key must not present as `metaKey`. + - Key codes: new `cefWinNativeKeyCode(PhysicalKeyboardKey, {repeat})` + returning a WM_KEYDOWN-shaped lParam (scan code bits 16-23, extended bit + 24) — the only value from which Blink derives DOM `event.code` + (`ui::KeycodeConverter::NativeKeycodeToDomCode`); `character = 0` for + editing/navigation keys (no NSEvent 0xF700 codepoints). Extend + `cefWindowsKeyCode` (cef_input.dart:180-188) for F1-F12, `VK_OEM_*`, + Insert, numpad — those return 0 today on **both** platforms. +- **`loadFile` bug** (cef_web_controller.dart:812): `file://C:\path` → + `Uri.file(absolutePath, windows: …)` = `file:///C:/dev/page.html`. +- **Create-failure state**: `_ensureSession` awaits `create()` inside an + un-awaited post-frame callback (cef_web_view.dart:259-267, :305-306); wrap + it so `MissingPluginException`/native errors render a failure widget rather + than a permanent placeholder. +- **`CefSurfaceInfo.surfaceId`**: no shape change (Dart int is 64-bit). + Re-document (cef_events.dart:82-100 + cef_web_controller.dart:139-146) as + "an opaque platform surface token — global IOSurface id on macOS, DXGI + legacy share handle on Windows". +- Multi-click thresholds: `GetDoubleClickTime`/`SM_CXDOUBLECLK` via a one-shot + `getInputMetrics` channel call on Windows; macOS keeps its constants. +- `showEmojiPicker` → `PlatformException(MethodNotImplemented)` on Windows + (Win+`.` is shell-owned; `SendInput` synthesis is fragile and steals + focus). `performSelector` documented mac-only. The example app's emoji + button (example/lib/main.dart:189-193) is hidden per-profile. +- **IME**: keep `enableDeltaModel: true`; the Win32 embedder never emits + deltas, so Windows lands on the `updateEditingValue` fallback (:802-823) — + currently untested against the deliberately-empty scratch buffer. P7 + rebuilds it as a diff-against-last-known-value strategy selected by + `CefKeyProfile`, retaining `setComposingRect`/`setEditableSizeAndTransform` + (they drive `ImmSetCandidateWindow`); the caret-seed and re-click `show()` + AppKit hacks become mac-only. + +### 4.2 Windows host plugin (C++, `packages/flutter_cef_windows/windows/`) + +`flutter::Plugin` + `PluginRegistrarWindows` + `MethodChannel` +/ `StandardMethodCodec`. Infrastructure that must exist before anything else +compiles (Windows plugins get **no** task runner; `InvokeMethod` and +`MarkTextureFrameAvailable` are platform-thread-only): + +- `platform_dispatcher` (~120 LOC): message-only HWND (`HWND_MESSAGE`) + + `PostMessage` draining a mutex-guarded deque — replaces ~40 + `DispatchQueue.main.async` sites. +- `delayed_scheduler` (~150 LOC): thread + `priority_queue` + + `condition_variable::wait_until` — replaces the 10 + `DispatchQueue.global().asyncAfter` timers in CefProfileHost.swift. +- `named_pipe_transport` (~300 LOC): `\\.\pipe\fcef-<128 random bits>`, + `PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE` + (defeats squatting), `PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | + PIPE_REJECT_REMOTE_CLIENTS`, explicit current-user-SID DACL (**never** a + NULL SD; never JCEF's `D:(A;;FA;;;WD)`); client opens with + `SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS`. Reader restructured around + OVERLAPPED + `CancelIoEx` + `WaitForMultipleObjects` (there is no + `shutdown()`-wakes-the-reader on named pipes); the macOS "never close a + handle on join timeout — deliberately leak" rule + (CefProfileHost.swift:999-1002) carries over verbatim. +- `job_guard` (~60 LOC): `CreateJobObject` + `JOB_OBJECT_LIMIT_KILL_ON_JOB_ + CLOSE` + `AssignProcessToJobObject` — kernel-guaranteed no orphaned + cef_host even under `TerminateProcess` of the app (strictly stronger than + macOS's `willTerminateNotification`), plus a `RegisterTopLevelWindowProc + Delegate` on `WM_ENDSESSION`. Retires the kqueue `WatchParentDeath` on this + side. + +Discipline for the CefProfileHost/CefWebSession rewrite — the highest-variance +work in the port (all three adversarial reviews independently named it, not +graphics): + +- `shared_ptr` + `enable_shared_from_this` **mandatory**, private + constructors so raw-pointer storage cannot compile; the ~30 `[weak self]` + captures become `weak_ptr::lock()`. +- The documented lock order (`writeLock → bufferLock`; `browsersLock` / + `presentLock` never nested), never-block-the-reader-on-a-write, and + callbacks-outside-locks (CefProfileHost.swift:975-980, :1270-1275, + :1454-1457, :1569, :1582) are encoded as a debug lock-order verifier, not + comments. `assert(GetCurrentThreadId() == platform_thread_id_)` at every + site macOS asserts `dispatchPrecondition(.onQueue(.main))`. +- Built under **Application Verifier + ASan from the first commit**; the P6 + gate is a soak (cascade probe ×50 + TerminateProcess-mid-resize fault + injection), not one green run. +- **Fix, don't port**: `pendingCreates` is declared writeLock-guarded + (CefProfileHost.swift:129) but cleared under browsersLock at :968/:1262 — + masked by Swift CoW, a real race in `std::vector`. Unify on writeLock (per + the declaration) at all five sites, on macOS too. +- `UnregisterTexture` is async (§2 #7): the session is kept alive by a + `shared_ptr` captured in the completion callback — a correctness rewrite of + CefWebSession.swift:479-492's synchronous assumption, not a translation. +- Process lifecycle simplifies: `CreateProcessW` (with CommandLineToArgvW- + correct quoting, ~40 LOC) + held `hProcess`; the entire H5 pid-ownership + dance (:1250-1256, :1301-1326) deletes — a HANDLE is not a recyclable + global name. No SIGTERM: graceful = `opShutdown` + bounded wait, escalation + = `TerminateProcess`. Profile lock = `CreateFileW` with `dwShareMode=0`, + preserving the cross-layer contract **exit code 2 + literal log + `profile-locked`** (main.mm:2786-2806; keyed on at FlutterCefPlugin.swift:521). +- Profile dirs: `SHGetKnownFolderPath(FOLDERID_LocalAppData)`; 0700 becomes a + **protected** DACL (`D:P(A;OICI;FA;;;)`) passed to + `CreateDirectoryW` at creation (no inherited-ACL window) + + `SetNamedSecurityInfoW(…, PROTECTED_DACL_SECURITY_INFORMATION)` for an + existing leaf. The sanitizer (FlutterCefPlugin.swift:711-718) ports verbatim + **plus** Windows-only rules: CON/PRN/AUX/NUL/COM1-9/LPT1-9, trailing + dots/spaces, `:` (ADS), MAX_PATH. + +### 4.3 cef_host native — two-stage host, converged early + +**Stage 1 (P2–P4): a small greenfield `win/host_main.cc`, additive opcodes, +macOS untouched.** The Windows host starts as ~700 lines hand-copying only +what it needs, with the duplication tracked in `PORTING_DEBT.md` and a hard +rule that the window **closes at P5 — immediately after the interactive +milestone, NOT after verb parity** (converging two full 3,000-line hosts +never happens in practice). It presents via a **new opcode** `kOpPresentV2 = +0x1e` (verified unused in the table at main.mm:111-164), so `main.mm` is not +edited, `kCefHostProtocolVersion` stays 3, and macOS regression is impossible +by construction. The wire framing (`[u32 bodyLen][u32 browserId][u8 op]`, +≥5/≤64 MiB guard), BE codecs, and opcode payloads are copied verbatim. + +Non-negotiables carried from day one: **external begin frames OFF on Windows** +— S4 (SPIKES.md) refuted the CefSharp-#2675-era assumption: on CEF 144, +`windowless_frame_rate=60` alone delivers 60.3 fps × 12 concurrent browsers, +while `external_begin_frame_enabled=1` + a 16 ms `SendExternalBeginFrame` pump +makes **only the first browser in the process ever paint** (fatal for the +one-host-N-browsers model). The macOS `PumpBeginFrame` pacer (main.mm:295-316) +must NOT be carried to Windows as-is; host-driven cadence, if ever wanted, +gets its own per-browser re-spike first; +`SetAsWindowless` on one hidden `WS_CHILD` HWND per host process (a null +parent degrades dialogs/menus/DPI/IMM and +browser_platform_delegate_native_win.cc bails on `!msg.hwnd`); +`CefRunMessageLoop` on the main thread + a reader `std::thread` (same model +as macOS, ports as-is); a `CefExecuteProcess` early-return replaces the +5-helper-app fan-out (Windows relaunches the same exe with `--type=`; +process_helper.mm's 85 lines collapse). The **must-port checklist** of +security-relevant lines a greenfield host would silently drop: the +CDP-on-persistent-profile refusal (main.mm:2757-2760), the deny-default +`HostPermissionHandler` (:1068-1089), `disable-blink-features= +AutomationControlled` + the `--cdp-pipe` injection pattern (:1651-1662), the +profile flock semantics (:2786-2806), and the exit-code-2 contract. + +Software present path (P3, permanent): CEF software `OnPaint` → BGRA→RGBA +swizzle **producer-side** → shared-memory section → `flutter:: +PixelBufferTexture` (engine does packed-RGBA `glTexImage2D`; no format/stride +fields). Hardened against the flaws found in review: + +- **Name**: `Local\fcef-<128-bit CSPRNG hex>` communicated over the + authenticated pipe — never a predictable pid/bid/gen name (squattable). +- **ACL**: explicit current-user-SID SD on `CreateFileMappingW`; + `GetLastError() == ERROR_ALREADY_EXISTS` → abort (squat detection). +- **Sync**: a ring of **2–3 sections** with the presented index carried in + `kOpPresentV2` — a plugin-local `std::mutex` excludes nothing across + processes (the camera_windows `unique_lock::release()` idiom is + in-process-only and covers only the engine↔plugin edge, where it is still + used). +- **Validation**: consumer `VirtualQuery`s the mapped extent and rejects any + present whose `srcH*stride` exceeds it — the section is not self-describing + the way an IOSurface is. +- New section per size (never mutate a live one — JCEF's name-encodes-size + trick), old sections retired only after the consumer acks adoption. + +This path ships forever as the `IsGpuCompositingDisabled()` / ANGLE-init- +failed fallback and as the permanent "is it CEF or is it Flutter" bisector, +with a runtime kill-switch `FLUTTER_CEF_FORCE_SOFTWARE=1` (extending the +repo's env-knob culture). + +**Stage 2 (P5): `core/platform.h`, seam-by-seam, oracle after every seam.** +This is the split specs/cross-platform/PLAN.md P3 deferred "until two +consumers exist" — at P5 the second consumer exists and its seam is +empirically known. Extraction order = decreasing certainty, macOS conformance +oracle run after **every** seam, not once at phase end: + +1. `PlatformStream` — `Read/Write/Shutdown` (5 POSIX calls total on macOS; + framing/codecs/opcode switch already pure C++). +2. `PlatformPaths` — exe/resource/cache dirs (~40 LOC; Windows: flat layout, + `GetModuleFileNameW`, no `CefScopedLibraryLoader`, no framework paths). +3. `PlatformProcessGuard` — parent-death watch, profile lock, rlimit + (deleted on Windows). +4. `PlatformApp` — run loop + sandbox init (the 16-line NSApplication + subclass deletes on Windows). +5. `PlatformSurface` — **last**, when best understood: `Ensure(w,h)→Token`, + `Lock/Unlock` (+base/stride), `BlitFromShared(const + CefAcceleratedPaintInfo&)` (takes the CEF struct so the mac/win + field-name divergence stays inside the platform layer; must complete + before returning — same pool contract as CompositeMetalLocked's + `waitUntilCompleted`), `Token()`, `Release()`. `Slot`'s two macOS members + (`IOSurfaceRef surface`, `id dst_mtl` — referenced from five + external sites at main.mm:781/927/1522/1802/1850) collapse to an opaque + handle. + +Then the win host is rebased onto `core/`, deleting the Stage-1 duplicates, +and the wire converges at **protocol v4** on both platforms in lockstep +(main.mm:108 ↔ CefProfileHost.swift:42 — the skew check is the point; never +fork the wire per-OS). The v4 present payload is **tagged and +variable-length** so a Linux port does not force v5 (its +`plane_count/modifier/planes[]` dma-buf provably does not fit a u64): + +``` +kOpPresent v4: {u8 kind}{u32 srcW}{u32 srcH}{payload} + kind 0 = u32 IOSurface global id (macOS) + kind 1 = u64 DXGI legacy share handle (Windows GPU) + kind 2 = u8 len + utf8 shm name, u32 stride, u8 ringIndex (Windows software) + kind 3 = reserved (multi-plane dma-buf, Linux) +``` + +Landing tree: repo-root `native/{cef_host/{core,platform/mac,platform/win}, +cef_core/{policy,cdp_relay,test}}`, with build_cef_host.sh, the podspec +`prepare_command`/`:after_compile`, and `cef_host_hash.sh`'s find-root +re-pointed (one deliberate prebuilt-cache miss). CocoaPods can't glob across +package boundaries; use one-line `#include` shim TUs under `Classes/shim/`. +Also in stage 2: DevTools needs `window_info.SetAsPopup(NULL, "DevTools")` +(a default CefWindowInfo is not a top-level window on Windows, unlike +main.mm:2144-2145's comment); `GetScreenPoint` returns **device px** on +Windows vs DIP on macOS; `RealScreenDip` → `MonitorFromWindow` + +`GetMonitorInfoW` (top-left already, no Cocoa flip) with a per-platform +`kChromeH` (87 is "~ real Chrome on macOS" and leaks a wrong `outerHeight` +fingerprint otherwise); `DoKey`'s always-set `character` workaround +(main.mm:2297-2303, a macOS-Blink dedupe) goes behind the platform seam and +is settled empirically in P7 (risk: doubled Backspace/arrows). + +### 4.4 GPU surface path (P8) — producer-allocates, with a real lifetime protocol + +Retained from macOS: cef_host mints the bridge texture and publishes a token; +the size-tagged present + promote-only-on-size-match semantics (already +landed, §2 #13) carry over unchanged. The chain: + +1. Plugin reads the engine adapter LUID + (`PluginRegistrarWindows::GetGraphicsAdapter`, 3.38.8+) and passes + `--adapter-luid=,` on cef_host argv; cef_host creates its + `ID3D11Device` on that adapter (`D3D_DRIVER_TYPE_UNKNOWN`). Without this, + hybrid-GPU laptops fail the open intermittently. +2. `OnAcceleratedPaint`: `OpenSharedResource1(info.shared_texture_handle)` → + `CopyResource` into the slot's bridge texture (B8G8R8A8, `RENDER_TARGET | + SHADER_RESOURCE`, **legacy `MISC_SHARED`**) → `Flush` — synchronously + inside the callback (§2 #2). Bridge textures are allocated on a 64-px + lattice with `visible_width/height` cropping so the published handle + rarely changes (S4 validates this against flutter/flutter#154716). +3. `kOpPresent(kind=1)` publishes `IDXGIResource::GetSharedHandle()`. +4. **Lifetime protocol — the fix for the fatal flaw in the winning design.** + A legacy share handle is a bare u64 the consumer cannot refcount by + itself; without a protocol, cef_host destroying a bridge texture on + resize while ANGLE binds asynchronously on the raster thread is a + use-after-free (best case black tile; worst case the driver recycles the + value onto a *different* live texture — cross-tile content leak). Two + belts, both mandatory: + - The plugin owns a small D3D11 device (same LUID) and, on each **new** + token, `OpenSharedResource`s it itself, holding the ComPtr — a + cross-process kernel reference that keeps the resource alive + independent of the producer, and a validity probe (a failed open = + reject the present, keep the current texture, log). + - A new opcode `kOpSurfaceAck {u64 token}` (plugin → host): cef_host + keeps every superseded bridge texture alive until the plugin acks + adoption of its successor (first successful engine populate with the + new handle). Mirrors the macOS retain semantics of + `adoptSurfaceLocked`'s CVPixelBuffer. +5. Consumer: `flutter::GpuSurfaceTexture(kFlutterDesktopGpuSurfaceType + DxgiSharedHandle, cb)` in a `map>` + (registrar stores a raw pointer — the variant outlives registration); + descriptor with `struct_size` set, `visible_*` from the present, + `format = kFlutterDesktopPixelFormatNone`; a heap `DescriptorHolder` + holding the ComPtr, released in `release_callback` (fires every frame). + Startup probe: null `GetGraphicsAdapter()` ⇒ software path + loud log; + plus webview_cef's 5-second "no accelerated frame arrived" warning task. + +**Tearing is accepted as a structural v1 limitation, stated, not +discovered**: ANGLE's legacy-handle path excludes keyed mutex +(`MISC_SHARED_KEYEDMUTEX` is mutually exclusive; the engine never queries +`EGL_ANGLE_keyed_mutex`), so GPU mutual exclusion is unreachable through +Flutter's texture API. This is *parity*: README's Roadmap already documents +"the IOSurface is single-buffered, so very fast-updating pages can tear +slightly" on macOS. A 2-slot bridge ring is evaluated only against S1's +measured tearing rate — it pays `eglCreatePbufferFromClientBuffer` + +`eglBindTexImage` every frame and may hit #154716; adopt on measurement, not +principle. + +### 4.5 CDP relay — one audited boundary, Windows-first validation + +Shared C++ `native/cef_core/cdp_relay`: the pure policy +(`filterClientToPipe`, `filterPipeToClient`, `demuxPipeToClient`, +`rewriteOutgoingId`, `tokenAcceptable` — already factored `internal` so a +socket-free test can drive them), inverted from side-effecting to +value-returning (`struct FilterResult {optional to_pipe; +vector to_client; optional self_command;}` — ~1 day, makes +the core strictly more testable than the Swift), plus HTTP head parse, +RFC-6455 framing, SHA-1/CSPRNG behind a BCrypt/CommonCrypto shim, NUL pipe +framing, behind an ~8-function socket backend (`posix.cc` / `winsock.cc`). +JSON via CEF's `cef_parser.h` (already linked; no new supply chain). All 60+ +CdpRelayFilterTests assertions (incl. the 18 token edge cases and the +`pipeId = (relayId<<21)|seq` bit-layout cases) transcribe into a ctest target +that links neither CEF nor D3D, wired into **both** CI jobs — a CDP +regression once shipped precisely because CI wasn't running the filter suite +(ci.yaml:33-36). + +Winsock redesigns (not translations): `SO_EXCLUSIVEADDRUSE` replaces +`SO_REUSEADDR` (CdpRelay.swift:161 — on Windows the latter is a port-hijack +primitive); `SO_RCVTIMEO`/`SO_SNDTIMEO` take a `DWORD` of **milliseconds**, +not a `timeval` (a verbatim port silently disables the slowloris and +stuck-client timeouts the write-isolation design depends on); `shutdown()` +does not wake a listening `accept()` (`WSAENOTCONN`) — teardown becomes +`WSAEventSelect(FD_ACCEPT)` + a manual-reset stop event + +`WSAWaitForMultipleEvents`; the per-relay writer queue is **bounded** (the +Swift DispatchQueue is unbounded). + +Spawn: the S3 recipe (`CreatePipe` ×2, un-inherit parent ends, +`PROC_THREAD_ATTRIBUTE_HANDLE_LIST`, both `--remote-debugging-pipe` and +`--remote-debugging-io-pipes`); the fd-collision relocation guard +(CefProfileHost.swift:386-411) **deletes** — handle values are opaque. + +Sequencing is the point: Windows validates the C++ core end-to-end +(multiview_probe + a real Playwright `connectOverCDP` drive) in P9; macOS is +cut over to it in a **separate, later, independently gated phase** (P10) so +the shipping security boundary is never touched by unproven code — and then +CdpRelay.swift is deleted so the deny-by-default filter exists exactly once. + +### 4.6 Build, bundle, sign, distribute + +- Plugin CMake: `add_library(flutter_cef_windows_plugin SHARED …)`, + `apply_standard_settings`, link `flutter flutter_wrapper_plugin d3d11 dxgi + dxguid ws2_32 shlwapi imm32`; S6's `_HAS_EXCEPTIONS`/`/MD` fixes applied. + Bundling: `set(flutter_cef_windows_bundled_libraries … PARENT_SCOPE)` → + `install(FILES)` next to the runner exe; `locales/` via explicit + `install(DIRECTORY … DESTINATION ${INSTALL_BUNDLE_DATA_DIR})`; the ~250 + flat CEF files (libcef.dll, chrome_elf.dll, ANGLE/Vulkan DLLs, icudtl.dat, + v8_context_snapshot.bin, *.pak). **Stamp-guarded install** so the ~500 MB + copy does not re-run on every `flutter build windows` + (`CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD` is 1 in the app template). + **License files ship**: CEF/Chromium LICENSE + third-party manifest + + the Microsoft-redistributable terms for d3dcompiler_47.dll — a legal + ship-blocker, owner assigned in §10. +- Prebuilt fetch: no `pod install` hook exists — `fetch_cef_host.ps1` runs at + CMake configure via `execute_process`, cached under + `%LOCALAPPDATA%\flutter_cef`, hash-stamp short-circuit **load-bearing** + (configure runs every build). Failure policy matches macOS: **fail-open** + to build-from-source when the sidecar is unreachable, **fail-closed** on + digest/signature mismatch (delete the cached archive). The §2 #14 + `.gitattributes` fix is the precondition for the shared content-hash key. +- Sandbox (P11, per S2): cef_host becomes `cef_host.dll` exporting + `RunConsoleMain` + CEF's prebuilt `bootstrapc.exe`, `icacls /grant + *S-1-15-2-2:(OI)(CI)(RX)` LPAC grant in the install step. Sandbox ON + unconditionally once landed — it is signing-independent on Windows (the + inverse of macOS). +- Signing/verification: Authenticode has no `--deep` tree seal and no + requirement DSL, so 913f0f0's fail-closed property is rebuilt as: + `WinVerifyTrust(WINTRUST_ACTION_GENERIC_VERIFY_V2)` (exactly-0 = success) + + hand-rolled **leaf-thumbprint pinning** on **every PE**, + a signed + catalog (or Authenticode-signed SHA-256 manifest) covering the non-PE + assets, + rejection of **unexpected** files in the staging dir (per-PE + verification says nothing about files an attacker *added*). + `CreateProcessW` never consults SmartScreen/MotW — exactly as + `posix_spawn` bypasses Gatekeeper — so this gate is the only root of + trust. Publisher runs in CI with an HSM/Key-Vault identity (CA/B rules + since 2023-06 rule out a local P12; no `security find-identity` + equivalent). DLL hardening in cef_host/bootstrap entry: + `SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32)` first statement, + absolute-path `LoadLibraryExW`, `SetProcessMitigationPolicy + (ProcessImageLoadPolicy)` — the fetch dir is user-writable, making + search-order hijack of libcef.dll live in dev/CI. +- CI: a `windows-latest` job pinned to Flutter 3.38.8 — analyze ×5 packages, + `flutter test`, the cef_core ctest suite, a cached cef_host build. Note + GitHub-hosted Windows runners have **no GPU**: the conformance oracle runs + on a self-hosted/dev-box runner or as a documented pre-release ritual; CI + proper gates on the GPU-free suites. AV/Defender note in the dev docs: + unsigned exe + child spawning + RWX JIT + named-pipe IPC is the textbook + EDR silhouette; document Defender exclusions for dev and expect + enterprise-EDR reports as a support class. + +## 5. Phases + +Each phase ends at an explicit green gate; macOS gates are re-run at every +phase that touches shared code (verify, don't assume). + +- **P0 — Spikes + repo prep** (§3). ~3 wks. + *Gate:* SPIKES.md verdicts committed; fallbacks promoted; `.gitattributes` + + PORTING.md corrections landed; empty diff elsewhere. +- **P1 — Package skeleton + Dart conditionals + CI.** New + `flutter_cef_windows` (stub plugin answering `create` with a visible + error), root endorsement, `flutter create --platforms=windows example/` + (runner committed: main.cpp, flutter_window.cpp, Runner.rc, manifest — + decide per-monitor-V2 DPI opt-in here), the §4.1 Dart changes, Windows + twins of the ⌘-shortcut/key-table test groups, `/tmp` hardcodes in probes → + `Directory.systemTemp`, windows-latest CI job. ~1.5 wks. + *Gate:* analyze clean ×5 packages on both OSes; full Dart suite green on + both (incl. new twins); `flutter build windows` of example launches and + fails loudly, not silently; macOS example renders (oracle green). +- **P2 — cef_host.exe renders a page, zero Flutter.** Standalone CMake + project; fetch the pinned windows64 minimal dist; `no_sandbox=true` + (posture bit set, §7); windowless + hidden WS_CHILD HWND + external + begin-frame pump; software `OnPaint` writes a swizzled PPM of a hardcoded + URL at frame 30. ~1.5 wks. + *Gate:* `cef_host.exe https://flutter.dev` writes a correct PPM at the + requested size. All CEF-on-Windows build archaeology (S6 fixes, /MD, + bsdtar) retired before any Flutter integration exists. +- **P3 — THE SLICE: software pixels in a Flutter Texture.** Named-pipe IPC + (verbatim framing), six opcodes (`kOpCreateBrowser/Shutdown/Resize` in; + `kOpReady/Log/PresentV2` out), the hardened shm ring (§4.3), dispatcher + + Job Object + one reader thread + one `PixelBufferTexture`; single + hardcoded session. ~2 wks. + *Gate:* `flutter run -d windows` shows a live, correctly-scaled, + correctly-colored page in a `Texture` widget, **with an animated-content + check across a resize drag** (the ring, not just dimensions). macOS + re-verified: main.mm unedited, protocol still 3. +- **P4 — Interactive.** Pointer/key/navigate/reload/stop/back/forward/ + setVisible/invalidate opcodes; wire encodings verbatim from + cef_web_controller.dart:885-927; the resize supersede/coalesce machine + (resizeGen + 80 ms invalidate re-kick) + size gate on `srcW/srcH` + (promote only on match ±1px); title/url/loadState/pageStart/pageFinish/ + progress/loadErr/console/cursor events; wheel magnitude tuned against + `WHEEL_DELTA` + `SPI_GETWHEELSCROLLLINES` (the sign negation at + cef_web_view.dart:434-436 holds; the magnitude does not). ~1.5 wks. + *Gate:* a human browses: links, typing, scroll, window resize across a DPI + boundary, Ctrl+C/V, Ctrl+±/0; zoom_soak shows no stuck-scale frames. + **This is the go/no-go milestone (~week 7).** +- **P5 — Converge: core/platform.h + shared policies + protocol v4** (§4.3 + stage 2). Seam-by-seam with the macOS oracle after every seam; win host + rebased onto core (PORTING_DEBT.md emptied); tagged v4 present, versions + bumped in lockstep both sides; the three resize/liveness policies + their + ~30 test vectors → `native/cef_core/policy` ctest (skip the dead + `shouldForcePromote`); macOS cut over to the policy lib only after ctest + + full macOS gates green (Swift copies + swiftc runners then deleted). ~2.5 + wks. + *Gate:* macOS conformance oracle + leak soak + all four policy suites + green from the refactored tree; Windows P4 gate re-passes on core; + `git diff` shows moves + seam indirection, no logic change (except the + pendingCreates lock fix, called out). +- **P6 — Multi-browser lifecycle.** One host per profile key multiplexing N + browsers; real sessionId routing; dispose/created/createFailed; the create + pacer (window=3, 8 s backstop, 6-stable-frames/0.4 s dual release), + first-paint watchdog, liveness sweep, and targetId resolve/epoch/retry + implemented **in shared `core/`** (they reason about facts cef_host + observes directly — OnAfterCreated, presents, the DevTools observer; + consumer-side placement is why they need IPC backstops). Windows uses the + core-side machinery from day one (greenfield validation); macOS keeps its + Swift pacer untouched behind the existing wire until the P10 flag-flip. + `processGone` reasons; `paintStalled`; the failHost-must-dispose-session + ordering (fix macOS C-2 here: FlutterCefPlugin.swift:496-499 leaks the + texture per host crash). ASan/AppVerifier soak. ~2.5 wks. + *Gate:* Windows cascade probe: 12/12 tiles `paints>0` on one shared host, + run ×50; TerminateProcess-mid-resize fault injection: every session gets + `processGone`, zero texture/handle leak, zero crash; second app on a named + profile → `processGone('locked')`. macOS unchanged. +- **P7 — Verb parity + input truth + IME.** Cookies, JS channels (per-session + OnQuery routing), evalReturning, JS dialogs, find, zoom, editCommand, + loadTrusted, downloads; the three string-packings byte-for-byte + (`"id:json"`, `"name:message"`, cookies JSON). `CefDialogHandler:: + OnFileDialog` + new opcode (windowless = NULL owner HWND; ``/downloads otherwise fail silently — new on both platforms). + DevTools `SetAsPopup`. Empirically settle: (a) DoKey's always-set + `character` on Windows (doubled edits?); (b) whether Blink applies editor + commands from raw key events on Windows OSR or the explicit editCommand + path stays required; (c) the early-character fallback + (cef_web_view.dart:598-600) vs WM_CHAR ordering. IME non-delta strategy + (§4.1); verify the Win32 TextInputPlugin honors a non-implicit `viewId`. + Native OAuth popup: `CreateWindowExW(WS_OVERLAPPED|WS_CAPTION|WS_SYSMENU)` + + WndProc mirroring `windowShouldClose:`, `SetAsChild(HWND, CefRect)`, + `SetWindowTextW`, deferred destroy via `CefPostTask(TID_UI)`; forward + runner messages per docs.flutter.dev/platform-integration/windows/extern_win; + note `SetForegroundWindow` refusal means the popup may open un-foregrounded + — mitigate with `AllowSetForegroundWindow` from the plugin before spawn and + flash fallback. ~3 wks. + *Gate:* channel_probe + channel_probe_shared write `pass:true` on Windows; + cef_web_controller_test's 73 contract tests pass against the real host; + manual matrix: each editing key applies exactly once, MS-IME + a CJK IME + compose with the candidate window at the caret, `event.code` correct, + file-picker and a download complete, Google `signInWithPopup` completes. +- **P8 — GPU path + Windows gates.** §4.4 wholesale, per S1/S4 verdicts: + bridge textures, `--adapter-luid`, the two-belt lifetime protocol + (plugin-side open+ref, `kOpSurfaceAck` deferred-destroy), lattice sizing, + software fallback + `FLUTTER_CEF_FORCE_SOFTWARE`. Conformance oracle + rebuilt as a **Dart driver** (one runner both OSes) over the unchanged + conformance_harness.dart; Windows host emits the identical `diagpx + wire= painted=WxH want=WxH content=` + FIRSTPAINT/ADOPT/blitmismatch + grammar (hard requirement — the oracle is a parser over it; note the + Windows sampler is a staging-texture `D3D11_MAP` readback, so keep + `FLUTTER_CEF_DIAGPX_EVERY` debug-gated). Leak soak: `WorkingSet64` ceiling + + `HandleCount` + a host-emitted live-shared-texture counter replacing + `ioreg -c IOSurface`. ~3 wks. + *Gate:* Windows oracle exits 0 on all five hard rules (NO-RENDER / + NO-ADOPT / BLIT-CROP / BLANK / NO-DIAG) at HARNESS_N=12 HARNESS_HARD=1 + through all six storms; leak soak green; a forced + `--disable-gpu-compositing` run still renders via software; measured CPU/ + frame drop vs P3. macOS oracle still green. +- **P9 — CDP relay on Windows** (§4.5). Shared core + winsock backend + S3 + spawn recipe + `--cdp-io-pipes` flag; ctest filter suite in both CI jobs. + macOS stays on CdpRelay.swift. ~3 wks. + *Gate:* multiview_probe `pass:true` on Windows (two grants, distinct + ports+tokens, cross-token rejected, per-tile getTargets scoped, disable + kills one, re-enable mints fresh); real Playwright `connectOverCDP` drives + one tile without seeing a sibling. +- **P10 — macOS cutover: relay + pacer flag.** Retire CdpRelay.swift behind + a thin ObjC++ shim onto the shared core; flip macOS to the core-side pacer/ + watchdogs (P6) behind its flag; delete the Swift copies + runners. ~1.5 + wks. + *Gate:* macOS multiview_probe + run_channel_integration + cascade probe + + oracle + leak soak green; 16 s-idle run proves no false wedge flag; net + LOC deleted — one security boundary, one pacer. Tagged revert point. +- **P11 — Sandbox, signing, distribution, docs.** §4.6: bootstrapc.exe + restructure per S2, LPAC ACLs, DLL hardening, posture bit1→0, Authenticode + gate + signed manifest, fetch/publish scripts, license bundling, README + security rewrite (§7), CHANGELOG + pubspec graph/publish-ordering update + (root note at pubspec.yaml:22-33 gains the fourth package), Windows + dev-setup doc (cmake/ninja PATH, Defender exclusions, + FLUTTER_CEF_HOST probe order on Windows: env var → next to runner exe → + `data/`). ~3 wks. + *Gate:* `flutter build windows --release` on a clean machine yields a + running, signed, **sandboxed** app (restricted-token renderers verified in + Process Explorer); a tampered PE *and* a tampered .pak are rejected + fail-closed with the cached archive deleted; identical prebuilt hash from + a Windows and a macOS checkout of the same commit. +- **P12 — Windows-only hardening + folded probes.** TDR/`DXGI_ERROR_DEVICE_ + REMOVED` fault injection with a recreate-device/bridge/texture recovery + path + a new `processGone` reason `deviceLost` (and `hostLaunchFailed` for + missing VC++ runtime / AV-quarantined libcef — today indistinguishable + from `crashed`); per-monitor-V2 DPI drag between differently-scaled + monitors (the C-1f "dpr change while culled" case made routine); sleep/ + wake; software-fallback under hide/resize; single-renderer death in a + shared host; slot/wire-id reuse across respawn under load. Fold the manual + visual probes (cull_wedge, zoom_soak, crispness, interaction_soak, + realsite_soak, sharedhost_html) into conformance_harness as **asserted** + storm phases on both OSes; re-derive the Windows per-host browser ceiling + via the PROBE_N bracket. ~3 wks. + *Gate:* forced TDR recovers every tile to a painting texture within 5 s; + a cross-DPI monitor drag leaves no wedged/wrong-scale tile; all folded + storm phases assert green on both platforms; full parity matrix (§8) + green. + +Total ≈ 30 engineer-weeks. Pixels at ~week 5 (P3); go/no-go at ~week 7 (P4). + +## 6. Deliberately NOT ported in v1 + +- **`showEmojiPicker`** → `MethodNotImplemented` (no supported Win32 API; + example button hidden). **`performSelector`** documented mac-only. +- **The ad-hoc→mock-keychain→ephemeral downgrade rail** — inert on Windows + (§2 #12); replaced by the posture-bit scheme in §7, not silently dropped. +- **Trackpad pan-zoom path + `_kTrackpadScrollGain=3.0`** — the Win32 + embedder delivers precision-touchpad scroll as PointerScrollEvent; the + handler stays inert and the gain constant is never reused. +- **`CEF_MULTI_PROCESS=OFF`** — documented macOS-only (`--single-process` is + Chromium-debug-only on Windows and forfeits OnAcceleratedPaint). +- **5-helper-app fan-out, `CefScopedLibraryLoader`, NSApplication subclass, + rlimit bump, SIGPIPE guards, Mach-port peer-validation bypass, kqueue + parent watch** — deleted on Windows (Job Object + `--type=` relaunch model). +- **A C++ rewrite of the macOS Swift plumbing** (CefProfileHost/ + CefWebSession transport + lifecycle). Deliberate: six mutexes with a + documented lock order, ~30 ARC weak captures, and the wake/join/leak-on- + timeout teardown are the highest-variance code in the repo; the decisions + are shared (policies, pacer in core, relay), the OS-shaped plumbing is + written twice on purpose. +- **HTML5 drag-and-drop, printing, context menus, accessibility** — missing + on macOS too (no CefDragHandler/PrintHandler/ContextMenuHandler/ + AccessibilityHandler anywhere); parity means matching macOS, not fixing it. + If added later, they belong in `core/`. +- **Tear-free GPU sync** — structurally unreachable through Flutter's + Windows texture API (§4.4); parity with macOS's documented single-buffer + tearing. Buffer-ring only on S1 measurement. +- **Windows-on-ARM** — deferred pending §10 D4 (the windowsarm64 CEF dist + exists; the cost is fetch-arch plumbing, a second prebuilt, and CI runner + availability). +- **WebAuthn/passkeys** — per S5's verdict; already out of scope on macOS + (specs/persistent-profiles/PLAN.md:7-9). + +## 7. Security contract deltas on Windows (stated, so nothing weakens silently) + +1. **At-rest encryption.** macOS: OSCrypt key in the login Keychain, + ACL-bound to the signing identity, real crypto only in signed builds. + Windows: DPAPI-wrapped AES-256-GCM, **always on, signing-independent**, + key inside the profile dir (`LocalPrefs.json`) — but decryptable by **any + same-user process** with no prompt (the classic cookie-stealer path; + Chrome's App-Bound Encryption needs a SYSTEM elevation COM service CEF + does not ship). Net: better than macOS-ad-hoc, weaker same-user isolation + than macOS-signed. README's Secrets-at-rest section gets a platform + split, not a find-and-replace. Profile-dir ACL + BitLocker are the + backstops. +2. **Posture bits — extended, never redefined.** The `opReady` handshake + grows (under the v4 bump, where skew is caught loudly): **bit0 keeps its + existing meaning** (secrets-at-rest unavailable / ad-hoc; Windows + truthfully reports 0 — DPAPI is real crypto, so the named-profile + downgrade rail correctly never fires). **New bit1 = "sandbox + unavailable"**, set by the Windows host until P11 lands and thereafter + whenever sandbox init fails, surfaced to Dart as a machine-readable + `sandboxed` posture (create-result field + doc) so a consumer can refuse + untrusted content on an unhardened host. No silent hardcoding: an + unsandboxed build must not report a clean posture. +3. **Sandbox.** macOS: coupled to Developer-ID signing; off in ad-hoc. + Windows: signing-independent — **on unconditionally** once P11 lands + (dev and CI included), via the bootstrapc.exe model + LPAC ACL. Until + P11, renderers run with the user's full token — worse than macOS's worst + posture (no TCC/Gatekeeper second line) — which is why bit1 exists and + why P11 must not slip past GA. +4. **README.md:321 is retracted for Windows.** "Even a same-UID process + can't connect [to the relay]" rests on macOS denying `task_for_pid`. + Windows grants the owning user `PROCESS_VM_READ` (token readable from the + relay heap) and `PROCESS_DUP_HANDLE` (CDP pipe handles duplicable, + bypassing token *and* filter) by default; PPL needs a Microsoft cert. The + Windows claim is: defeats network/port-scanning and cross-user access — + **same-user is not a boundary on Windows.** Documentation, because no + engineering fixes it. +5. **Relay transport.** Token auth, constant-time compare, deny-by-default + filter, discovery-without-token all port unchanged. New Windows rules: + `SO_EXCLUSIVEADDRUSE` (never `SO_REUSEADDR`); DWORD-ms timeouts; + loopback-TCP retained for the relay (Playwright can't speak pipes); + AppContainer/MSIX packaging would break loopback without an exemption + (noted for packagers). +6. **IPC + shm surfaces.** Named pipe: random 128-bit name, + `FILE_FLAG_FIRST_PIPE_INSTANCE`, explicit user-SID DACL, + `PIPE_REJECT_REMOTE_CLIENTS`, client `SECURITY_SQOS_PRESENT | + SECURITY_ANONYMOUS` (a squatting server must not impersonate us). + Software-path sections: CSPRNG names over the authenticated pipe, + explicit user-SID DACL, `ERROR_ALREADY_EXISTS` → abort, extent-validated + before trusting wire dims. (Note `Local\` objects are per-session while + the profile lock is per-`%LOCALAPPDATA%`: RDP/fast-user-switching makes + the `locked` path routine where macOS rarely sees it — the exit-2 + contract covers it.) +7. **Binary trust.** No Gatekeeper/notarization/entitlements. Replacement: + per-PE WinVerifyTrust + pinned leaf thumbprint + signed manifest/catalog + for non-PE assets + unexpected-file rejection + DLL search-order + hardening (§4.6). `CreateProcessW` bypasses SmartScreen exactly as + posix_spawn bypasses Gatekeeper — the explicit gate is the only root of + trust, and it stays fail-closed. +8. **Command lines are logged on Windows** (4688/Sysmon/EDR persist argv to + disk). Nothing profile-identifying or secret ever goes on argv; pipe + handle values are not secrets (useless without PROCESS_DUP_HANDLE) but + the rule is stated. +9. **CDP-on-persistent-profile refusal** (main.mm:2757-2760) and the + deny-default permission handler are on the must-port checklist (§4.3) — + they live in `core/` after P5 so no greenfield host can drop them again. + +## 8. Parity test plan + +The oracle strategy: one log grammar, one harness, two platforms. The +Windows host **must** emit the identical `diagpx wire= painted=WxH +want=WxH content=` / FIRSTPAINT / `ADOPT psid` / blitmismatch lines under +`FLUTTER_CEF_DEBUG=1` — every gate below is a parser over that grammar. + +- **Tier 0 (P1, CI both OSes):** `flutter test` — the 73 controller-contract + tests, ~150 input/widget tests + Windows twins of the ⌘/NSEvent groups; + example integration smoke. +- **Tier 1 (P5/P9, CI both OSes, no GPU needed):** `native/cef_core` ctest — + the 3 resize/liveness policies (~30 vectors) + the CDP + filter/token/demux suite (60+ vectors incl. token edge cases and pipeId + bit layout), transcribed verbatim from the Swift tests. +- **Tier 2 (P8, self-hosted/dev-box — GitHub Windows runners have no GPU):** + the conformance oracle as a cross-platform Dart driver over the unchanged + conformance_harness.dart — five hard rules (NO-RENDER / NO-ADOPT / + BLIT-CROP / BLANK / NO-DIAG), convergence WARN-only, HARNESS_N=12, + HARNESS_HARD=1, all six storms. +- **Tier 3 (P6-P9):** leak soak (recreates_total>0; WorkingSet64 ≤1.5× + baseline; HandleCount + host-emitted live-shared-texture counter flat — + the direct analogue of the IOSurface ledger); cascade probe (12/12 wires + `paints>0`, ×50); channel gates rebuilt as a Dart runner (one runner both + OSes) asserting each probe's `pass:true` JSON — channel_probe, + channel_probe_shared, multiview_probe — with the Windows analogue of the + `FLUTTER_CEF_ALLOW_INSECURE_PROFILE` masking trap handled explicitly (an + unsandboxed host must not silently downgrade named profiles or the + shared-host probes pass vacuously). +- **Tier 4 (P12, Windows-first):** TDR/device-removed fault injection with + recovery assertion; cross-DPI monitor drag; sleep/wake; software fallback + under hide/resize; renderer-death-in-shared-host; respawn-under-load + wire-id reuse; the folded (now asserted) visual probes; PROBE_N ceiling + bracket. +- **Manual acceptance:** the example app feature matrix (PORTING.md + checklist) on Windows — pointer/keyboard/IME/navigation/allowlist/ + profiles/dialogs/downloads/OAuth popup. + +## 9. Invariants + +- **macOS builds, renders, and passes its full gate set at every phase** + (verify, don't assume — the oracle runs after every P5 seam, not per + phase). +- **App-facing API frozen**: `CefWebView` / `CefWebController` / exported + types unchanged; `package:flutter_cef/flutter_cef.dart` imports keep + working; `profile:` semantics identical. +- **Wire changes are additive until P5, then lockstep**: no edit to + `main.mm` or the v3 protocol before the P5 convergence; from P5 the + protocol version bumps on *both* sides in one change or not at all — never + a per-OS fork, never a silent byte-reinterpretation (posture bits are + *added*, not redefined). +- **No shared component reaches shipping macOS before Windows has validated + it** (policies: ctest + full macOS gates; pacer: P6 Windows → P10 flag; + relay: P9 Windows → P10 cutover, separately gated, tagged revert point). +- **The duplication window is bounded and ledgered**: PORTING_DEBT.md tracks + every line copied into the Stage-1 win host; the ledger must be empty at + the P5 gate. +- **Security posture is never silently weakened**: every delta in §7 is + either surfaced machine-readably (posture bits) or documented in the + platform-split README before GA; the fetch stays fail-closed on mismatch; + the CDP filter exists exactly once after P10. +- **Cancellation is cheap by construction**: through P4 the macOS tree is + byte-identical (additive opcode, no protocol bump); killing the port at + the week-7 go/no-go costs ~6 weeks and zero macOS risk. +- **The diagpx/FIRSTPAINT/ADOPT log grammar is byte-identical across + platforms** — it is the contract every automated gate parses. +- **cef_host pin moves in lockstep**: one CEF version string + (build_cef_host.sh:17) for both platforms; a bump lands with both + prebuilts republished and both oracle runs green. + +## 10. Open decisions for the maintainer + +- **D1 — Buy vs build (answer first).** If Windows users need "a webview", + WebView2 via an existing plugin is 2–4 weeks and OS-maintained — but has + no OSR multi-tile compositing, no per-tile CDP isolation, no shared + profile hosts. This port is only justified by the agent-browser/multi-tile + product on Windows. Who is the Windows user, and which do they need? +- **D2 — Agent control in Windows v1?** Cutting P9+P10 saves ~4.5 weeks; + `enableAgentControl` already surfaces a clean `PlatformException` + (cef_web_controller.dart:594-604). If cut, the shared-relay work still + eventually happens — decide whether v1 ships without it. +- **D3 — Sandbox timing.** Block GA on P11 (recommended; unsandboxed + Windows is worse than macOS's worst posture), or ship a bounded + internal-preview window with bit1 surfaced and a hard deadline? + Unsandboxed-forever is not an option. +- **D4 — Windows-on-ARM in scope?** Affects fetch arch detection, GCS + object naming, a second prebuilt + publisher, and CI (no GitHub-hosted + ARM64 Windows runners). +- **D5 — The oracle's engine.** run_conformance_oracle.sh:25 prefers the + consumer's custom engine (`work_canvas/scripts/campus-flutter-engine.sh`); + ci.yaml pins 3.38.8 for the same reason. Does a **Windows** build of that + engine exist, and do its patches touch ExternalTextureD3d/ANGLE? The + Windows gate is authoritative against whichever engine ships — resolve in + writing before P8. +- **D6 — Signing identity.** Does the org hold (or must it procure) an + HSM/Key-Vault-backed Authenticode cert? Procurement lead time can exceed a + phase; start at P0. OV suffices (EV no longer buys SmartScreen reputation). +- **D7 — Licensing owner.** Who reviews/ships the CEF+Chromium license set + and the Windows-SDK redistribution terms for d3dcompiler_47.dll? Legal + ship-blocker; not an engineering task to discover at P11. +- **D8 — Staffing.** This needs Win32 + D3D11 + C++-concurrency fluency; the + failure modes (torn frames, black textures with no error path, + teardown races) are undiagnosable without it. If unavailable, add 30–50% + to every estimate. +- **D9 — Kill criteria (agree up front).** Abandon/descope if: S1 fails AND + software rendering can't meet the product's tile/fps bar; or the P4 + interactive milestone slips past week 10; or D1's demand hasn't + materialized by milestone time; or D8 can't be staffed. Each caps the loss + at ~6 weeks with macOS untouched. +- **D10 — Ongoing cost sign-off.** Post-GA, every CEF bump lands twice (two + prebuilts, two signing pipelines, two oracle runs); the macOS host is + under active churn (the last four commits touched render/screen/popup code + — each would have needed a Windows twin). Budget ~30–50% ongoing overhead + on this subsystem, or the platforms drift. diff --git a/specs/windows-port/SPIKES.md b/specs/windows-port/SPIKES.md new file mode 100644 index 0000000..7cca8fc --- /dev/null +++ b/specs/windows-port/SPIKES.md @@ -0,0 +1,179 @@ +# Windows port — Phase 0 spike report + +Executed 2026-07-20 on the reference box (Win11, VS2022 17.14 / MSVC 19.44, +Win10 SDK 10.0.26100, Intel Arc iGPU, Flutter 3.38.8 + system 3.32.8, CEF +`144.0.27+g3fae261+chromium-144.0.7559.254` windows64_minimal). Spike code is +throwaway, lives outside the repo at `C:\dev\flutter_cef_spikes\{s1..s6b}` +(producers, consumers, logs, screenshots preserved there). Verdicts are +against the pass criteria in [PLAN.md §3](PLAN.md). + +**Result: 7/7 PASS. No fallback promoted. The GPU path, the bootstrap sandbox +model, the CDP pipe transport, and the plugin/toolchain assumptions all +survived. Three PLAN.md claims are corrected below (one refuted outright).** + +| Spike | Verdict | One-line result | +| --- | --- | --- | +| S1 GPU handle → ANGLE | **PASS** | Cross-process legacy `MISC_SHARED` handle → Flutter `GpuSurfaceTexture` works, animated, 12 tiles; tearing 1.81%; NT→legacy copy mean 0.48–0.59 ms @720p | +| S2 sandbox/bootstrap | **PASS** | Sandboxed render via `bootstrapc.exe` + client DLL `RunConsoleMain`; renderer at UNTRUSTED IL; argv + inherited HANDLEs survive; LPAC icacls grant NOT needed for default config | +| S3 CDP io-pipes | **PASS** | `Target.getTargets` → `attachToTarget` → `Runtime.evaluate` over inherited pipe handles, 3/3 runs, ~2–3 s; also through `bootstrapc.exe` | +| S4 handle identity | **PASS** | Handle **value** is fresh-per-callback and aliases across sizes/browsers — never key on it; true pool = 2–3 kernel objects/browser, never reused across sizes; #154716 leak probe **clean** on 3.38.8 | +| S5 WebAuthn OSR | **PASS** | Native passkey modal (CredentialUIBroker) appears **even with `SetAsWindowless(nullptr)`** — support passkeys in v1 | +| S6a CEF headers/flags | **PASS** | CEF 144 headers compile **clean** under stock `apply_standard_settings`; entire fix = `NOMINMAX` + per-config `/MD` wrapper builds | +| S6b symlinks/OOT | **PASS** | Out-of-tree `../native/` sources build + incremental-rebuild through `.plugin_symlinks`; `bundled_libraries` lands extra DLLs beside the exe. Requires Developer Mode (true SYMLINKD, not junction, on 3.38.8) | + +## S1 — cross-process shared texture (the novel risk): retired + +- **Producer** 12× `ID3D11Texture2D` B8G8R8A8 `D3D11_RESOURCE_MISC_SHARED`, + rolling frame counter in 8×8 corner blocks, 59.5–60.0 fps over 15,300 + frames. +- **Raw ANGLE consumer** (separate process): all 12 handles open via + `eglCreatePbufferFromClientBuffer` + `EGL_D3D_TEXTURE_2D_SHARE_HANDLE_ANGLE`, + 0 failures; counters advance 350 consecutive reads/tile; 1,178 reads/s. + **Tearing 1.81%** (76/4200 reads, diagonal-corner counter disagreement) — + the number that prices the future 2-slot ring; acceptable for v1. +- **Real Flutter consumer** (3.38.8 release): 12 `flutter::GpuSurfaceTexture` + (`kFlutterDesktopGpuSurfaceTypeDxgiSharedHandle`) live-animate in a 4×3 + grid; 5 s vs 15 s screenshots differ 64.4–65.8% of sampled pixels on every + tile. +- **Destroy-while-bound**: consumer's held reference keeps the kernel object + alive after producer `Release()` — counter freezes at last value, glError 0, + no device-removed. **Belt-1 (plugin holds a ref) is the safety requirement; + belt-2 (`kOpSurfaceAck` deferred destroy) is a visual-quality optimization** + and may ship after P8 if schedule demands (PLAN §4.4 refined). +- **CEF leg**: `OnAcceleratedPaint` NT handle (`MiscFlags=0x802`) → + `OpenSharedResource1` → `CopyResource` → legacy bridge: mean **0.594 ms** + (p95 1.56) on example.com, **0.483 ms** (p95 1.51) on a CSS-animation page + @1280×720 — 3–10% of a 60 fps frame budget. The structurally-mandatory copy + is not a perf concern. +- Caveats: single-GPU box — hybrid-GPU/cross-adapter (`--adapter-luid`) + remains an assumption; handle-value-recycled-onto-different-texture worst + case not exercised (covered instead by S4's identity findings + belt-1). +- Note: Flutter ships **no standalone** `libEGL.dll`/`libGLESv2.dll` in 3.38.8 + engine artifacts (ANGLE is statically linked into `flutter_windows.dll`); + the raw-ANGLE leg used CEF's shipped ANGLE. Both providers accept the same + cross-process legacy handle. + +## S2 — bootstrap sandbox: the shipping contract + +- Invocation (empirical; not in CEF's README): client DLL must sit **in the + bootstrap exe's own directory**; select with `--module=` + (positional is a FATAL "Missing module name"), or **rename the bootstrap + exe to `.exe`** — the rename route is what we should ship (clean + Task Manager names). Neither `bootstrapc.exe` nor `libcef.dll` is + Authenticode-signed, so unsigned dev DLLs load fine. +- `RunConsoleMain(argc, argv, sandbox_info, version_info)`: forward + `sandbox_info` to both `CefExecuteProcess` and `CefInitialize`. With + `no_sandbox=0`: 6-process tree, renderers at **UNTRUSTED** integrity, GPU at + LOW, page renders correctly (PPM inspected), clean exit. +- argv (`--marker=xyz123`) and an inherited pipe HANDLE (via + `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`) both pass through the bootstrap entry + unchanged. +- **LPAC `icacls` S-1-15-2-2 grant is NOT required** for CEF 144's default + sandbox (no AppContainer children; network I/O works ungrated). Only the + opt-in `NetworkServiceSandbox` feature needs it (and more than the exe-dir + grant — not fully chased). Document as future hardening, not setup. +- Build detail: client DLL must link `delayimp.lib` (CEF's `/DELAYLOAD` list). + +## S3 — CDP over inherited pipe handles: ports directly + +- `CreatePipe` ×2 + `HANDLE_FLAG_INHERIT` + `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`; + child translates a private `--cdp-io-pipes=,` into + `--remote-debugging-pipe` **plus** `--remote-debugging-io-pipes=,` in + `OnBeforeCommandLineProcessing` (browser process only) — the exact macOS + `--cdp-pipe` pattern (main.mm:~1640) with handle values instead of fds 3/4. +- Full sequence green 3/3: `Target.getTargets` (answers **before** any + browser exists — the Windows relay can open its transport at spawn, don't + wait for create) → `attachToTarget(flatten:true)` → sessioned + `Runtime.evaluate` = 2. NUL-framed UTF-8 JSON, same as macOS. +- Same sequence green through `bootstrapc.exe` (composition with S2 proven). + Not yet exercised: io-pipes with the sandbox *enabled* (S3 ran + `no_sandbox=1`; S2 proved handle inheritance under the sandbox separately — + compose in P2). + +## S4 — handle identity: the law of the pool + +- The `shared_texture_handle` **value** is a fresh NT handle every callback, + closed by CEF on return. Values recycle and **alias**: 185 values seen at + >1 coded-size, 441 values seen from >1 browser. **Never key identity or + change-detection on the handle value.** Consumption must be + open+copy-inside-the-callback (confirms PLAN §4.4 step 2 as mandatory and + sufficient). +- True identity (`CompareObjectHandles` over retained dups): **2–3 kernel + objects per browser** in strict A/B alternation (+1 burst buffer); + per-browser pools; **zero** cross-browser sharing; **zero** null handles in + 11,842 paints. +- Resize: every `WasResized` (even ±1 px) discards the whole pool; kernel + objects are never reused across coded sizes. 1–3 late callbacks per step + still deliver the **old** size after `WasResized` — and + `info.extra.coded_size` correctly describes each delivered frame, so the + OSR_SCALE_MISMATCH size-gate oracle works on Windows as designed. +- flutter/flutter#154716 leak probe: **flat** GPU memory over ~6,700 + handle-changing imports on 3.38.8 → the 64-px size lattice is a churn + optimization, not a leak requirement; media_kit-style re-register + + `textureChanged` event **not needed**. (Skia/ANGLE backend; re-run if the + engine flips to Impeller-default.) +- **REFUTES PLAN §4.3's "non-negotiable"** external-begin-frame rule — see + Plan corrections below. + +## S5 — WebAuthn: supported in v1 (decision flipped) + +- The native passkey modal (`Credential Dialog Xaml Host`, owner + `CredentialUIBroker.exe`) appears in **all three** configs, including + `SetAsWindowless(nullptr)` — Windows brokers WebAuthn UI out-of-process and + does not need our HWND. With a visible owner HWND the dialog tracks the + window's position; with nullptr it centers on the desktop. +- `isUserVerifyingPlatformAuthenticatorAvailable()` → true in every config. + Live-confirmed end-to-end to the phone leg: the caBLE QR flow reached a + real phone during the run (maintainer observation). +- v1 action: plumb the runner's top-level HWND into `SetAsWindowless` for + dialog placement (small refinement, not a blocker). Re-confirm the + biometric sub-UI on a Hello-enrolled box (this box has no NGC container). + +## S6 — toolchain: smaller delta than budgeted + +- `apply_standard_settings` verbatim (3.32.8 template; 3.38.8 byte-identical): + `cxx_std_17`, `/W4 /WX /wd4100`, **`/EHsc` already present** (folklore + wrong), `_HAS_EXCEPTIONS=0`. CEF 144 headers + wrapper subclasses + + `CefMessageRouterBrowserSide` compile **clean** under stock flags. The + entire real-world fix: + - `NOMINMAX` per target (`cef_types_win.h` pulls `windows.h`; else + `std::min` → C2589), and + - wrapper built per-config with `-DCEF_RUNTIME_LIBRARY_FLAG=/MD` (Debug + auto-appends `d`; mixing configs → LNK2038 RuntimeLibrary / + `_ITERATOR_DEBUG_LEVEL` mismatch). Prebuilt tooling must ship /MD **and** + /MDd wrapper libs. + No `cxx_std_20`, no `/wd` additions, no exception-model change, no + `USING_CEF_SHARED` macro. +- `.plugin_symlinks` on 3.38.8 is a **true SYMLINKD** (reparse tag + 0xa000000c), not a junction → **Developer Mode (or elevation) is a + dev/CI-image requirement**; document in setup. Out-of-tree + `../native/shared/*.cc` sources compile, incremental rebuild propagates + through the symlink (edit → 7.9 s rebuild, change present), + `_bundled_libraries PARENT_SCOPE` lands an out-of-tree DLL beside + `app.exe` — the shared-core layout of PLAN §4.3 is buildable exactly as + drawn. + +## Plan corrections applied + +1. **PLAN §4.3 external-begin-frame "non-negotiable" — REFUTED and + inverted.** On CEF 144/Windows, `windowless_frame_rate=60` **without** + external begin frames delivers 60.3 fps × 12 concurrent browsers. With + `external_begin_frame_enabled=1` + a 16 ms `SendExternalBeginFrame` pump to + all 12, **only the first browser ever paints** — fatal for the + one-host-N-browsers model. Windows default: external begin frames OFF; + host-driven cadence (the macOS pacer) must be re-spiked per-browser before + any P6 use. (CefSharp #2675 evidently fixed/changed by 144.) +2. **PLAN §3 S6 expected-fix list** replaced by the two-line S6a recipe + above. +3. **PLAN §2 claim #18 (WebAuthn)** resolved: UI appears; v1 supports + passkeys; `SetAsWindowless(hwnd)` refinement noted. +4. (P0 scope, already landed with this report: `.gitattributes` LF-pins + `packages/**/native/**` + `*.sh`; PORTING.md's producer-allocates + inversion, `--remote-debugging-io-pipes`, and bootstrap-DLL sandbox model + corrected.) + +## P0 gate: **GREEN** + +All six spike questions answered with measurements; no fallback needed +anywhere; PLAN.md §2/§3/§4 updated where evidence contradicted it. Next: P1 +(package skeleton + Dart conditionals + example/windows runner + CI). From df34eabb3bb73b715dd1e2fd12e8dcbfe88c7ac5 Mon Sep 17 00:00:00 2001 From: wenkai fan Date: Mon, 20 Jul 2026 23:26:57 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat(windows):=20P1-P4=20vertical=20slice?= =?UTF-8?q?=20=E2=80=94=20the=20example=20app=20browses=20on=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New federated package packages/flutter_cef_windows: - windows/ plugin (C++): channel verb dispatch, GpuSurfaceTexture fed by the host's legacy MISC_SHARED bridge handles (belt-1 ComPtr ref held), named-pipe IPC + reader-thread -> platform-thread marshal, cef_host spawn with Job Object reaping, editCommand/zoom/find relays. - native/cef_host: Windows host as cef_host.dll + CEF bootstrapc.exe renamed cef_host.exe (RunConsoleMain). OnAcceleratedPaint per the spike laws: OpenSharedResource1 + CopyResource inside the callback, size-gated presents {u64 bridgeHandle}{u32 srcW}{u32 srcH}, external begin frames OFF (windowless_frame_rate drives 60fps; S4 refuted the begin-frame pump on CEF 144). PROTOCOL.md transcribes the full opcode contract from main.mm. - Dart: Windows endorsement (default_package), platform-aware accelerators (Ctrl on Windows, unchanged Cmd on macOS), Windows VK native keycodes, loadFile drive-letter handling, example/windows runner; 96 lines of new Windows-locked widget tests. flutter analyze + 156/156 tests green; packages/flutter_cef_macos untouched. Verified live on-machine: browsing, link clicks, scroll, typing, backspace, Ctrl accelerators, resize, clean shutdown — and a real end-to-end passkey login (WebAuthn via CredentialUIBroker works in OSR; macOS never had this). Slice posture: no_sandbox=1, ephemeral profiles only; profiles/JS-bridge/ cookies/IME-composition/CDP relay/sandbox = P5-P12. Co-Authored-By: Claude Fable 5 --- .gitattributes | 4 + example/.metadata | 30 + example/lib/main.dart | 16 +- example/pubspec.lock | 7 + example/windows/.gitignore | 17 + example/windows/CMakeLists.txt | 108 + example/windows/flutter/CMakeLists.txt | 109 + .../flutter/generated_plugin_registrant.cc | 14 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 24 + example/windows/runner/CMakeLists.txt | 40 + example/windows/runner/Runner.rc | 121 ++ example/windows/runner/flutter_window.cpp | 71 + example/windows/runner/flutter_window.h | 33 + example/windows/runner/main.cpp | 43 + example/windows/runner/resource.h | 16 + example/windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes example/windows/runner/runner.exe.manifest | 14 + example/windows/runner/utils.cpp | 65 + example/windows/runner/utils.h | 19 + example/windows/runner/win32_window.cpp | 288 +++ example/windows/runner/win32_window.h | 102 + lib/flutter_cef.dart | 9 +- lib/src/cef_web_controller.dart | 10 +- lib/src/cef_web_view.dart | 51 +- packages/flutter_cef_windows/.gitignore | 3 + packages/flutter_cef_windows/CHANGELOG.md | 7 + packages/flutter_cef_windows/LICENSE | 21 + packages/flutter_cef_windows/README.md | 35 + .../lib/flutter_cef_windows.dart | 19 + .../native/cef_host/CMakeLists.txt | 88 + .../native/cef_host/PROTOCOL.md | 239 +++ .../native/cef_host/build_cef_host.bat | 45 + .../native/cef_host/cef_host_protocol.h | 139 ++ .../native/cef_host/cef_host_win.cc | 1768 +++++++++++++++++ .../native/cef_host/fetch_cef.ps1 | 37 + .../native/cef_host/test/pipe_probe.cc | 341 ++++ packages/flutter_cef_windows/pubspec.yaml | 37 + .../windows/CMakeLists.txt | 148 ++ .../windows/flutter_cef_plugin.cpp | 1031 ++++++++++ .../windows/flutter_cef_plugin.h | 157 ++ .../windows/host_process.cpp | 120 ++ .../windows/host_process.h | 71 + .../flutter_cef_windows/flutter_cef_plugin.h | 29 + .../flutter_cef_windows/windows/ipc_pipe.cpp | 232 +++ .../flutter_cef_windows/windows/ipc_pipe.h | 112 ++ .../windows/texture_bridge.cpp | 194 ++ .../windows/texture_bridge.h | 83 + pubspec.yaml | 7 + specs/windows-port/SPIKES.md | 5 + test/cef_web_controller_test.dart | 14 + test/cef_web_view_test.dart | 96 + 52 files changed, 6283 insertions(+), 21 deletions(-) create mode 100644 example/.metadata create mode 100644 example/windows/.gitignore create mode 100644 example/windows/CMakeLists.txt create mode 100644 example/windows/flutter/CMakeLists.txt create mode 100644 example/windows/flutter/generated_plugin_registrant.cc create mode 100644 example/windows/flutter/generated_plugin_registrant.h create mode 100644 example/windows/flutter/generated_plugins.cmake create mode 100644 example/windows/runner/CMakeLists.txt create mode 100644 example/windows/runner/Runner.rc create mode 100644 example/windows/runner/flutter_window.cpp create mode 100644 example/windows/runner/flutter_window.h create mode 100644 example/windows/runner/main.cpp create mode 100644 example/windows/runner/resource.h create mode 100644 example/windows/runner/resources/app_icon.ico create mode 100644 example/windows/runner/runner.exe.manifest create mode 100644 example/windows/runner/utils.cpp create mode 100644 example/windows/runner/utils.h create mode 100644 example/windows/runner/win32_window.cpp create mode 100644 example/windows/runner/win32_window.h create mode 100644 packages/flutter_cef_windows/.gitignore create mode 100644 packages/flutter_cef_windows/CHANGELOG.md create mode 100644 packages/flutter_cef_windows/LICENSE create mode 100644 packages/flutter_cef_windows/README.md create mode 100644 packages/flutter_cef_windows/lib/flutter_cef_windows.dart create mode 100644 packages/flutter_cef_windows/native/cef_host/CMakeLists.txt create mode 100644 packages/flutter_cef_windows/native/cef_host/PROTOCOL.md create mode 100644 packages/flutter_cef_windows/native/cef_host/build_cef_host.bat create mode 100644 packages/flutter_cef_windows/native/cef_host/cef_host_protocol.h create mode 100644 packages/flutter_cef_windows/native/cef_host/cef_host_win.cc create mode 100644 packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 create mode 100644 packages/flutter_cef_windows/native/cef_host/test/pipe_probe.cc create mode 100644 packages/flutter_cef_windows/pubspec.yaml create mode 100644 packages/flutter_cef_windows/windows/CMakeLists.txt create mode 100644 packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp create mode 100644 packages/flutter_cef_windows/windows/flutter_cef_plugin.h create mode 100644 packages/flutter_cef_windows/windows/host_process.cpp create mode 100644 packages/flutter_cef_windows/windows/host_process.h create mode 100644 packages/flutter_cef_windows/windows/include/flutter_cef_windows/flutter_cef_plugin.h create mode 100644 packages/flutter_cef_windows/windows/ipc_pipe.cpp create mode 100644 packages/flutter_cef_windows/windows/ipc_pipe.h create mode 100644 packages/flutter_cef_windows/windows/texture_bridge.cpp create mode 100644 packages/flutter_cef_windows/windows/texture_bridge.h diff --git a/.gitattributes b/.gitattributes index fddab01..105d8dc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,7 @@ 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/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/main.dart b/example/lib/main.dart index 52b0982..5e76614 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cef/flutter_cef.dart'; @@ -248,11 +250,15 @@ and committed text — including emoji — should appear intact.

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', 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 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 zcmeHQc|26z|35SKE&G-*mXah&B~fFkXr)DEO&hIfqby^T&>|8^_Ub8Vp#`BLl3lbZ zvPO!8k!2X>cg~Elr=IVxo~J*a`+9wR=A83c-k-DFd(XM&UI1VKCqM@V;DDtJ09WB} zRaHKiW(GT00brH|0EeTeKVbpbGZg?nK6-j827q-+NFM34gXjqWxJ*a#{b_apGN<-L_m3#8Z26atkEn& ze87Bvv^6vVmM+p+cQ~{u%=NJF>#(d;8{7Q{^rWKWNtf14H}>#&y7$lqmY6xmZryI& z($uy?c5-+cPnt2%)R&(KIWEXww>Cnz{OUpT>W$CbO$h1= z#4BPMkFG1Y)x}Ui+WXr?Z!w!t_hjRq8qTaWpu}FH{MsHlU{>;08goVLm{V<&`itk~ zE_Ys=D(hjiy+5=?=$HGii=Y5)jMe9|wWoD_K07(}edAxh`~LBorOJ!Cf@f{_gNCC| z%{*04ViE!#>@hc1t5bb+NO>ncf@@Dv01K!NxH$3Eg1%)|wLyMDF8^d44lV!_Sr}iEWefOaL z8f?ud3Q%Sen39u|%00W<#!E=-RpGa+H8}{ulxVl4mwpjaU+%2pzmi{3HM)%8vb*~-M9rPUAfGCSos8GUXp02|o~0BTV2l#`>>aFV&_P$ejS;nGwSVP8 zMbOaG7<7eKD>c12VdGH;?2@q7535sa7MN*L@&!m?L`ASG%boY7(&L5imY#EQ$KrBB z4@_tfP5m50(T--qv1BJcD&aiH#b-QC>8#7Fx@3yXlonJI#aEIi=8&ChiVpc#N=5le zM*?rDIdcpawoc5kizv$GEjnveyrp3sY>+5_R5;>`>erS%JolimF=A^EIsAK zsPoVyyUHCgf0aYr&alx`<)eb6Be$m&`JYSuBu=p8j%QlNNp$-5C{b4#RubPb|CAIS zGE=9OFLP7?Hgc{?k45)84biT0k&-C6C%Q}aI~q<(7BL`C#<6HyxaR%!dFx7*o^laG z=!GBF^cwK$IA(sn9y6>60Rw{mYRYkp%$jH z*xQM~+bp)G$_RhtFPYx2HTsWk80+p(uqv9@I9)y{b$7NK53rYL$ezbmRjdXS?V}fj zWxX_feWoLFNm3MG7pMUuFPs$qrQWO9!l2B(SIuy2}S|lHNbHzoE+M2|Zxhjq9+Ws8c{*}x^VAib7SbxJ*Q3EnY5lgI9 z=U^f3IW6T=TWaVj+2N%K3<%Un;CF(wUp`TC&Y|ZjyFu6co^uqDDB#EP?DV5v_dw~E zIRK*BoY9y-G_ToU2V_XCX4nJ32~`czdjT!zwme zGgJ0nOk3U4@IE5JwtM}pwimLjk{ln^*4HMU%Fl4~n(cnsLB}Ja-jUM>xIB%aY;Nq8 z)Fp8dv1tkqKanv<68o@cN|%thj$+f;zGSO7H#b+eMAV8xH$hLggtt?O?;oYEgbq@= zV(u9bbd12^%;?nyk6&$GPI%|+<_mEpJGNfl*`!KV;VfmZWw{n{rnZ51?}FDh8we_L z8OI9nE31skDqJ5Oa_ybn7|5@ui>aC`s34p4ZEu6-s!%{uU45$Zd1=p$^^dZBh zu<*pDDPLW+c>iWO$&Z_*{VSQKg7=YEpS3PssPn1U!lSm6eZIho*{@&20e4Y_lRklKDTUCKI%o4Pc<|G^Xgu$J^Q|B87U;`c1zGwf^-zH*VQ^x+i^OUWE0yd z;{FJq)2w!%`x7yg@>uGFFf-XJl4H`YtUG%0slGKOlXV`q?RP>AEWg#x!b{0RicxGhS!3$p7 zij;{gm!_u@D4$Ox%>>bPtLJ> zwKtYz?T_DR1jN>DkkfGU^<#6sGz|~p*I{y`aZ>^Di#TC|Z!7j_O1=Wo8thuit?WxR zh9_S>kw^{V^|g}HRUF=dcq>?q(pHxw!8rx4dC6vbQVmIhmICF#zU!HkHpQ>9S%Uo( zMw{eC+`&pb=GZRou|3;Po1}m46H6NGd$t<2mQh}kaK-WFfmj_66_17BX0|j-E2fe3Jat}ijpc53 zJV$$;PC<5aW`{*^Z6e5##^`Ed#a0nwJDT#Qq~^e8^JTA=z^Kl>La|(UQ!bI@#ge{Dzz@61p-I)kc2?ZxFt^QQ}f%ldLjO*GPj(5)V9IyuUakJX=~GnTgZ4$5!3E=V#t`yOG4U z(gphZB6u2zsj=qNFLYShhg$}lNpO`P9xOSnO*$@@UdMYES*{jJVj|9z-}F^riksLK zbsU+4-{281P9e2UjY6tse^&a)WM1MFw;p#_dHhWI7p&U*9TR0zKdVuQed%6{otTsq z$f~S!;wg#Bd9kez=Br{m|66Wv z#g1xMup<0)H;c2ZO6su_ii&m8j&+jJz4iKnGZ&wxoQX|5a>v&_e#6WA!MB_4asTxLRGQCC5cI(em z%$ZfeqP>!*q5kU>a+BO&ln=4Jm>Ef(QE8o&RgLkk%2}4Tf}U%IFP&uS7}&|Q-)`5< z+e>;s#4cJ-z%&-^&!xsYx777Wt(wZY9(3(avmr|gRe4cD+a8&!LY`1^T?7x{E<=kdY9NYw>A;FtTvQ=Y&1M%lyZPl$ss1oY^Sl8we}n}Aob#6 zl4jERwnt9BlSoWb@3HxYgga(752Vu6Y)k4yk9u~Kw>cA5&LHcrvn1Y-HoIuFWg~}4 zEw4bR`mXZQIyOAzo)FYqg?$5W<;^+XX%Uz61{-L6@eP|lLH%|w?g=rFc;OvEW;^qh z&iYXGhVt(G-q<+_j}CTbPS_=K>RKN0&;dubh0NxJyDOHFF;<1k!{k#7b{|Qok9hac z;gHz}6>H6C6RnB`Tt#oaSrX0p-j-oRJ;_WvS-qS--P*8}V943RT6kou-G=A+7QPGQ z!ze^UGxtW3FC0$|(lY9^L!Lx^?Q8cny(rR`es5U;-xBhphF%_WNu|aO<+e9%6LuZq zt(0PoagJG<%hyuf;te}n+qIl_Ej;czWdc{LX^pS>77s9t*2b4s5dvP_!L^3cwlc)E!(!kGrg~FescVT zZCLeua3f4;d;Tk4iXzt}g}O@nlK3?_o91_~@UMIl?@77Qc$IAlLE95#Z=TES>2E%z zxUKpK{_HvGF;5%Q7n&vA?`{%8ohlYT_?(3A$cZSi)MvIJygXD}TS-3UwyUxGLGiJP znblO~G|*uA^|ac8E-w#}uBtg|s_~s&t>-g0X%zIZ@;o_wNMr_;{KDg^O=rg`fhDZu zFp(VKd1Edj%F zWHPl+)FGj%J1BO3bOHVfH^3d1F{)*PL&sRX`~(-Zy3&9UQX)Z;c51tvaI2E*E7!)q zcz|{vpK7bjxix(k&6=OEIBJC!9lTkUbgg?4-yE{9+pFS)$Ar@vrIf`D0Bnsed(Cf? zObt2CJ>BKOl>q8PyFO6w)+6Iz`LW%T5^R`U_NIW0r1dWv6OY=TVF?N=EfA(k(~7VBW(S;Tu5m4Lg8emDG-(mOSSs=M9Q&N8jc^Y4&9RqIsk(yO_P(mcCr}rCs%1MW1VBrn=0-oQN(Xj!k%iKV zb%ricBF3G4S1;+8lzg5PbZ|$Se$)I=PwiK=cDpHYdov2QO1_a-*dL4KUi|g&oh>(* zq$<`dQ^fat`+VW?m)?_KLn&mp^-@d=&7yGDt<=XwZZC=1scwxO2^RRI7n@g-1o8ps z)&+et_~)vr8aIF1VY1Qrq~Xe``KJrQSnAZ{CSq3yP;V*JC;mmCT6oRLSs7=GA?@6g zUooM}@tKtx(^|aKK8vbaHlUQqwE0}>j&~YlN3H#vKGm@u)xxS?n9XrOWUfCRa< z`20Fld2f&;gg7zpo{Adh+mqNntMc-D$N^yWZAZRI+u1T1zWHPxk{+?vcS1D>08>@6 zLhE@`gt1Y9mAK6Z4p|u(5I%EkfU7rKFSM=E4?VG9tI;a*@?6!ey{lzN5=Y-!$WFSe z&2dtO>^0@V4WRc#L&P%R(?@KfSblMS+N+?xUN$u3K4Ys%OmEh+tq}fnU}i>6YHM?< zlnL2gl~sF!j!Y4E;j3eIU-lfa`RsOL*Tt<%EFC0gPzoHfNWAfKFIKZN8}w~(Yi~=q z>=VNLO2|CjkxP}RkutxjV#4fWYR1KNrPYq5ha9Wl+u>ipsk*I(HS@iLnmGH9MFlTU zaFZ*KSR0px>o+pL7BbhB2EC1%PJ{67_ z#kY&#O4@P=OV#-79y_W>Gv2dxL*@G7%LksNSqgId9v;2xJ zrh8uR!F-eU$NMx@S*+sk=C~Dxr9Qn7TfWnTupuHKuQ$;gGiBcU>GF5sWx(~4IP3`f zWE;YFO*?jGwYh%C3X<>RKHC-DZ!*r;cIr}GLOno^3U4tFSSoJp%oHPiSa%nh=Zgn% z14+8v@ygy0>UgEN1bczD6wK45%M>psM)y^)IfG*>3ItX|TzV*0i%@>L(VN!zdKb8S?Qf7BhjNpziA zR}?={-eu>9JDcl*R=OP9B8N$IcCETXah9SUDhr{yrld{G;PnCWRsPD7!eOOFBTWUQ=LrA_~)mFf&!zJX!Oc-_=kT<}m|K52 z)M=G#;p;Rdb@~h5D{q^K;^fX-m5V}L%!wVC2iZ1uu401Ll}#rocTeK|7FAeBRhNdQ zCc2d^aQnQp=MpOmak60N$OgS}a;p(l9CL`o4r(e-nN}mQ?M&isv-P&d$!8|1D1I(3-z!wi zTgoo)*Mv`gC?~bm?S|@}I|m-E2yqPEvYybiD5azInexpK8?9q*$9Yy9-t%5jU8~ym zgZDx>!@ujQ=|HJnwp^wv-FdD{RtzO9SnyfB{mH_(c!jHL*$>0o-(h(eqe*ZwF6Lvu z{7rkk%PEqaA>o+f{H02tzZ@TWy&su?VNw43! z-X+rN`6llvpUms3ZiSt)JMeztB~>9{J8SPmYs&qohxdYFi!ra8KR$35Zp9oR)eFC4 zE;P31#3V)n`w$fZ|4X-|%MX`xZDM~gJyl2W;O$H25*=+1S#%|53>|LyH za@yh+;325%Gq3;J&a)?%7X%t@WXcWL*BaaR*7UEZad4I8iDt7^R_Fd`XeUo256;sAo2F!HcIQKk;h})QxEsPE5BcKc7WyerTchgKmrfRX z!x#H_%cL#B9TWAqkA4I$R^8{%do3Y*&(;WFmJ zU7Dih{t1<{($VtJRl9|&EB?|cJ)xse!;}>6mSO$o5XIx@V|AA8ZcoD88ZM?C*;{|f zZVmf94_l1OmaICt`2sTyG!$^UeTHx9YuUP!omj(r|7zpm5475|yXI=rR>>fteLI+| z)MoiGho0oEt=*J(;?VY0QzwCqw@cVm?d7Y!z0A@u#H?sCJ*ecvyhj& z-F77lO;SH^dmf?L>3i>?Z*U}Em4ZYV_CjgfvzYsRZ+1B!Uo6H6mbS<-FFL`ytqvb& zE7+)2ahv-~dz(Hs+f})z{*4|{)b=2!RZK;PWwOnO=hG7xG`JU5>bAvUbdYd_CjvtHBHgtGdlO+s^9ca^Bv3`t@VRX2_AD$Ckg36OcQRF zXD6QtGfHdw*hx~V(MV-;;ZZF#dJ-piEF+s27z4X1qi5$!o~xBnvf=uopcn7ftfsZc zy@(PuOk`4GL_n(H9(E2)VUjqRCk9kR?w)v@xO6Jm_Mx})&WGEl=GS0#)0FAq^J*o! zAClhvoTsNP*-b~rN{8Yym3g{01}Ep^^Omf=SKqvN?{Q*C4HNNAcrowIa^mf+3PRy! z*_G-|3i8a;+q;iP@~Of_$(vtFkB8yOyWt2*K)vAn9El>=D;A$CEx6b*XF@4y_6M+2 zpeW`RHoI_p(B{%(&jTHI->hmNmZjHUj<@;7w0mx3&koy!2$@cfX{sN19Y}euYJFn& z1?)+?HCkD0MRI$~uB2UWri})0bru_B;klFdwsLc!ne4YUE;t41JqfG# zZJq6%vbsdx!wYeE<~?>o4V`A3?lN%MnKQ`z=uUivQN^vzJ|C;sdQ37Qn?;lpzg})y z)_2~rUdH}zNwX;Tp0tJ78+&I=IwOQ-fl30R79O8@?Ub8IIA(6I`yHn%lARVL`%b8+ z4$8D-|MZZWxc_)vu6@VZN!HsI$*2NOV&uMxBNzIbRgy%ob_ zhwEH{J9r$!dEix9XM7n&c{S(h>nGm?el;gaX0@|QnzFD@bne`el^CO$yXC?BDJ|Qg z+y$GRoR`?ST1z^e*>;!IS@5Ovb7*RlN>BV_UC!7E_F;N#ky%1J{+iixp(dUJj93aK zzHNN>R-oN7>kykHClPnoPTIj7zc6KM(Pnlb(|s??)SMb)4!sMHU^-ntJwY5Big7xv zb1Ew`Xj;|D2kzGja*C$eS44(d&RMU~c_Y14V9_TLTz0J#uHlsx`S6{nhsA0dWZ#cG zJ?`fO50E>*X4TQLv#nl%3GOk*UkAgt=IY+u0LNXqeln3Z zv$~&Li`ZJOKkFuS)dJRA>)b_Da%Q~axwA_8zNK{BH{#}#m}zGcuckz}riDE-z_Ms> zR8-EqAMcfyGJCtvTpaUVQtajhUS%c@Yj}&6Zz;-M7MZzqv3kA7{SuW$oW#=0az2wQ zg-WG@Vb4|D`pl~Il54N7Hmsauc_ne-a!o5#j3WaBBh@Wuefb!QJIOn5;d)%A#s+5% zuD$H=VNux9bE-}1&bcYGZ+>1Fo;3Z@e&zX^n!?JK*adSbONm$XW9z;Q^L>9U!}Toj2WdafJ%oL#h|yWWwyAGxzfrAWdDTtaKl zK4`5tDpPg5>z$MNv=X0LZ0d6l%D{(D8oT@+w0?ce$DZ6pv>{1&Ok67Ix1 zH}3=IEhPJEhItCC8E=`T`N5(k?G=B4+xzZ?<4!~ ze~z6Wk9!CHTI(0rLJ4{JU?E-puc;xusR?>G?;4vt;q~iI9=kDL=z0Rr%O$vU`30X$ zDZRFyZ`(omOy@u|i6h;wtJlP;+}$|Ak|k2dea7n?U1*$T!sXqqOjq^NxLPMmk~&qI zYg0W?yK8T(6+Ea+$YyspKK?kP$+B`~t3^Pib_`!6xCs32!i@pqXfFV6PmBIR<-QW= zN8L{pt0Vap0x`Gzn#E@zh@H)0FfVfA_Iu4fjYZ+umO1LXIbVc$pY+E234u)ttcrl$ z>s92z4vT%n6cMb>=XT6;l0+9e(|CZG)$@C7t7Z7Ez@a)h)!hyuV&B5K%%)P5?Lk|C zZZSVzdXp{@OXSP0hoU-gF8s8Um(#xzjP2Vem zec#-^JqTa&Y#QJ>-FBxd7tf`XB6e^JPUgagB8iBSEps;92KG`!#mvVcPQ5yNC-GEG zTiHEDYfH+0O15}r^+ z#jxj=@x8iNHWALe!P3R67TwmhItn**0JwnzSV2O&KE8KcT+0hWH^OPD1pwiuyx=b@ zNf5Jh0{9X)8;~Es)$t@%(3!OnbY+`@?i{mGX7Yy}8T_*0a6g;kaFPq;*=px5EhO{Cp%1kI<0?*|h8v!6WnO3cCJRF2-CRrU3JiLJnj@6;L)!0kWYAc_}F{2P))3HmCrz zQ&N&gE70;`!6*eJ4^1IR{f6j4(-l&X!tjHxkbHA^Zhrnhr9g{exN|xrS`5Pq=#Xf& zG%P=#ra-TyVFfgW%cZo5OSIwFL9WtXAlFOa+ubmI5t*3=g#Y zF%;70p5;{ZeFL}&}yOY1N1*Q;*<(kTB!7vM$QokF)yr2FlIU@$Ph58$Bz z0J?xQG=MlS4L6jA22eS42g|9*9pX@$#*sUeM(z+t?hr@r5J&D1rx}2pW&m*_`VDCW zUYY@v-;bAO0HqoAgbbiGGC<=ryf96}3pouhy3XJrX+!!u*O_>Si38V{uJmQ&USptX zKp#l(?>%^7;2%h(q@YWS#9;a!JhKlkR#Vd)ERILlgu!Hr@jA@V;sk4BJ-H#p*4EqC zDGjC*tl=@3Oi6)Bn^QwFpul18fpkbpg0+peH$xyPBqb%`$OUhPKyWb32o7clB*9Z< zN=i~NLjavrLtwgJ01bufP+>p-jR2I95|TpmKpQL2!oV>g(4RvS2pK4*ou%m(h6r3A zX#s&`9LU1ZG&;{CkOK!4fLDTnBys`M!vuz>Q&9OZ0hGQl!~!jSDg|~s*w52opC{sB ze|Cf2luD(*G13LcOAGA!s2FjSK8&IE5#W%J25w!vM0^VyQM!t)inj&RTiJ!wXzFgz z3^IqzB7I0L$llljsGq})thBy9UOyjtFO_*hYM_sgcMk>44jeH0V1FDyELc{S1F-;A zS;T^k^~4biG&V*Irq}O;e}j$$+E_#G?HKIn05iP3j|87TkGK~SqG!-KBg5+mN(aLm z8ybhIM`%C19UX$H$KY6JgXbY$0AT%rEpHC;u`rQ$Y=rxUdsc5*Kvc8jaYaO$^)cI6){P6K0r)I6DY4Wr4&B zLQUBraey#0HV|&c4v7PVo3n$zHj99(TZO^3?Ly%C4nYvJTL9eLBLHsM3WKKD>5!B` zQ=BsR3aR6PD(Fa>327E2HAu5TM~Wusc!)>~(gM)+3~m;92Jd;FnSib=M5d6;;5{%R zb4V7DEJ0V!CP-F*oU?gkc>ksUtAYP&V4ND5J>J2^jt*vcFflQWCrB&fLdT%O59PVJ zhid#toR=FNgD!q3&r8#wEBr`!wzvQu5zX?Q>nlSJ4i@WC*CN*-xU66F^V5crWevQ9gsq$I@z1o(a=k7LL~ z7m_~`o;_Ozha1$8Q}{WBehvAlO4EL60y5}8GDrZ< zXh&F}71JbW2A~8KfEWj&UWV#4+Z4p`b{uAj4&WC zha`}X@3~+Iz^WRlOHU&KngK>#j}+_o@LdBC1H-`gT+krWX3-;!)6?{FBp~%20a}FL zFP9%Emqcwa#(`=G>BBZ0qZDQhmZKJg_g8<=bBFKWr!dyg(YkpE+|R*SGpDVU!+VlU zFC54^DLv}`qa%49T>nNiA9Q7Ips#!Xx90tCU2gvK`(F+GPcL=J^>No{)~we#o@&mUb6c$ zCc*<|NJBk-#+{j9xkQ&ujB zI~`#kN~7W!f*-}wkG~Ld!JqZ@tK}eeSnsS5J1fMFXm|`LJx&}5`@dK3W^7#Wnm+_P zBZkp&j1fa2Y=eIjJ0}gh85jt43kaIXXv?xmo@eHrka!Z|vQv12HN#+!I5E z`(fbuW>gFiJL|uXJ!vKt#z3e3HlVdboH7;e#i3(2<)Fg-I@BR!qY#eof3MFZ&*Y@l zI|KJf&ge@p2Dq09Vu$$Qxb7!}{m-iRk@!)%KL)txi3;~Z4Pb}u@GsW;ELiWeG9V51 znX#}B&4Y2E7-H=OpNE@q{%hFLxwIpBF2t{vPREa8_{linXT;#1vMRWjOzLOP$-hf( z>=?$0;~~PnkqY;~K{EM6Vo-T(0K{A0}VUGmu*hR z{tw3hvBN%N3G3Yw`X5Te+F{J`(3w1s3-+1EbnFQKcrgrX1Jqvs@ADGe%M0s$EbK$$ zK)=y=upBc6SjGYAACCcI=Y*6Fi8_jgwZlLxD26fnQfJmb8^gHRN5(TemhX@0e=vr> zg`W}6U>x6VhoA3DqsGGD9uL1DhB3!OXO=k}59TqD@(0Nb{)Ut_luTioK_>7wjc!5C zIr@w}b`Fez3)0wQfKl&bae7;PcTA7%?f2xucM0G)wt_KO!Ewx>F~;=BI0j=Fb4>pp zv}0R^xM4eti~+^+gE$6b81p(kwzuDti(-K9bc|?+pJEl@H+jSYuxZQV8rl8 zjp@M{#%qItIUFN~KcO9Hed*`$5A-2~pAo~K&<-Q+`9`$CK>rzqAI4w~$F%vs9s{~x zg4BP%Gy*@m?;D6=SRX?888Q6peF@_4Z->8wAH~Cn!R$|Hhq2cIzFYqT_+cDourHbY z0qroxJnrZ4Gh+Ay+F`_c%+KRT>y3qw{)89?=hJ@=KO=@ep)aBJ$c!JHfBMJpsP*3G za7|)VJJ8B;4?n{~ldJF7%jmb`-ftIvNd~ekoufG(`K(3=LNc;HBY& z(lp#q8XAD#cIf}k49zX_i`*fO+#!zKA&%T3j@%)R+#yag067CU%yUEe47>wzGU8^` z1EXFT^@I!{J!F8!X?S6ph8J=gUi5tl93*W>7}_uR<2N2~e}FaG?}KPyugQ=-OGEZs z!GBoyYY+H*ANn4?Z)X4l+7H%`17i5~zRlRIX?t)6_eu=g2Q`3WBhxSUeea+M-S?RL zX9oBGKn%a!H+*hx4d2(I!gsi+@SQK%<{X22M~2tMulJoa)0*+z9=-YO+;DFEm5eE1U9b^B(Z}2^9!Qk`!A$wUE z7$Ar5?NRg2&G!AZqnmE64eh^Anss3i!{}%6@Et+4rr!=}!SBF8eZ2*J3ujCWbl;3; z48H~goPSv(8X61fKKdpP!Z7$88NL^Z?j`!^*I?-P4X^pMxyWz~@$(UeAcTSDd(`vO z{~rc;9|GfMJcApU3k}22a!&)k4{CU!e_ny^Y3cO;tOvOMKEyWz!vG(Kp*;hB?d|R3`2X~=5a6#^o5@qn?J-bI8Ppip{-yG z!k|VcGsq!jF~}7DMr49Wap-s&>o=U^T0!Lcy}!(bhtYsPQy z4|EJe{12QL#=c(suQ89Mhw9<`bui%nx7Nep`C&*M3~vMEACmcRYYRGtANq$F%zh&V zc)cEVeHz*Z1N)L7k-(k3np#{GcDh2Q@ya0YHl*n7fl*ZPAsbU-a94MYYtA#&!c`xGIaV;yzsmrjfieTEtqB_WgZp2*NplHx=$O{M~2#i_vJ{ps-NgK zQsxKK_CBM2PP_je+Xft`(vYfXXgIUr{=PA=7a8`2EHk)Ym2QKIforz# tySWtj{oF3N9@_;i*Fv5S)9x^z=nlWP>jpp-9)52ZmLVA=i*%6g{{fxOO~wEK literal 0 HcmV?d00001 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..5f03a12 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)); @@ -559,12 +584,18 @@ class _CefWebViewState extends State // 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_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..2986b06 --- /dev/null +++ b/packages/flutter_cef_windows/native/cef_host/CMakeLists.txt @@ -0,0 +1,88 @@ +# 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_ROOT: -DCEF_ROOT=... > env CEF_ROOT > the dev-box spike extraction. +if(NOT DEFINED CEF_ROOT) + if(DEFINED ENV{CEF_ROOT}) + set(CEF_ROOT "$ENV{CEF_ROOT}") + else() + set(CEF_ROOT "C:/dev/flutter_cef_spikes/cef/cef_binary_144.0.27+g3fae261+chromium-144.0.7559.254_windows64_minimal") + endif() +endif() +if(NOT EXISTS "${CEF_ROOT}/cmake") + message(FATAL_ERROR + "CEF_ROOT not found at '${CEF_ROOT}'. Run fetch_cef.ps1 or set CEF_ROOT " + "(env or -DCEF_ROOT=) to the extracted cef_binary_144.0.27 windows64_minimal dir.") +endif() +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. -DCEF_WRAPPER_LIB=. +if(NOT DEFINED CEF_WRAPPER_LIB) + set(_default_prebuilt + "C:/dev/flutter_cef_spikes/cefbuild/libcef_dll_wrapper/libcef_dll_wrapper.lib") + if(EXISTS "${_default_prebuilt}") + set(CEF_WRAPPER_LIB "${_default_prebuilt}") + endif() +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..0c85cfd --- /dev/null +++ b/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md @@ -0,0 +1,239 @@ +# 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 = must be functional for the vertical slice; +STUB = 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 | STUB | Swift:131-133 | +| editCommand | command:int | null | 0x38 | STUB | Swift:134-136 | +| setVisible | visible:bool | null | 0x35 | SLICE | Swift:137-139 | +| find | text:String, forward:bool, matchCase:bool, findNext:bool | null | 0x27 | STUB | Swift:140-148 | +| stopFind | clearSelection:bool | null | 0x28 | STUB | Swift:149-151 | +| respondJsDialog | id:int, ok:bool, text:String | null | 0x29 | STUB | Swift:152-158 | +| evalReturning | id:int, code:String | null | 0x2a | STUB | Swift:159-165 | +| addJavaScriptChannel | name:String | null | 0x2b | STUB | Swift:166-170 | +| setCookie | url, name, value, domain, path : String | null | 0x2c | STUB | Swift:171-179 | +| clearCookies | — | null | 0x2d | STUB | Swift:180-182 | +| visitCookies | id:int, url:String | null | 0x2e | STUB | Swift:183-188 | +| deleteCookie | url:String, name:String | null | 0x2f | STUB | Swift:189-194 | +| showDevTools | — | null | 0x33 | STUB | Swift:195-197 | +| enableAgentControl | — | `{wsUrl, token, port}` or FlutterError | CDP relay (P9) | STUB (null) | Swift:198-217 | +| disableAgentControl | — | null | CDP relay (P9) | STUB | Swift:218-225 | +| showEmojiPicker | — | null | macOS-only (Character Palette) | STUB | Swift:226-230 | +| imeSetComposition | text:String | null | 0x30 | STUB | Swift:231-233 | +| imeCommitText | text:String | null | 0x31 | STUB | Swift:234-236 | +| imeCancelComposition | — | null | 0x32 | STUB | 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 (slice): `--ipc=` `--profile-dir=` + `--ephemeral` (cf. macOS args main.mm:32-38; `--cdp-port`/`--cdp-pipe`/ + `--allowed-schemes` are post-slice). 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..8729ac3 --- /dev/null +++ b/packages/flutter_cef_windows/native/cef_host/build_cef_host.bat @@ -0,0 +1,45 @@ +@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 +rem Deterministic + incremental-safe: configure only when build.ninja is +rem missing; ninja no-ops when nothing changed. + +setlocal +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 env (cmake/ninja ship with VS2022 but are NOT on PATH — see +rem specs/windows-port dev notes). +call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" >nul +if errorlevel 1 exit /b 1 +set CMAKE="C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe" +set NINJA_DIR=C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja +set PATH=%NINJA_DIR%;%PATH% + +if not exist "%BUILD_DIR%\build.ninja" ( + %CMAKE% -G Ninja -DCMAKE_BUILD_TYPE=Release -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..70787e4 --- /dev/null +++ b/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc @@ -0,0 +1,1768 @@ +// 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 + +#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_request_handler.h" +#include "include/cef_sandbox_win.h" +#include "include/cef_task.h" +#include "include/wrapper/cef_closure_task.h" +#include "include/wrapper/cef_helpers.h" + +#include "cef_host_protocol.h" + +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; + +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; + +// Returns false (once, then cached) if D3D11 is unavailable — the pixel path +// then degrades to the logged software OnPaint fallback. +bool EnsureD3D() { + static bool tried = false; + if (g_d3d_device1) return true; + if (tried) return false; + 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; + } + return true; +} + +// 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; + // 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) + return true; // steady state: zero allocation + 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; + 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) { + // popup compositing, CDP relay (P9), sandbox/signing (P11). Co-Authored-By: Claude Fable 5 --- README.md | 10 +- example/lib/alert_probe.dart | 98 ++++++ example/lib/jsbridge_smoke.dart | 250 +++++++++++++++ example/lib/main.dart | 114 +++++++ .../native/cef_host/PROTOCOL.md | 177 ++++++++++- .../native/cef_host/cef_host_win.cc | 290 ++++++++++++++++-- 6 files changed, 889 insertions(+), 50 deletions(-) create mode 100644 example/lib/alert_probe.dart create mode 100644 example/lib/jsbridge_smoke.dart diff --git a/README.md b/README.md index d255499..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(); @@ -456,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/lib/alert_probe.dart b/example/lib/alert_probe.dart new file mode 100644 index 0000000..965e90f --- /dev/null +++ b/example/lib/alert_probe.dart @@ -0,0 +1,98 @@ +// Minimal alert-dismiss probe: does answering a JS alert unblock the renderer? +// Uses document.title (OnTitleChange, independent of the eval router) as the +// liveness signal. After alert() is answered, the page sets title='resumed'. +// ignore_for_file: avoid_print +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _html = r''' +'''; + +void main() => runApp(const AlertProbe()); + +class AlertProbe extends StatefulWidget { + const AlertProbe({super.key}); + @override + State createState() => _S(); +} + +class _S extends State { + final CefWebController _c = CefWebController(); + bool _loaded = false, _alertFired = false, _done = false; + final List _titles = []; + + @override + void initState() { + super.initState(); + _c.onJavaScriptAlertDialog = (r) async { + _alertFired = true; + print('ALERT_PROBE alert fired: ${r.message}'); + }; + _c.title.addListener(() { + _titles.add(_c.title.value); + print('ALERT_PROBE title=${_c.title.value}'); + if (_c.title.value == 'resumed' && !_done) { + _done = true; + _postAlert(); + } + }); + _c.onPageStarted = (u) { + if (!_loaded) { + _loaded = true; + _c.loadHtmlString(_html); + } + }; + Timer(const Duration(seconds: 12), () { + if (!_done) { + _done = true; + _finish(false); + } + }); + } + + Future _postAlert() async { + // Does the Dart event loop / method channel keep working AFTER a dialog? + print('POST_ALERT begin'); + await Future.delayed(const Duration(milliseconds: 800)); + print('POST_ALERT delay-ok'); + try { + final v = await _c + .runJavaScriptReturningResult('6*7') + .timeout(const Duration(seconds: 4)); + print('POST_ALERT eval=$v'); + } catch (e) { + print('POST_ALERT eval-FAILED $e'); + } + _finish(true); + } + + void _finish(bool ok) { + final out = { + 'pass': ok, + 'alert_fired': _alertFired, + 'titles': _titles, + }; + try { + File('/tmp/cef_alert_probe.json').writeAsStringSync(jsonEncode(out)); + } catch (_) {} + print('ALERT_PROBE_RESULT ${jsonEncode(out)}'); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => MaterialApp( + home: Scaffold(body: CefWebView(url: 'about:blank', controller: _c))); +} diff --git a/example/lib/jsbridge_smoke.dart b/example/lib/jsbridge_smoke.dart new file mode 100644 index 0000000..bd5dcb4 --- /dev/null +++ b/example/lib/jsbridge_smoke.dart @@ -0,0 +1,250 @@ +// P7 JS-bridge smoke harness (Windows integration proof) — reload-tolerant. +// +// loadHtmlString settles with an extra page reload, so linear orchestration on +// one page session is fragile. Instead: confirm() + the download click run in +// the page's inline script (idempotent — they re-run on every load and report a +// stable signal), while find + zoom are Dart-driven and re-armed on each load. +// A feature is "proven" the first time its signal is observed; the probe +// finalizes once all are collected (or on a hard timeout). +// +// Covers: runJavaScriptReturningResult (String/num/List, incl. error path), +// JS confirm dialog (Dart answers true, page observes true), find-in-page count, +// content zoom (applies without wedging the renderer), and download (onDownload +// + file lands in Downloads). Alert is proven separately by alert_probe.dart. +// +// Writes C:\tmp\cef_jsbridge_smoke.json and prints CEF_JSBRIDGE_SMOKE_RESULT. +// ignore_for_file: avoid_print +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _resultPath = '/tmp/cef_jsbridge_smoke.json'; + +// confirm() result -> title 'cfm:true'/'cfm:false'; download link auto-clicked. +const _html = r''' + +

jsbridge smoke

+

the quick brown fox the fox jumps over the fox again

+dl +'''; + +void main() => runApp(const SmokeApp()); + +class SmokeApp extends StatefulWidget { + const SmokeApp({super.key}); + @override + State createState() => _SmokeAppState(); +} + +class _SmokeAppState extends State { + final CefWebController _c = CefWebController(); + final Map _out = {}; + bool _loaded = false; + bool _runjsDone = false; + bool _findZoomDone = false; + bool _finished = false; + String _status = 'starting…'; + + @override + void initState() { + super.initState(); + + // confirm — Dart answers true; the page's confirm() must observe true. + _c.onJavaScriptConfirmDialog = (req) async { + _out['confirm_seen'] = req.message; + return true; + }; + _c.title.addListener(() { + final t = _c.title.value; + if (t.startsWith('cfm:')) { + _out['confirm_page_saw'] = t == 'cfm:true'; + _out['confirm_ok'] = + _out['confirm_seen'] == 'smoke-confirm' && t == 'cfm:true'; + print('CEF_JSBRIDGE_SMOKE confirm title=$t'); + _maybeStartDartTests(); + } + }); + + // download — fires on every load; capture the first. + _c.onDownload = (name) { + if (_out['download_event'] == null) { + _out['download_event'] = name; + print('CEF_JSBRIDGE_SMOKE download=$name'); + } + }; + + _c.onPageStarted = (url) { + if (!_loaded) { + _loaded = true; + _c.loadHtmlString(_html); + } + }; + + Timer(const Duration(seconds: 30), () => _finish()); + } + + // Runs the Dart-driven tests once, after the confirm signal proves the page + // is live. Re-entrant-safe via the _runjsDone / _findZoomDone guards. + Future _maybeStartDartTests() async { + if (_runjsDone) return; + _runjsDone = true; + + // 1. runJavaScriptReturningResult — typed round-trips. + try { + final s = await _c + .runJavaScriptReturningResult("'hello'+'-world'") + .timeout(const Duration(seconds: 4)); + _out['runjs_string'] = s; + _out['runjs_string_ok'] = s == 'hello-world'; + + final n = await _c + .runJavaScriptReturningResult('6*7') + .timeout(const Duration(seconds: 4)); + _out['runjs_num'] = n; + _out['runjs_num_ok'] = n is num && n == 42; + + final l = await _c + .runJavaScriptReturningResult('[1,2,3].map(function(x){return x*10;})') + .timeout(const Duration(seconds: 4)); + _out['runjs_list'] = l; + _out['runjs_list_ok'] = l is List && l.length == 3 && l[2] == 30; + + try { + await _c + .runJavaScriptReturningResult('(function(){throw new Error("boom")})()') + .timeout(const Duration(seconds: 4)); + _out['runjs_error_ok'] = false; + } catch (_) { + _out['runjs_error_ok'] = true; + } + print('CEF_JSBRIDGE_SMOKE runjs s=$s n=$n l=$l err_ok=${_out['runjs_error_ok']}'); + } catch (e) { + _out['runjs_exception'] = e.toString(); + print('CEF_JSBRIDGE_SMOKE runjs EXCEPTION $e'); + } + + await _runFindZoom(); + _finish(); + } + + Future _runFindZoom() async { + if (_findZoomDone) return; + _findZoomDone = true; + + // 4. find — "fox" appears 3x. + final findDone = Completer(); + _c.onFindResult = (r) { + if (r.numberOfMatches > 0 && !findDone.isCompleted) findDone.complete(r); + }; + await _c.find('fox'); + try { + final fr = await findDone.future.timeout(const Duration(seconds: 4)); + _out['find_count'] = fr.numberOfMatches; + _out['find_ok'] = fr.numberOfMatches == 3; + print('CEF_JSBRIDGE_SMOKE find count=${fr.numberOfMatches}'); + } catch (_) { + _out['find_ok'] = false; + } + await _c.stopFind(); + + // 5. zoom — verb applies and the renderer stays live (readback of an eval). + try { + await _c.setZoomLevel(2.0); + await Future.delayed(const Duration(milliseconds: 300)); + final alive = await _c + .runJavaScriptReturningResult('1+1') + .timeout(const Duration(seconds: 4)); + await _c.setZoomLevel(0.0); + _out['zoom_ok'] = alive == 2; + print('CEF_JSBRIDGE_SMOKE zoom alive=$alive'); + } catch (e) { + _out['zoom_ok'] = false; + _out['zoom_exception'] = e.toString(); + } + } + + void _finish() { + if (_finished) return; + // Wait for the Dart tests + a download signal before finalizing (unless the + // hard timeout forced us here). + final dlDir = '${Platform.environment['USERPROFILE']}\\Downloads'; + final target = File('$dlDir\\cef_smoke.txt'); + _out['download_landed'] = target.existsSync(); + _out['download_ok'] = + _out['download_event'] != null && target.existsSync(); + if (target.existsSync()) { + try { + _out['download_bytes'] = target.readAsStringSync(); + } catch (_) {} + } + + final haveAll = _findZoomDone && + _out.containsKey('runjs_string_ok') && + _out.containsKey('find_ok') && + _out.containsKey('zoom_ok') && + _out.containsKey('confirm_ok') && + _out['download_event'] != null; + if (!haveAll && DateTime.now().isBefore(_hardDeadline)) { + // Not everything collected yet and no timeout — try again shortly. + Timer(const Duration(milliseconds: 500), _finish); + return; + } + _finished = true; + + final gate = (_out['runjs_string_ok'] == true) && + (_out['runjs_num_ok'] == true) && + (_out['runjs_list_ok'] == true) && + (_out['runjs_error_ok'] == true) && + (_out['confirm_ok'] == true) && + (_out['find_ok'] == true) && + (_out['zoom_ok'] == true) && + (_out['download_ok'] == true); + _out['pass'] = gate; + + try { + File(_resultPath).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(_out), + ); + } catch (_) {} + print('CEF_JSBRIDGE_SMOKE_RESULT ${jsonEncode(_out)}'); + if (mounted) setState(() => _status = gate ? 'PASS' : 'PARTIAL — $_out'); + } + + final DateTime _hardDeadline = + DateTime.now().add(const Duration(seconds: 28)); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text('jsbridge smoke — $_status', + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded(child: CefWebView(url: 'about:blank', controller: _c)), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 5e76614..13a492a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -45,6 +45,14 @@ class _BrowserDemoState extends State { final TextEditingController _urlBar = TextEditingController(text: _startUrl); double _zoom = 0; + // Find-in-page bar, opened by ⌘F / Ctrl+F via [CefWebView.onFind]. The view + // has no find UI of its own; the host owns it and drives controller.find / + // stopFind, reading results back on onFindResult. + final TextEditingController _findBar = TextEditingController(); + final FocusNode _findFocus = FocusNode(debugLabel: 'find'); + bool _findVisible = false; + CefFindResult? _findResult; + CefWebController _newController() => CefWebController(profile: _profile); @override @@ -69,6 +77,17 @@ class _BrowserDemoState extends State { _urlBar.text = url; _controller.navigate(url); }; + // A page->host JS channel: the page calls window.flutterCef.postMessage(...) + // (see the "channel" toolbar button, which pokes the page to do exactly + // that) and the message surfaces here. Registered per-controller, so it is + // re-wired when a profile toggle swaps the controller. + _controller.addJavaScriptChannel('flutterCef', + onMessageReceived: (m) => _snack('JS channel -> $m')); + // Find-in-page result updates, driven by the find bar (opened with ⌘F / + // Ctrl+F). Guarded on mounted since the callback can outlive a rebuild. + _controller.onFindResult = (r) { + if (mounted) setState(() => _findResult = r); + }; } /// Toggle between the default ephemeral session and a persistent, shared @@ -95,6 +114,39 @@ class _BrowserDemoState extends State { _controller.setZoomLevel(_zoom); } + /// Poke the page to post a message back through the `flutterCef` JS channel + /// registered in [_wireController] — exercises the page->host bridge. + void _pingChannel() => _controller.executeJavaScript( + "window.flutterCef && window.flutterCef.postMessage(" + "'hello from ' + location.host)"); + + void _openFindBar() { + setState(() => _findVisible = true); + _findFocus.requestFocus(); + } + + /// (Re)issue the current find query. An empty query stops the search. + /// [findNext] advances to the next/previous match of the same query. + void _runFind({bool findNext = false, bool forward = true}) { + final q = _findBar.text; + if (q.isEmpty) { + _controller.stopFind(); + setState(() => _findResult = null); + return; + } + _controller.find(q, forward: forward, findNext: findNext); + } + + void _closeFindBar() { + _controller.stopFind(); + _findBar.clear(); + setState(() { + _findVisible = false; + _findResult = null; + }); + _webFocus.requestFocus(); + } + Future _runJs() async { try { final r = await _controller.runJavaScriptReturningResult( @@ -199,6 +251,8 @@ and committed text — including emoji — should appear intact.

void dispose() { _webFocus.dispose(); _urlBar.dispose(); + _findBar.dispose(); + _findFocus.dispose(); _controller.dispose(); super.dispose(); } @@ -245,6 +299,16 @@ 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()', @@ -300,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( @@ -326,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 @@ -344,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/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md b/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md index 5f9976e..4341eee 100644 --- a/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md +++ b/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md @@ -146,8 +146,11 @@ stage-1; the slice instead reuses `kOpPresent 0x01` with the Windows payload Verb names + arg keys verbatim from FlutterCefPlugin.swift:113-241 and cef_web_controller.dart (invokeMethod sites). Every arg map carries -`sessionId` (String). SLICE = must be functional for the vertical slice; -STUB = reply success/null + `OutputDebugString` warning, never an error. +`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 | |---|---|---|---|---|---| @@ -164,25 +167,25 @@ STUB = reply success/null + `OutputDebugString` warning, never an error. | 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 | STUB | Swift:131-133 | -| editCommand | command:int | null | 0x38 | STUB | Swift:134-136 | +| 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 | STUB | Swift:140-148 | -| stopFind | clearSelection:bool | null | 0x28 | STUB | Swift:149-151 | -| respondJsDialog | id:int, ok:bool, text:String | null | 0x29 | STUB | Swift:152-158 | -| evalReturning | id:int, code:String | null | 0x2a | STUB | Swift:159-165 | -| addJavaScriptChannel | name:String | null | 0x2b | STUB | Swift:166-170 | -| setCookie | url, name, value, domain, path : String | null | 0x2c | STUB | Swift:171-179 | -| clearCookies | — | null | 0x2d | STUB | Swift:180-182 | -| visitCookies | id:int, url:String | null | 0x2e | STUB | Swift:183-188 | -| deleteCookie | url:String, name:String | null | 0x2f | STUB | Swift:189-194 | -| showDevTools | — | null | 0x33 | STUB | Swift:195-197 | +| 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, token, port}` or FlutterError | CDP relay (P9) | STUB (null) | Swift:198-217 | | disableAgentControl | — | null | CDP relay (P9) | STUB | Swift:218-225 | | showEmojiPicker | — | null | macOS-only (Character Palette) | STUB | Swift:226-230 | -| imeSetComposition | text:String | null | 0x30 | STUB | Swift:231-233 | -| imeCommitText | text:String | null | 0x31 | STUB | Swift:234-236 | -| imeCancelComposition | — | null | 0x32 | STUB | Swift:237-239 | +| 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 @@ -333,3 +336,143 @@ arrives carrying the same `id` and a JSON array (`CefCookie.fromJson` per elemen 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). 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 index 4e3f497..094efeb 100644 --- a/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc +++ b/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc @@ -46,6 +46,7 @@ #include #include +#include // SHGetKnownFolderPath / FOLDERID_Downloads (downloads) #include #include @@ -75,14 +76,22 @@ #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) @@ -105,6 +114,61 @@ HWND g_hidden_hwnd = nullptr; // HostClient::OnBeforeBrowse exactly like main.mm:335-342/1528-1565. std::set g_allowed_schemes; +// 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); @@ -663,12 +727,22 @@ class HostClient : public CefClient, public CefFindHandler, public CefJSDialogHandler, public CefDownloadHandler, - public CefRequestHandler { + public CefRequestHandler, + public CefMessageRouterBrowserSide::Handler { public: explicit HostClient(std::shared_ptr slot) : slot_(std::move(slot)) { + // Browser-side message router (default config: window.cefQuery / + // cefQueryCancel) — the SAME config the renderer half uses (main.mm: + // 1228-1234). One router per HostClient, i.e. per browser: OnQuery below + // stamps slot_->browser_id so a page message is delivered ONLY to the + // originating session (per-session routing, channel_probe_shared). + CefMessageRouterConfig config; + router_ = CefMessageRouterBrowserSide::Create(config); + router_->AddHandler(this, false); rh_ = new HostRenderHandler(slot_); ph_ = new HostPermissionHandler(); } + CefRefPtr router_; CefRefPtr rh_; CefRefPtr ph_; CefRefPtr GetRenderHandler() override { return rh_; } @@ -683,12 +757,24 @@ class HostClient : public CefClient, CefRefPtr GetDownloadHandler() override { return this; } CefRefPtr GetRequestHandler() override { return this; } - // CefDownloadHandler (main.mm:1251-1257): allow downloads + notify. + // CefDownloadHandler (main.mm:1251-1257): allow downloads + notify the + // plugin. macOS Continues with an empty path + show_dialog so AppKit's + // native save panel picks the location; a hidden-window OSR host on Windows + // has no good save panel, so default to the user's Downloads folder + // (SHGetKnownFolderPath FOLDERID_Downloads) with the suggested name, and + // fall back to the Save-As dialog only if the folder can't be resolved. bool OnBeforeDownload(CefRefPtr, CefRefPtr, const CefString& suggested_name, CefRefPtr callback) override { SendUtf8(slot_->browser_id, kOpDownload, suggested_name.ToString()); - callback->Continue(CefString(), true); + const std::wstring dir = GetDownloadsDir(); + if (!dir.empty()) { + CefString full; + full.FromWString(dir + L"\\" + suggested_name.ToWString()); + callback->Continue(full, /*show_dialog=*/false); + } else { + callback->Continue(CefString(), /*show_dialog=*/true); + } return true; } @@ -741,9 +827,57 @@ class HostClient : public CefClient, const CefString&) override { SendLog(slot_->browser_id, "renderer terminated (status " + std::to_string(status) + ") — reloading"); + if (router_) router_->OnRenderProcessTerminated(browser); if (browser) browser->ReloadIgnoreCache(); } + // CefMessageRouter wiring (main.mm:1468-1501). The renderer half (HostApp + // below) injects window.cefQuery; queries land here. Forward the request to + // the plugin: "eval::" -> kOpEvalResult (a + // runJavaScriptReturningResult result); "ch::" -> + // kOpChannelMsg (a JS-channel post). Both are stamped with slot_->browser_id + // (this HostClient owns exactly one browser), so the message reaches ONLY the + // originating session's Dart channel — the per-session routing boundary + // (channel_probe_shared). 'eval:'/'ch:' are privileged (they hit the trusted + // host eval path / the channel bridge) and the shim is injected per-frame, so + // honor them ONLY from the MAIN frame; refuse a forged subframe query. + bool OnQuery(CefRefPtr, CefRefPtr frame, int64_t, + const CefString& request, bool, + CefRefPtr callback) override { + std::string r = request.ToString(); + const bool main_frame = !frame || frame->IsMain(); + if (r.rfind("eval:", 0) == 0) { + if (!main_frame) { + callback->Failure(403, "subframe"); + return true; + } + SendUtf8(slot_->browser_id, kOpEvalResult, r.substr(5)); + callback->Success(CefString()); + return true; + } + if (r.rfind("ch:", 0) == 0) { + if (!main_frame) { + callback->Failure(403, "subframe"); + return true; + } + SendUtf8(slot_->browser_id, kOpChannelMsg, r.substr(3)); + callback->Success(CefString()); + return true; + } + return false; + } + + // Route renderer->browser process messages through the message router + // (main.mm:1495-1501). This carries the cefQuery payloads that surface in + // OnQuery above. + bool OnProcessMessageReceived(CefRefPtr browser, + CefRefPtr frame, + CefProcessId source_process, + CefRefPtr message) override { + return router_->OnProcessMessageReceived(browser, frame, source_process, + message); + } + // CefLoadHandler: spinner + back/forward enablement. void OnLoadingStateChange(CefRefPtr, bool isLoading, bool canGoBack, bool canGoForward) override { @@ -751,8 +885,13 @@ class HostClient : public CefClient, } void OnLoadStart(CefRefPtr, CefRefPtr frame, TransitionType) override { - if (frame && frame->IsMain()) + if (frame && frame->IsMain()) { SendUtf8(slot_->browser_id, kOpPageStart, frame->GetURL().ToString()); + // SECURITY (main.mm:1339-1343): install the JS-channel shims ONLY into + // the MAIN frame — injecting the privileged window. bridge into a + // cross-origin subframe would hand an untrusted iframe that bridge. + for (const auto& name : g_channels) InjectChannelShim(frame, name); + } } void OnLoadEnd(CefRefPtr browser, CefRefPtr frame, int) override { @@ -863,7 +1002,8 @@ class HostClient : public CefClient, // Centralized per-browser teardown (main.mm:1510-1527): drop the routing // entry, release the bridge under the slot lock (closing set FIRST so a // racing paint can't re-mint), break the retain cycle. - void OnBeforeClose(CefRefPtr) override { + void OnBeforeClose(CefRefPtr browser) override { + if (router_) router_->OnBeforeClose(browser); { std::lock_guard lock(g_slots_mutex); g_slots_by_wire_id.erase(slot_->browser_id); @@ -916,11 +1056,52 @@ class HostClient : public CefClient, // ---- CEF app ---- -class HostApp : public CefApp, public CefBrowserProcessHandler { +// The one CefApp, used for BOTH the browser process (CefInitialize) and every +// re-exec'd sub-process (CefExecuteProcess). CEF calls GetBrowserProcessHandler +// in the browser process and GetRenderProcessHandler in the render process, so +// the SAME binary hosts both halves of CefMessageRouter (main.mm's split across +// main.mm + process_helper.mm collapses here — Windows re-runs cef_host.exe as +// the render subprocess, LAW 8). The renderer half owns a +// CefMessageRouterRendererSide with the DEFAULT config (must match the +// browser-side HostClient config); it injects window.cefQuery into every frame +// and relays cefQuery calls to the browser process. +class HostApp : public CefApp, + public CefBrowserProcessHandler, + public CefRenderProcessHandler { public: CefRefPtr GetBrowserProcessHandler() override { return this; } + CefRefPtr GetRenderProcessHandler() override { + return this; + } + + // ---- Render-process half (process_helper.mm:24-57 counterpart) ---- + // Render-process-only callback; create the renderer-side router here with the + // default config (window.cefQuery / cefQueryCancel). + void OnWebKitInitialized() override { + CefMessageRouterConfig config; + render_router_ = CefMessageRouterRendererSide::Create(config); + } + void OnContextCreated(CefRefPtr browser, CefRefPtr frame, + CefRefPtr context) override { + if (render_router_) render_router_->OnContextCreated(browser, frame, + context); + } + void OnContextReleased(CefRefPtr browser, + CefRefPtr frame, + CefRefPtr context) override { + if (render_router_) + render_router_->OnContextReleased(browser, frame, context); + } + bool OnProcessMessageReceived(CefRefPtr browser, + CefRefPtr frame, + CefProcessId source_process, + CefRefPtr message) override { + return render_router_ && + render_router_->OnProcessMessageReceived(browser, frame, + source_process, message); + } void OnBeforeCommandLineProcessing( const CefString& process_type, @@ -959,6 +1140,11 @@ class HostApp : public CefApp, public CefBrowserProcessHandler { sizeof(ready_payload)); } + private: + // Renderer-side message router (render process only; created in + // OnWebKitInitialized). Null in the browser process. + CefRefPtr render_router_; + IMPLEMENT_REFCOUNTING(HostApp); }; @@ -1181,8 +1367,54 @@ void DoJsDialogResp(const std::shared_ptr& slot, uint32_t id, bool ok, const std::string& text) { auto it = slot->dialogs.find(id); if (it == slot->dialogs.end()) return; - it->second->Continue(ok, text); + // On Windows, CefJSDialogCallback::Continue() synchronously re-enters + // OnResetDialogState(), which clears slot->dialogs — so a Continue()-then- + // erase(it) (as macOS does, where the reset is async) would erase() through an + // invalidated iterator and crash the host. Take the ref and drop our map entry + // BEFORE Continue(); the callback stays alive in `cb`. No wire-observable + // difference from macOS — this only fixes the iterator lifetime on Windows. + CefRefPtr cb = it->second; slot->dialogs.erase(it); + cb->Continue(ok, text); +} + +// runJavaScriptReturningResult (main.mm DoEvalReturning:2001-2018, verbatim). +// Evaluate the user expression and post its JSON result back through +// window.cefQuery (-> HostClient::OnQuery -> kOpEvalResult "id:json", +// correlated to the Dart Future). `code` is the trusted host's JS (same trust +// level as executeJavaScript) and must be a single expression. It is spliced +// (not eval()'d) so it works under a strict page CSP; the Dart side fails any +// pending result on navigation so a wedged callback can't leak a completer. +void DoEvalReturning(const std::shared_ptr& slot, uint32_t id, + const std::string& code) { + if (!slot->browser) return; + CefRefPtr frame = slot->browser->GetMainFrame(); + if (!frame) return; + std::string js = + "window.cefQuery({request:'eval:" + std::to_string(id) + + ":'+(function(){try{return JSON.stringify({ok:true,v:(" + code + + "\n)});}catch(e){return JSON.stringify({ok:false,v:String(e)});}})()," + "persistent:false,onSuccess:function(){},onFailure:function(){}});"; + frame->ExecuteJavaScript(js, "", 0); +} + +// Register a JS channel (main.mm DoAddChannel:2019-2035, verbatim). Registers +// process-globally: OnLoadStart injects every g_channels entry into each +// freshly-loaded MAIN frame, so the shim lands on the next load. Null-safe on +// `slot` (a shared host may still be queuing this session's create). Also +// injects into the registering session's CURRENT frame, covering registration +// after the page already loaded. +void DoAddChannel(const std::shared_ptr& slot, const std::string& name) { + if (!IsValidChannelName(name)) { + if (slot) + SendLog(slot->browser_id, + "addJavaScriptChannel: rejected invalid name '" + name + + "' (must be a JS identifier)"); + return; + } + g_channels.insert(name); + if (slot && slot->browser) + InjectChannelShim(slot->browser->GetMainFrame(), name); } // ---- Cookies (global manager = the shared profile jar; main.mm:2040-2140) ---- @@ -1663,35 +1895,31 @@ void IpcReadLoop() { break; } case kOpEvalReturning: { - // #14: runJavaScriptReturningResult AWAITS the evalResult event, so a - // silent drop hangs the Dart Future forever. The renderer-side - // CefMessageRouter half that would produce a real result is post-slice — - // until it lands, synthesize an ERROR evalResult so the Future completes - // (with an error) instead of hanging. Wire shape per PROTOCOL.md - // kOpEvalResult: {utf8 "id:json"}, json = {ok,v} (cef_web_controller - // .dart:295-313). {u32 id}{utf8 code} inbound. + // runJavaScriptReturningResult (main.mm:2475-2482). {u32 id}{utf8 code} + // inbound; DoEvalReturning posts the value back via the message router + // as kOpEvalResult "id:json", correlated to the Dart Future. + if (!slot) break; if (plen < 4) break; uint32_t id = ReadU32BE(p); - std::string reply = - std::to_string(id) + - ":{\"ok\":false,\"v\":\"evalReturning not implemented on Windows " - "yet\"}"; - SendUtf8(wire_id, kOpEvalResult, reply); - static bool logged_eval = false; - if (!logged_eval) { - logged_eval = true; - SendLog(0, "cef_host: evalReturning not implemented in the Windows " - "slice — replying error evalResult (Future completes)"); - } + std::string code(reinterpret_cast(p + 4), plen - 4); + CefPostTask(TID_UI, base::BindOnce(&DoEvalReturning, slot, id, code)); + break; + } + case kOpAddChannel: { + // Do NOT require `slot` (main.mm:2483-2494): on a shared host a + // session's create may still be queued when this arrives; dropping it + // is exactly why a peer session's window. shim was never + // injected. DoAddChannel registers the name process-globally + // (OnLoadStart injects it on the next load) and, if the browser already + // exists, into its current frame. + std::string name(reinterpret_cast(p), plen); + CefPostTask(TID_UI, base::BindOnce(&DoAddChannel, slot, name)); break; } - case kOpAddChannel: case kOpResolveTargetId: { - // Post-slice: these need the renderer-side CefMessageRouter half / - // the DevTools observer (macOS process_helper.mm counterpart). Neither - // can hang a Dart Future (addJavaScriptChannel resolves via the method- - // channel reply; resolveTargetId is fire-and-forget), so a logged drop - // is safe. Log ONCE per opcode; never kill the stream. + // Post-slice: needs the DevTools observer (macOS CEF-2b path). It can't + // hang a Dart Future (fire-and-forget), so a logged drop is safe. Log + // ONCE per opcode; never kill the stream. static bool logged_stub[256] = {false}; if (!logged_stub[opcode]) { logged_stub[opcode] = true; From fd1b744efbd91024493ef925b5ca26cf5c7ec978 Mon Sep 17 00:00:00 2001 From: wenkai fan Date: Tue, 21 Jul 2026 12:29:46 -0400 Subject: [PATCH 6/9] feat(windows): agent-control CDP relay, single-tile (P9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external CDP client (Playwright connectOverCDP / agent-browser) can drive a live, logged-in tile with NO open debug port. Chromium speaks CDP over an inherited pipe; a token-gated loopback relay bridges a standard client to it. - windows/cdp_relay.{h,cpp}: winsock loopback HTTP+WebSocket relay (port of CdpRelay.swift's POSIX-socket design). 127.0.0.1:0 ephemeral bind, RFC-6455 upgrade with SHA-1 Sec-WebSocket-Accept (BCrypt), MANDATORY bearer token (Authorization: Bearer / ?token= fallback, constant-time compare), 24-byte BCryptGenRandom token, token-free /json* discovery, single-client, masked frame codec + ping/pong/close, 64 MiB caps. scope_target_id seam left for the deferred CEF-2b per-tile filter. - host_process: Spawn(agent_control) uses the S3-proven recipe (CreatePipe x2 inheritable + STARTUPINFOEX PROC_THREAD_ATTRIBUTE_HANDLE_LIST + CreateProcessW, --cdp-io-pipes=,); the non-agent path stays byte-identical. - cef_host_win.cc: parse --cdp-io-pipes, inject --remote-debugging-pipe + --remote-debugging-io-pipes in OnBeforeCommandLineProcessing (browser only). - plugin: CdpTransport (parent pipe ends + relay), CdpReadLoop, enable/ disableAgentControl verbs, reaper teardown; CMakeLists links ws2_32 + bcrypt. Verified independently on-machine (example/lib/agentcontrol_probe.dart, my own run): enableAgentControl -> {wsUrl, token, port}; a real loopback WebSocket CDP client gets 401 WITHOUT the token, 101 WITH it, then Target.getTargets -> attachToTarget -> Runtime.evaluate("1+1") == 2; disableAgentControl refuses a subsequent connect. Proves the S3 pipe recipe composes with the bootstrap (cef_host.exe = renamed bootstrapc.exe) while the IPC named-pipe / job-object path renders the tile normally. macOS zero-diff; analyze clean; 164/164 tests. (Client was the probe's in-process dart:io WS client over real TCP loopback — the relay can't distinguish it from an external Playwright client; a real connectOverCDP is the natural next confirmation.) Deferred (documented, PROTOCOL.md §8.4): the N-tile Target-domain multiplex (deny-by-default flatten-only filter, per-relay CDP-id rewrite, targetId resolution) — single-tile uses raw browser-level passthrough. enableCdp (TCP) stays unimplemented on Windows (cdpPort 0; the Dart assert still blocks enableCdp + named profile). Co-Authored-By: Claude Fable 5 --- example/lib/agentcontrol_probe.dart | 304 +++++++++ .../native/cef_host/PROTOCOL.md | 100 ++- .../native/cef_host/cef_host_win.cc | 26 +- .../windows/CMakeLists.txt | 6 + .../flutter_cef_windows/windows/cdp_relay.cpp | 604 ++++++++++++++++++ .../flutter_cef_windows/windows/cdp_relay.h | 140 ++++ .../windows/flutter_cef_plugin.cpp | 180 +++++- .../windows/flutter_cef_plugin.h | 48 +- .../windows/host_process.cpp | 93 ++- .../windows/host_process.h | 20 +- 10 files changed, 1498 insertions(+), 23 deletions(-) create mode 100644 example/lib/agentcontrol_probe.dart create mode 100644 packages/flutter_cef_windows/windows/cdp_relay.cpp create mode 100644 packages/flutter_cef_windows/windows/cdp_relay.h 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>().firstWhere( + (t) => t['type'] == 'page', + orElse: () => {}, + ); + final targetId = page['targetId'] as String?; + if (targetId == null) { + _note('FAIL — no page target in getTargets'); + await ws.close(); + _write(out); + return; + } + _note('page targetId=$targetId'); + + final attach = await cdp.send('Target.attachToTarget', + params: {'targetId': targetId, 'flatten': true}); + final sessionId = attach['result']?['sessionId'] as String?; + if (sessionId == null) { + _note('FAIL — no sessionId from attachToTarget'); + await ws.close(); + _write(out); + return; + } + _note('attached sessionId=$sessionId'); + + final eval = await cdp.send('Runtime.evaluate', + params: {'expression': '1+1'}, sessionId: sessionId); + final value = eval['result']?['result']?['value']; + final pass = value == 2; + out['gate_evaluate_2'] = pass; + out['evaluate_value'] = value; + _note('Runtime.evaluate("1+1") -> value=$value ${pass ? 'PASS' : 'FAIL'}'); + await ws.close(); + + // ---- GATE 3: disableAgentControl tears down the port ---- + await _c.disableAgentControl(); + _note('disableAgentControl() called'); + // The port bounce can lag a hair; poll briefly for it to be gone. + bool gone = false; + for (var i = 0; i < 20 && !gone; i++) { + try { + final ws2 = await WebSocket.connect( + base, + headers: {'Authorization': 'Bearer ${ep.token}'}, + ).timeout(const Duration(seconds: 1)); + await ws2.close(); + await Future.delayed(const Duration(milliseconds: 200)); + } catch (_) { + gone = true; + } + } + out['gate_teardown'] = gone; + _note('GATE teardown ${gone ? 'PASS' : 'FAIL'} — ' + 'post-disable connect ${gone ? 'refused' : 'still succeeded'}'); + } catch (e, st) { + out['error'] = '$e'; + _note('EXCEPTION $e\n$st'); + } + _write(out); + } + + /// Send a raw RFC-6455 upgrade request (optionally with a bearer token) and + /// return the HTTP status line the relay replies with — "HTTP/1.1 401 + /// Unauthorized" without a valid token, "HTTP/1.1 101 Switching Protocols" + /// with one. Uses a fixed valid 16-byte Sec-WebSocket-Key. + Future _rawUpgradeStatusLine(int port, {String? token}) async { + Socket? sock; + try { + sock = await Socket.connect('127.0.0.1', port, + timeout: const Duration(seconds: 3)); + final req = StringBuffer() + ..write('GET /devtools/browser HTTP/1.1\r\n') + ..write('Host: 127.0.0.1:$port\r\n') + ..write('Upgrade: websocket\r\n') + ..write('Connection: Upgrade\r\n') + ..write('Sec-WebSocket-Version: 13\r\n') + ..write('Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n'); + if (token != null) req.write('Authorization: Bearer $token\r\n'); + req.write('\r\n'); + sock.add(utf8.encode(req.toString())); + final bytes = []; + await for (final chunk in sock.timeout(const Duration(seconds: 3))) { + bytes.addAll(chunk); + if (String.fromCharCodes(bytes).contains('\r\n')) break; + } + final text = String.fromCharCodes(bytes); + return text.split('\r\n').first.trim(); + } catch (e) { + return 'ERROR: $e'; + } finally { + sock?.destroy(); + } + } + + void _write(Map out) { + final pass = out['enable_ok'] == true && + out['gate_401'] == true && + out['gate_evaluate_2'] == true && + out['gate_teardown'] == true; + out['PASS'] = pass; + try { + Directory(r'C:\tmp').createSync(recursive: true); + File(_resultPath).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(out)); + } catch (_) {} + // ignore: avoid_print + print('CEF_AGENTCONTROL_RESULT ${jsonEncode({ + 'PASS': pass, + 'enable_ok': out['enable_ok'], + 'gate_401': out['gate_401'], + 'gate_evaluate_2': out['gate_evaluate_2'], + 'gate_teardown': out['gate_teardown'], + 'evaluate_value': out['evaluate_value'], + })}'); + if (mounted) { + setState(() => _status = pass ? 'ALL GATES PASS' : 'SOME GATES FAILED'); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text('agent-control probe — $_status', + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: CefWebView( + url: 'about:blank', + controller: _c, + agentControl: true, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Minimal CDP request/response multiplexer over one WebSocket: sends a command +/// with an auto-incremented id and completes on the response with a matching id +/// (events, which carry no id, are ignored). +class _CdpConn { + _CdpConn(this._ws) { + _ws.listen((data) { + try { + final msg = jsonDecode(data as String) as Map; + final id = msg['id']; + if (id is int) { + _pending.remove(id)?.complete(msg); + } + } catch (_) {} + }, onError: (_) {}, onDone: () {}); + } + + final WebSocket _ws; + int _nextId = 0; + final Map>> _pending = {}; + + Future> send(String method, + {Map? params, String? sessionId}) { + final id = ++_nextId; + final c = Completer>(); + _pending[id] = c; + final m = {'id': id, 'method': method}; + if (params != null) m['params'] = params; + if (sessionId != null) m['sessionId'] = sessionId; + _ws.add(jsonEncode(m)); + return c.future.timeout(const Duration(seconds: 10), onTimeout: () { + _pending.remove(id); + throw TimeoutException('CDP $method timed out'); + }); + } +} diff --git a/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md b/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md index 4341eee..f11ff8e 100644 --- a/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md +++ b/packages/flutter_cef_windows/native/cef_host/PROTOCOL.md @@ -180,8 +180,8 @@ reply success/null + `OutputDebugString` warning, never an error. | 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, token, port}` or FlutterError | CDP relay (P9) | STUB (null) | Swift:198-217 | -| disableAgentControl | — | null | CDP relay (P9) | STUB | Swift:218-225 | +| 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 | @@ -237,9 +237,11 @@ thread (marshal from the reader thread). `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 (slice): `--ipc=` `--profile-dir=` - `--ephemeral` (cf. macOS args main.mm:32-38; `--cdp-port`/`--cdp-pipe`/ - `--allowed-schemes` are post-slice). +- 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) @@ -476,3 +478,91 @@ Page → host. The shim is injected NATIVELY (there is no Dart-injected shim): 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/cef_host_win.cc b/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc index 094efeb..1608148 100644 --- a/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc +++ b/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc @@ -114,6 +114,15 @@ HWND g_hidden_hwnd = nullptr; // 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 @@ -1123,8 +1132,17 @@ class HostApp : public CefApp, "log-file", std::string(tmp) + "cef_host_chromium.log"); command_line->AppendSwitchWithValue("v", "1"); } - // CDP pipe translation (S3) + disable-blink-features=AutomationControlled - // land here post-slice (main.mm:1640-1662). + // Agent-control CDP-over-pipe translation (S3, P9): the plugin passed the + // two inherited pipe HANDLE values as --cdp-io-pipes=,; turn + // them into Chromium's real switches. Browser process only (process_type + // empty) — the renderer/GPU children never get the debugging pipe (and never + // inherited the handles). Mirrors macOS main.mm's --cdp-pipe injection. + // (disable-blink-features=AutomationControlled is post-slice.) + if (process_type.empty() && !g_cdp_io_pipes.empty()) { + command_line->AppendSwitch("remote-debugging-pipe"); + command_line->AppendSwitchWithValue("remote-debugging-io-pipes", + g_cdp_io_pipes); + } } // Announce readiness. Payload = {readyFlags, protocolVersion} @@ -1984,6 +2002,10 @@ extern "C" CEF_BOOTSTRAP_EXPORT int RunConsoleMain( std::string profile_dir = GetSwitch(argc, argv, "--profile-dir="); std::string allowed = GetSwitch(argc, argv, "--allowed-schemes="); bool ephemeral = HasFlag(argc, argv, "--ephemeral"); + // Agent control (P9): "," inherited-HANDLE values. Stored in the + // file-global so OnBeforeCommandLineProcessing (called from CefInitialize + // below) can inject the Chromium CDP-pipe switches. Empty when off. + g_cdp_io_pipes = GetSwitch(argc, argv, "--cdp-io-pipes="); // NB: the PLUGIN owns ephemeral profile-dir deletion — its reaper deletes the // dir once this host is confirmed dead, and a startup sweep reclaims dirs // orphaned by a crash (FlutterCefPlugin.cpp TeardownSession reaper + diff --git a/packages/flutter_cef_windows/windows/CMakeLists.txt b/packages/flutter_cef_windows/windows/CMakeLists.txt index c103278..9b14f6e 100644 --- a/packages/flutter_cef_windows/windows/CMakeLists.txt +++ b/packages/flutter_cef_windows/windows/CMakeLists.txt @@ -33,6 +33,8 @@ list(APPEND PLUGIN_SOURCES "host_process.h" "ipc_pipe.cpp" "ipc_pipe.h" + "cdp_relay.cpp" + "cdp_relay.h" ) add_library(${PLUGIN_NAME} SHARED @@ -58,6 +60,10 @@ target_link_libraries(${PLUGIN_NAME} PRIVATE flutter_wrapper_plugin d3d11 dxgi + # Agent-control CDP relay (P9): winsock (loopback HTTP+WS server) + bcrypt + # (SHA-1 Sec-WebSocket-Accept + BCryptGenRandom token). cdp_relay.cpp. + ws2_32 + bcrypt ) # ---- cef_host: build + stage (LAW 8 ship pair: cef_host.dll + renamed diff --git a/packages/flutter_cef_windows/windows/cdp_relay.cpp b/packages/flutter_cef_windows/windows/cdp_relay.cpp new file mode 100644 index 0000000..3c8d09e --- /dev/null +++ b/packages/flutter_cef_windows/windows/cdp_relay.cpp @@ -0,0 +1,604 @@ +#include "cdp_relay.h" + +#include +#include + +#include +#include +#include +#include + +// winsock + bcrypt (SHA-1 accept, CSPRNG token). ws2_32 is also linked via +// CMakeLists; the pragma keeps this TU self-documenting. +#pragma comment(lib, "ws2_32.lib") +#pragma comment(lib, "bcrypt.lib") + +namespace flutter_cef { + +namespace { + +// Diagnostics are gated behind FLUTTER_CEF_DEBUG (mirrors CdpRelay.swift +// dlog): a release build stays quiet and never logs the port or per-frame, +// peer-controlled protocol errors. Win32 env read (the plugin bans the CRT +// getenv, which is C4996/treated-as-error here). +bool DebugEnabled() { + wchar_t buf[8] = {}; + return GetEnvironmentVariableW(L"FLUTTER_CEF_DEBUG", buf, 8) > 0; +} +void DLog(const char* fmt, ...) { + static const bool enabled = DebugEnabled(); + if (!enabled) return; + char buf[512]; + va_list ap; + va_start(ap, fmt); + _vsnprintf_s(buf, _TRUNCATE, fmt, ap); + va_end(ap); + fprintf(stderr, "[cef][relay] %s\n", buf); + fflush(stderr); +} + +// Hash `data` with SHA-1 (BCrypt); writes 20 bytes into `out`. False on any +// CNG failure. +bool Sha1(const uint8_t* data, size_t len, uint8_t out[20]) { + BCRYPT_ALG_HANDLE alg = nullptr; + if (BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA1_ALGORITHM, nullptr, 0) != 0) + return false; + BCRYPT_HASH_HANDLE hash = nullptr; + bool ok = false; + if (BCryptCreateHash(alg, &hash, nullptr, 0, nullptr, 0, 0) == 0) { + if (BCryptHashData(hash, const_cast(data), + static_cast(len), 0) == 0 && + BCryptFinishHash(hash, out, 20, 0) == 0) { + ok = true; + } + BCryptDestroyHash(hash); + } + BCryptCloseAlgorithmProvider(alg, 0); + return ok; +} + +// Standard base64 of `n` bytes. +std::string Base64(const uint8_t* p, size_t n) { + static const char* tbl = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((n + 2) / 3) * 4); + size_t i = 0; + for (; i + 3 <= n; i += 3) { + uint32_t v = (uint32_t(p[i]) << 16) | (uint32_t(p[i + 1]) << 8) | p[i + 2]; + out.push_back(tbl[(v >> 18) & 0x3f]); + out.push_back(tbl[(v >> 12) & 0x3f]); + out.push_back(tbl[(v >> 6) & 0x3f]); + out.push_back(tbl[v & 0x3f]); + } + if (n - i == 1) { + uint32_t v = uint32_t(p[i]) << 16; + out.push_back(tbl[(v >> 18) & 0x3f]); + out.push_back(tbl[(v >> 12) & 0x3f]); + out.push_back('='); + out.push_back('='); + } else if (n - i == 2) { + uint32_t v = (uint32_t(p[i]) << 16) | (uint32_t(p[i + 1]) << 8); + out.push_back(tbl[(v >> 18) & 0x3f]); + out.push_back(tbl[(v >> 12) & 0x3f]); + out.push_back(tbl[(v >> 6) & 0x3f]); + out.push_back('='); + } + return out; +} + +// CSPRNG token: 24 random bytes hex-encoded (48 chars), matching +// CdpRelay.swift randomToken(). Fails closed to an unguessable-but-unusable +// value if CNG somehow fails (Start() success is the gate; a bad token just +// means no client can connect). +std::string RandomToken() { + uint8_t bytes[24] = {}; + if (BCryptGenRandom(nullptr, bytes, sizeof(bytes), + BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + // Never expected; keep the buffer zeroed (still 48 hex chars, unusable). + } + static const char* hex = "0123456789abcdef"; + std::string s; + s.reserve(48); + for (uint8_t b : bytes) { + s.push_back(hex[b >> 4]); + s.push_back(hex[b & 0xf]); + } + return s; +} + +bool ConstantTimeEquals(const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + uint8_t diff = 0; + for (size_t i = 0; i < a.size(); ++i) + diff |= static_cast(a[i]) ^ static_cast(b[i]); + return diff == 0; +} + +std::string ToLower(std::string s) { + for (char& c : s) + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + return s; +} + +std::string Trim(const std::string& s) { + size_t a = s.find_first_not_of(" \t"); + if (a == std::string::npos) return std::string(); + size_t b = s.find_last_not_of(" \t"); + return s.substr(a, b - a + 1); +} + +} // namespace + +CdpRelay::CdpRelay(SendToPipe send_to_pipe, std::string scope_target_id) + : send_to_pipe_(std::move(send_to_pipe)), + scope_target_id_(std::move(scope_target_id)), + token_(RandomToken()) {} + +CdpRelay::~CdpRelay() { + Stop(); + if (wsa_started_) WSACleanup(); +} + +// MARK: Lifecycle + +bool CdpRelay::Start() { + WSADATA wsa = {}; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { + DLog("WSAStartup failed"); + return false; + } + wsa_started_ = true; + + SOCKET fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (fd == INVALID_SOCKET) { + DLog("socket() failed"); + return false; + } + BOOL on = TRUE; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&on), + sizeof(on)); + + sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = 0; // OS picks an ephemeral port + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1 — loopback only + if (bind(fd, reinterpret_cast(&addr), sizeof(addr)) != 0 || + listen(fd, 4) != 0) { + DLog("bind/listen failed (wsa=%d)", WSAGetLastError()); + closesocket(fd); + return false; + } + + sockaddr_in bound = {}; + int len = sizeof(bound); + if (getsockname(fd, reinterpret_cast(&bound), &len) != 0) { + DLog("getsockname failed"); + closesocket(fd); + return false; + } + port_ = ntohs(bound.sin_port); + + listen_sock_ = fd; + running_.store(true); + std::thread([self = shared_from_this()]() { self->AcceptLoop(); }).detach(); + DLog("listening on 127.0.0.1:%u", port_); + return true; +} + +void CdpRelay::Stop() { + running_.store(false); + SOCKET lfd = listen_sock_; + listen_sock_ = INVALID_SOCKET; + if (lfd != INVALID_SOCKET) { + shutdown(lfd, SD_BOTH); + closesocket(lfd); // wakes accept() + } + std::lock_guard lock(client_lock_); + if (client_sock_ != INVALID_SOCKET) { + shutdown(client_sock_, SD_BOTH); // wakes the handler's blocked recv() + closesocket(client_sock_); + client_sock_ = INVALID_SOCKET; + } +} + +// MARK: Accept + +void CdpRelay::AcceptLoop() { + while (running_.load()) { + SOCKET fd = accept(listen_sock_, nullptr, nullptr); + if (fd == INVALID_SOCKET) { + if (running_.load()) continue; + break; + } + // Detached handler thread; the shared_from_this() keeps the relay alive for + // the duration of the connection even across a concurrent Stop(). + std::thread([self = shared_from_this(), fd]() { + self->HandleConnection(fd); + }).detach(); + } +} + +// MARK: HTTP / handshake + +void CdpRelay::HandleConnection(SOCKET fd) { + // Read timeout for the HANDSHAKE only (slowloris backstop). Cleared after a + // successful upgrade — the ws frame loop idles between agent commands. + DWORD rcv = 10000; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&rcv), + sizeof(rcv)); + DWORD snd = 2000; // a stalled client must not wedge DeliverToClient/stop + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&snd), + sizeof(snd)); + + std::string method, target; + std::map headers; + if (!ReadRequestHead(fd, &method, &target, &headers)) { + closesocket(fd); + return; + } + std::string path = target.substr(0, target.find('?')); + // Normalize a trailing slash: Playwright fetches `GET /json/version/`. + if (path.size() > 1 && path.back() == '/') path.pop_back(); + + // CDP discovery (token-free): the client GETs this to find the ws-url. + if (method == "GET" && + (path == "/json/version" || path == "/json" || path == "/json/list")) { + ServeDiscovery(fd, path); + closesocket(fd); + return; + } + + // WebSocket upgrade — the only authenticated path. + auto up = headers.find("upgrade"); + const bool is_upgrade = + up != headers.end() && ToLower(up->second).find("websocket") != + std::string::npos; + auto key_it = headers.find("sec-websocket-key"); + if (!is_upgrade || key_it == headers.end() || key_it->second.empty()) { + WriteRaw(fd, + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: " + "close\r\n\r\n"); + closesocket(fd); + return; + } + if (!TokenAcceptable(target, headers)) { + DLog("ws upgrade rejected: token absent or invalid"); + WriteRaw(fd, + "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: " + "close\r\n\r\n"); + closesocket(fd); + return; + } + + // One active client per relay: reject a second concurrent upgrade (503). + { + std::unique_lock lock(client_lock_); + if (client_sock_ != INVALID_SOCKET) { + lock.unlock(); + WriteRaw(fd, + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: " + "0\r\nConnection: close\r\n\r\n"); + closesocket(fd); + return; + } + client_sock_ = fd; + } + + // Sec-WebSocket-Accept = base64(SHA1(key + GUID)). + std::string accept_src = key_it->second + kWsGuid; + uint8_t digest[20] = {}; + std::string accept_key; + if (Sha1(reinterpret_cast(accept_src.data()), + accept_src.size(), digest)) { + accept_key = Base64(digest, sizeof(digest)); + } + WriteRaw(fd, + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" + "Connection: Upgrade\r\nSec-WebSocket-Accept: " + + accept_key + "\r\n\r\n"); + DLog("client attached"); + + // Clear the handshake read timeout: the ws connection idles between commands. + DWORD zero = 0; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&zero), + sizeof(zero)); + FrameLoop(fd); + + // Whoever clears client_sock_ from `fd` owns the close: if Stop() already took + // the slot (client_sock_ != fd) it closed the fd, so closing here too would be + // a double-close on a possibly-reused socket. + bool owned = false; + { + std::lock_guard lock(client_lock_); + if (client_sock_ == fd) { + client_sock_ = INVALID_SOCKET; + owned = true; + } + } + if (owned) closesocket(fd); + DLog("client detached"); +} + +void CdpRelay::ServeDiscovery(SOCKET fd, const std::string& path) { + // Token-free ws-url. The token is NOT advertised here — the broker presents + // it as an Authorization: Bearer header on the upgrade, so a local + // port-scanner that reads this url still can't connect. + char ws[64]; + _snprintf_s(ws, _TRUNCATE, "ws://127.0.0.1:%u/devtools/browser", port_); + std::string body; + if (path == "/json/list") { + body = std::string("[{\"type\":\"page\",\"webSocketDebuggerUrl\":\"") + ws + + "\"}]"; + } else { + body = std::string( + "{\"Browser\":\"flutter_cef\",\"Protocol-Version\":\"1.3\"," + "\"webSocketDebuggerUrl\":\"") + + ws + "\"}"; + } + char head[160]; + _snprintf_s(head, _TRUNCATE, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + "Content-Length: %zu\r\nConnection: close\r\n\r\n", + body.size()); + WriteRaw(fd, std::string(head) + body); +} + +// True iff the connection presents the correct token (now REQUIRED — an absent +// OR wrong token is rejected). Preferred: Authorization: Bearer ; +// fallback: ?token= in the request target. Constant-time compared. +bool CdpRelay::TokenAcceptable( + const std::string& target, + const std::map& headers) { + std::string supplied; + bool have = false; + auto auth = headers.find("authorization"); + if (auth != headers.end()) { + const std::string& v = auth->second; + size_t sp = v.find(' '); + if (sp != std::string::npos && ToLower(v.substr(0, sp)) == "bearer") { + supplied = Trim(v.substr(sp + 1)); + have = true; + } + } + if (!have) { + size_t q = target.find('?'); + if (q != std::string::npos) { + std::string query = target.substr(q + 1); + size_t start = 0; + while (start <= query.size()) { + size_t amp = query.find('&', start); + std::string pair = query.substr( + start, amp == std::string::npos ? std::string::npos : amp - start); + size_t eq = pair.find('='); + std::string k = pair.substr(0, eq); + if (k == "token") { + supplied = eq == std::string::npos ? std::string() + : pair.substr(eq + 1); + have = true; + break; + } + if (amp == std::string::npos) break; + start = amp + 1; + } + } + } + if (!have || supplied.empty()) return false; // absent -> REJECT + return ConstantTimeEquals(supplied, token_); +} + +// Read the HTTP request head (request line + headers) up to CRLFCRLF. Bounded +// to 16 KiB; a truncated head is rejected (never parsed as complete). +bool CdpRelay::ReadRequestHead( + SOCKET fd, std::string* method, std::string* target, + std::map* headers) { + std::string acc; + char c = 0; + while (acc.size() < (16u << 10)) { + int n = recv(fd, &c, 1, 0); + if (n <= 0) return false; + acc.push_back(c); + size_t s = acc.size(); + if (s >= 4 && acc[s - 4] == '\r' && acc[s - 3] == '\n' && + acc[s - 2] == '\r' && acc[s - 1] == '\n') + break; + } + size_t s = acc.size(); + if (!(s >= 4 && acc[s - 4] == '\r' && acc[s - 3] == '\n' && + acc[s - 2] == '\r' && acc[s - 1] == '\n')) + return false; + + // Split into CRLF lines. + std::vector lines; + size_t start = 0; + while (start < acc.size()) { + size_t nl = acc.find("\r\n", start); + if (nl == std::string::npos) break; + lines.push_back(acc.substr(start, nl - start)); + start = nl + 2; + } + if (lines.empty()) return false; + // Request line: METHOD SP TARGET SP VERSION. + const std::string& req = lines[0]; + size_t sp1 = req.find(' '); + if (sp1 == std::string::npos) return false; + size_t sp2 = req.find(' ', sp1 + 1); + *method = req.substr(0, sp1); + *target = req.substr(sp1 + 1, + sp2 == std::string::npos ? std::string::npos + : sp2 - sp1 - 1); + for (size_t i = 1; i < lines.size(); ++i) { + const std::string& line = lines[i]; + if (line.empty()) continue; + size_t colon = line.find(':'); + if (colon == std::string::npos) continue; + std::string k = ToLower(Trim(line.substr(0, colon))); + std::string v = Trim(line.substr(colon + 1)); + (*headers)[k] = v; + } + return true; +} + +// MARK: WebSocket framing (RFC 6455, server side) + +void CdpRelay::FrameLoop(SOCKET fd) { + std::vector msg; // accumulated payload of the current message + bool assembling = false; // a fragmented data message is in progress + bool assembling_text = false; // ...and it's text (binary payload dropped) + while (running_.load()) { + uint8_t h[2]; + if (!ReadN(fd, h, 2)) return; + const bool fin = (h[0] & 0x80) != 0; + const uint8_t opcode = h[0] & 0x0f; + const bool masked = (h[1] & 0x80) != 0; + uint64_t len = h[1] & 0x7f; + if (len == 126) { + uint8_t e[2]; + if (!ReadN(fd, e, 2)) return; + len = (uint64_t(e[0]) << 8) | e[1]; + } else if (len == 127) { + uint8_t e[8]; + if (!ReadN(fd, e, 8)) return; + len = 0; + for (int i = 0; i < 8; ++i) len = (len << 8) | e[i]; + } + if (len > kMaxFrame) { + DLog("frame too large"); + return; + } + // RFC 6455 §5.5: control frames are <=125 bytes and never fragmented. + if (opcode == 0x8 || opcode == 0x9 || opcode == 0xA) { + if (len > 125 || !fin) { + DLog("bad control frame"); + return; + } + } + // Client->server frames MUST be masked (RFC 6455 §5.1). + if (!masked) return; + uint8_t mask[4]; + if (!ReadN(fd, mask, 4)) return; + std::vector payload(static_cast(len)); + if (len > 0 && !ReadN(fd, payload.data(), static_cast(len))) return; + for (size_t i = 0; i < payload.size(); ++i) payload[i] ^= mask[i % 4]; + + switch (opcode) { + case 0x0: + case 0x1: + case 0x2: { + if (opcode == 0x0) { + if (!assembling) { + DLog("continuation with no message"); + return; + } + } else { + if (assembling) { + DLog("new data frame mid-message"); + return; + } + assembling_text = (opcode == 0x1); // 0x2 binary: payload dropped + } + if (assembling_text) { + if (msg.size() + payload.size() > kMaxFrame) { + DLog("message too large"); + return; + } + msg.insert(msg.end(), payload.begin(), payload.end()); + } + assembling = !fin; + if (fin) { + if (assembling_text) { + // Single-tile passthrough: forward the CDP message verbatim. (The + // CEF-2b filterClientToPipe + rewriteOutgoingId hooks would go here + // for the N-tile follow-up — see the header comment.) + send_to_pipe_(std::string(msg.begin(), msg.end())); + } + msg.clear(); + assembling_text = false; + } + break; + } + case 0x8: { // close + std::lock_guard lock(client_lock_); + WriteFrameLocked(fd, 0x8, {}); + return; + } + case 0x9: { // ping -> pong + std::lock_guard lock(client_lock_); + WriteFrameLocked(fd, 0xA, payload); + break; + } + case 0xA: // pong — ignore + break; + default: + DLog("unknown opcode %u", opcode); + return; + } + } +} + +void CdpRelay::DeliverToClient(const std::string& json) { + // Single-tile passthrough: deliver verbatim. (The CEF-2b demux + filter + // seam — demuxPipeToClient/filterPipeToClient — would gate this for N-tile.) + std::vector payload(json.begin(), json.end()); + std::lock_guard lock(client_lock_); + if (client_sock_ == INVALID_SOCKET) return; + WriteFrameLocked(client_sock_, 0x1, payload); +} + +// Write a server frame (unmasked). Caller holds client_lock_. +void CdpRelay::WriteFrameLocked(SOCKET fd, uint8_t opcode, + const std::vector& payload) { + std::vector frame; + frame.push_back(0x80 | opcode); // FIN + opcode + const size_t n = payload.size(); + if (n < 126) { + frame.push_back(static_cast(n)); + } else if (n <= 0xFFFF) { + frame.push_back(126); + frame.push_back(static_cast((n >> 8) & 0xff)); + frame.push_back(static_cast(n & 0xff)); + } else { + frame.push_back(127); + for (int s = 56; s >= 0; s -= 8) + frame.push_back(static_cast((uint64_t(n) >> s) & 0xff)); + } + frame.insert(frame.end(), payload.begin(), payload.end()); + if (!WriteAll(fd, frame.data(), static_cast(frame.size()))) { + // Write failed (stuck/dead peer, possibly mid-frame -> stream desynced). + // Don't keep writing onto a broken stream: shut it down so the handler's + // blocked recv() returns and it exits, closing the fd via the ownership + // protocol. Only shutdown here (not close/clear) — leave the single + // close-owner intact. + shutdown(fd, SD_BOTH); + } +} + +// MARK: Blocking IO helpers + +bool CdpRelay::ReadN(SOCKET fd, void* buf, int count) { + uint8_t* p = static_cast(buf); + int got = 0; + while (got < count) { + int n = recv(fd, reinterpret_cast(p + got), count - got, 0); + if (n <= 0) return false; + got += n; + } + return true; +} + +bool CdpRelay::WriteAll(SOCKET fd, const void* buf, int len) { + const uint8_t* p = static_cast(buf); + int off = 0; + while (off < len) { + int n = send(fd, reinterpret_cast(p + off), len - off, 0); + if (n <= 0) return false; // WSAEWOULDBLOCK (SO_SNDTIMEO) or a dead socket + off += n; + } + return true; +} + +void CdpRelay::WriteRaw(SOCKET fd, const std::string& s) { + WriteAll(fd, s.data(), static_cast(s.size())); +} + +} // namespace flutter_cef diff --git a/packages/flutter_cef_windows/windows/cdp_relay.h b/packages/flutter_cef_windows/windows/cdp_relay.h new file mode 100644 index 0000000..ecb9d0c --- /dev/null +++ b/packages/flutter_cef_windows/windows/cdp_relay.h @@ -0,0 +1,140 @@ +// CdpRelay (Windows) — the token-gated loopback CDP HTTP+WebSocket relay. +// +// A direct winsock port of the macOS reference +// packages/flutter_cef_macos/macos/Classes/CdpRelay.swift (the canonical +// relay): it re-exposes cef_host's CDP-over-pipe (Chromium +// --remote-debugging-pipe + --remote-debugging-io-pipes, NUL-framed JSON on +// two inherited anonymous pipes — the S3 recipe) to a standard CDP client +// (Playwright via connectOverCDP / agent-browser) as a loopback HTTP+WebSocket +// endpoint. The Swift protocol logic ports directly: the POSIX socket calls +// (socket/bind/listen/accept/recv/send, fd) become winsock (WSAStartup, +// SOCKET, closesocket, the same BSD-ish API); the RFC-6455 handshake +// (SHA-1 Sec-WebSocket-Accept via BCrypt), frame codec, the mandatory-token +// gate, and the CDP-pipe bridge are otherwise identical. +// +// SECURITY MODEL (verbatim from CdpRelay.swift — the trust boundary): +// - Mandatory bearer token: the ws upgrade is rejected 401 without a valid +// `Authorization: Bearer ` header (a `?token=` query is an accepted +// fallback). Discovery (/json/*) stays token-free, so a port-scanner learns +// the ws-url but can't upgrade — it never sees the token. +// - Loopback only (127.0.0.1) — never reachable off-box. +// - Ephemeral, OS-assigned port; exists only during a grant +// (enableAgentControl -> Start, disableAgentControl/dispose/host-death -> +// Stop). No standing port. +// - Single active client — a second concurrent ws upgrade is rejected 503. +// - CSPRNG token (BCryptGenRandom), constant-time compared. +// +// SINGLE-TILE SCOPE (P9): this relay is the raw browser-level PASSTHROUGH +// (CEF-2a) — the CDP pipe carries exactly ONE page target (the one tile), so a +// passthrough is functionally correct and safe (there is no sibling tile to +// hide). The per-tile Target-domain FILTER + N-relay CDP-id MULTIPLEX (CEF-2b — +// the `scopeTargetId`/`relayId` machinery in CdpRelay.swift:560-884) is the +// documented follow-up for N-tile Target multiplexing and is NOT implemented +// here. The `scope_target_id` seam below is left in place for it (empty = +// passthrough today); see the class comment where the filter hooks would go. +// +// LIFETIME: the relay is always owned by a std::shared_ptr (the plugin's +// CdpTransport::relay). Its worker threads are DETACHED and each holds a +// shared_from_this(), so the object outlives any in-flight thread; Stop() only +// closes the sockets (unblocking those threads), which then release their refs +// and let the last one destroy the object — the C++ analogue of the Swift +// "shutdown() wakes the blocked handler; the handler owns the close" protocol. + +#ifndef FLUTTER_PLUGIN_FLUTTER_CEF_CDP_RELAY_H_ +#define FLUTTER_PLUGIN_FLUTTER_CEF_CDP_RELAY_H_ + +#include // must precede windows.h +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace flutter_cef { + +class CdpRelay : public std::enable_shared_from_this { + public: + // Forwards one CDP message (a single JSON line, no framing) to cef_host over + // the inherited command pipe (the plugin adds the NUL terminator). Must be + // safe to call from the relay's frame-loop thread. + using SendToPipe = std::function; + + // `send_to_pipe` bridges client->pipe. `scope_target_id` is the CEF-2b + // per-tile filter seam: empty (the P9 single-tile default) is the raw + // browser-level passthrough; a non-empty targetId would engage the + // Target-domain filter (NOT implemented in the slice — see the file comment). + explicit CdpRelay(SendToPipe send_to_pipe, + std::string scope_target_id = std::string()); + ~CdpRelay(); + + CdpRelay(const CdpRelay&) = delete; + CdpRelay& operator=(const CdpRelay&) = delete; + + // Bind a loopback TCP listener on an OS-assigned ephemeral port and start + // accepting. Returns false (cleaning up) on any failure. Must be called on a + // shared_ptr-owned instance (launches threads via shared_from_this()). + bool Start(); + + // Stop the listener, drop any client, and refuse further connections. + // Idempotent. Closes the sockets (waking blocked worker threads); the threads + // self-terminate and the last shared_ptr release destroys the object. + void Stop(); + + // Deliver one CDP message from the pipe to the connected client (framed as a + // WebSocket text frame). Called off the plugin's CDP reader thread. A no-op + // when no client is attached. + void DeliverToClient(const std::string& json); + + uint16_t port() const { return port_; } + const std::string& token() const { return token_; } + + private: + // RFC-6455 server-accept GUID (appended to Sec-WebSocket-Key before SHA-1). + static constexpr const char* kWsGuid = + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + // Frame/message size cap (mirrors the IPC reader's 64 MiB cap): a hostile or + // buggy client must not make us allocate unbounded. + static constexpr uint64_t kMaxFrame = 64ull << 20; + + void AcceptLoop(); + void HandleConnection(SOCKET fd); + void ServeDiscovery(SOCKET fd, const std::string& path); + void FrameLoop(SOCKET fd); + + // HTTP handshake helpers. + bool ReadRequestHead(SOCKET fd, std::string* method, std::string* target, + std::map* headers); + bool TokenAcceptable(const std::string& target, + const std::map& headers); + + // WebSocket framing / blocking IO. + void WriteFrameLocked(SOCKET fd, uint8_t opcode, + const std::vector& payload); + bool ReadN(SOCKET fd, void* buf, int count); + bool WriteAll(SOCKET fd, const void* buf, int len); + void WriteRaw(SOCKET fd, const std::string& s); + + const SendToPipe send_to_pipe_; + const std::string scope_target_id_; // CEF-2b seam (empty = passthrough) + std::string token_; + uint16_t port_ = 0; + + SOCKET listen_sock_ = INVALID_SOCKET; + std::atomic running_{false}; + + // The single active ws client (one connection per relay; a second upgrade is + // 503'd). Guarded by client_lock_, which also serializes writes to it. + SOCKET client_sock_ = INVALID_SOCKET; + std::mutex client_lock_; + + bool wsa_started_ = false; // this instance called WSAStartup (balance it) +}; + +} // namespace flutter_cef + +#endif // FLUTTER_PLUGIN_FLUTTER_CEF_CDP_RELAY_H_ diff --git a/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp b/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp index 9ff3da1..16e91f5 100644 --- a/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp +++ b/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp @@ -1,3 +1,8 @@ +// cdp_relay.h pulls in BEFORE ; it must lead so the +// winsock2/windows.h ordering holds for the whole TU (windows.h's default +// winsock.h would otherwise conflict — the classic WSA redefinition). +#include "cdp_relay.h" + #include "flutter_cef_plugin.h" #include @@ -578,6 +583,14 @@ void FlutterCefPlugin::HandleMethodCall( result->Success(); return; } + if (method == "enableAgentControl") { + EnableAgentControl(args, result); + return; + } + if (method == "disableAgentControl") { + DisableAgentControl(args, result); + return; + } if (method == "getFrameSurface") { if (s) { result->Success(flutter::EncodableValue(flutter::EncodableMap{ @@ -618,6 +631,10 @@ void FlutterCefPlugin::HandleCreate( const std::string allowed_schemes = GetString(args, "allowedSchemes"); const bool named_profile = HasNamedProfile(args); const std::string profile = GetString(args, "profile"); + // Agent control (P9): CDP-over-pipe launch. Off by default; when set, the host + // is spawned with the two inherited CDP pipes (the S3 recipe). Independent of + // enableCdp (TCP) — the pipe path never opens a listening port. + const bool agent_control = GetBool(args, "agentControl", false); // Re-creating the same id is idempotent (Swift:293-295) — route teardown // through its host first. @@ -650,8 +667,8 @@ void FlutterCefPlugin::HandleCreate( key = "~ephemeral~" + session_id; } - Host* host = - ResolveOrSpawnHost(key, profile_dir, ephemeral, host_exe, allowed_schemes); + Host* host = ResolveOrSpawnHost(key, profile_dir, ephemeral, host_exe, + allowed_schemes, agent_control); if (!host) { result->Error("spawn_failed", "failed to spawn cef_host"); return; @@ -764,8 +781,11 @@ FlutterCefPlugin::Host* FlutterCefPlugin::HostForSession( FlutterCefPlugin::Host* FlutterCefPlugin::ResolveOrSpawnHost( const std::string& key, const std::wstring& profile_dir, bool ephemeral, - const std::wstring& host_exe, const std::string& allowed_schemes) { + const std::wstring& host_exe, const std::string& allowed_schemes, + bool agent_control) { // Reuse a live host for this key (a shared persistent profile's 2nd+ tile). + // agent_control (like allowed_schemes) is a process arg fixed at the host's + // spawn — a reuse ignores it (macOS parity, CefProfileHost.swift:456-471). auto existing = hosts_.find(key); if (existing != hosts_.end() && !existing->second->closing) { return existing->second.get(); @@ -778,8 +798,11 @@ FlutterCefPlugin::Host* FlutterCefPlugin::ResolveOrSpawnHost( return nullptr; } auto process = std::make_unique(); + HANDLE cdp_read = nullptr, cdp_write = nullptr; if (!process->Spawn(host_exe, pipe->pipe_name(), profile_dir, ephemeral, - allowed_schemes)) { + allowed_schemes, agent_control, + agent_control ? &cdp_read : nullptr, + agent_control ? &cdp_write : nullptr)) { if (ephemeral) DeleteDirRecursive(profile_dir); // no host will own it return nullptr; } @@ -791,6 +814,15 @@ FlutterCefPlugin::Host* FlutterCefPlugin::ResolveOrSpawnHost( host->profile_dir = profile_dir; host->pipe = std::move(pipe); host->process = std::move(process); + host->agent_control = agent_control; + // Agent control: own the parent-side CDP pipe ends and start the always-on + // reader that fans NUL-framed CDP messages to the (later-created) relay. + if (agent_control) { + host->cdp = std::make_shared(); + host->cdp->read = cdp_read; + host->cdp->write = cdp_write; + host->cdp_reader = std::thread(&FlutterCefPlugin::CdpReadLoop, host->cdp); + } const std::string host_key = key; const uint64_t gen = host->generation; @@ -925,7 +957,19 @@ void FlutterCefPlugin::TeardownHost(const std::string& host_key, reapers_.push_back(Reaper{ std::thread([pipe = std::move(h->pipe), process = std::move(h->process), watcher = std::move(h->exit_watcher), + cdp = std::move(h->cdp), + cdp_reader = std::move(h->cdp_reader), profile_dir = std::move(profile_dir), done]() mutable { + // Agent control: stop the relay FIRST (closes its WS sockets, so no more + // client IO or sendToPipe) before we kill the host. + if (cdp) { + std::shared_ptr relay; + { + std::lock_guard lk(cdp->relay_mutex); + relay = std::move(cdp->relay); + } + if (relay) relay->Stop(); + } if (process) { if (process->WaitForExit(3000) == HostProcess::kStillRunning) { process->Terminate(); @@ -936,6 +980,17 @@ void FlutterCefPlugin::TeardownHost(const std::string& host_key, pipe.release(); // wedged reader: leak by contract } if (watcher.joinable()) watcher.join(); + // CDP reader: the host is dead now, so the child's CDP write end closed + // -> the reader's ReadFile hits EOF and exits. CancelIoEx unblocks it + // defensively; then join and close the parent-side handles (only after + // the join, so the reader never touches a closed handle). + if (cdp && cdp->read) CancelIoEx(cdp->read, nullptr); + if (cdp_reader.joinable()) cdp_reader.join(); + if (cdp) { + if (cdp->read) CloseHandle(cdp->read); + if (cdp->write) CloseHandle(cdp->write); + cdp->read = cdp->write = nullptr; + } if (!profile_dir.empty()) DeleteDirRecursive(profile_dir); done->store(true); }), @@ -981,6 +1036,123 @@ void FlutterCefPlugin::SendOrQueue(Session* session, uint8_t opcode, static_cast(payload.size())); } +// ---- agent control (P9) ---- + +// static +void FlutterCefPlugin::CdpReadLoop(std::shared_ptr transport) { + HANDLE read = transport->read; + if (!read) return; + std::string acc; + std::vector buf(64 * 1024); + for (;;) { + DWORD got = 0; + if (!ReadFile(read, buf.data(), static_cast(buf.size()), &got, + nullptr) || + got == 0) { + break; // EOF / error / CancelIoEx: host gone or teardown + } + acc.append(buf.data(), got); + // Split the NUL-delimited JSON stream into complete messages (a message can + // span reads; one read can carry several / straddle a boundary — mirror + // macOS readCdpLoop). + size_t nul; + while ((nul = acc.find('\0')) != std::string::npos) { + std::string msg = acc.substr(0, nul); + acc.erase(0, nul + 1); + std::shared_ptr relay; + { + std::lock_guard lk(transport->relay_mutex); + relay = transport->relay; // snapshot; deliver OUTSIDE the lock + } + if (relay) relay->DeliverToClient(msg); + } + // Bound the accumulator (mirror the IPC reader cap): a malformed never-NUL + // stream must not grow unbounded. The peer is our own cef_host (Chromium + // always NUL-frames), so this is defensive. + if (acc.size() > (64u << 20)) break; + } +} + +void FlutterCefPlugin::EnableAgentControl( + const flutter::EncodableMap& args, + std::unique_ptr>& result) { + Session* s = FindSession(args); + Host* h = HostForSession(s); + if (!h || !h->agent_control || !h->cdp) { + // macOS throws a PlatformException when the tile isn't agent-control mode. + result->Error("no_agent_control", + "enableAgentControl requires a session created with " + "agentControl: true"); + return; + } + + std::shared_ptr cdp = h->cdp; + std::shared_ptr relay; + { + std::lock_guard lk(cdp->relay_mutex); + relay = cdp->relay; // idempotent fast-path: return the live relay + } + if (!relay) { + // sendToPipe: NUL-frame the client's CDP command onto the host's command + // pipe. A WEAK transport ref avoids a relay<->transport cycle, and a dead + // transport (post-teardown) just drops the write. + std::weak_ptr weak = cdp; + auto send_to_pipe = [weak](const std::string& json) { + auto t = weak.lock(); + if (!t) return; + std::lock_guard lk(t->write_mutex); + if (!t->write) return; + std::string framed = json; + framed.push_back('\0'); + DWORD wrote = 0; + WriteFile(t->write, framed.data(), static_cast(framed.size()), + &wrote, nullptr); + }; + auto fresh = std::make_shared(send_to_pipe); + if (!fresh->Start()) { + result->Error("relay_failed", "failed to start the CDP relay"); + return; + } + std::lock_guard lk(cdp->relay_mutex); + if (cdp->relay) { + // A concurrent enable raced us: keep the existing relay, drop ours. + fresh->Stop(); + relay = cdp->relay; + } else { + cdp->relay = fresh; + relay = fresh; + } + } + + // Match the Swift return shape EXACTLY (CefProfileHost.endpoint): + // ws://127.0.0.1:/devtools/browser?token= + const int port = relay->port(); + const std::string token = relay->token(); + std::ostringstream ws; + ws << "ws://127.0.0.1:" << port << "/devtools/browser?token=" << token; + result->Success(flutter::EncodableValue(flutter::EncodableMap{ + {Ev("wsUrl"), flutter::EncodableValue(ws.str())}, + {Ev("token"), flutter::EncodableValue(token)}, + {Ev("port"), flutter::EncodableValue(port)}, + })); +} + +void FlutterCefPlugin::DisableAgentControl( + const flutter::EncodableMap& args, + std::unique_ptr>& result) { + Session* s = FindSession(args); + Host* h = HostForSession(s); + if (h && h->cdp) { + std::shared_ptr relay; + { + std::lock_guard lk(h->cdp->relay_mutex); + relay = std::move(h->cdp->relay); + } + if (relay) relay->Stop(); // outside the lock (Stop closes sockets) + } + result->Success(); // idempotent — no-op if that tile has no relay +} + // ---- inbound host events (platform thread) ---- void FlutterCefPlugin::HandleHostGone(const std::string& host_key, diff --git a/packages/flutter_cef_windows/windows/flutter_cef_plugin.h b/packages/flutter_cef_windows/windows/flutter_cef_plugin.h index 0f785c0..c0ca810 100644 --- a/packages/flutter_cef_windows/windows/flutter_cef_plugin.h +++ b/packages/flutter_cef_windows/windows/flutter_cef_plugin.h @@ -62,6 +62,26 @@ namespace flutter_cef { +class CdpRelay; // windows/cdp_relay.h — winsock, pulled in only by the .cpp + +// Agent-control (P9) CDP-over-pipe transport for one host. Held via a +// shared_ptr so the always-on CDP reader thread (which delivers pipe messages +// to the current relay) can safely outlive a Host erase during teardown — the +// reader captures its own shared_ptr, so the transport (and its handles) stay +// alive until the reader is joined in the reaper. `read`/`write` are the +// PARENT-side ends of the two inherited anonymous pipes (we read CDP +// responses/events on `read`, write CDP commands on `write`). `relay` is the +// token-gated WS relay, created lazily by enableAgentControl and swapped in/out +// under `relay_mutex` (mirrors macOS CefProfileHost.cdpRelays + onCdpMessage). +// SINGLE-TILE: one relay slot per host; the N-relay fan-out is deferred. +struct CdpTransport { + HANDLE read = nullptr; + HANDLE write = nullptr; + std::mutex write_mutex; // serializes WriteFile to `write` + std::mutex relay_mutex; // guards `relay` + std::shared_ptr relay; // null until enableAgentControl +}; + class FlutterCefPlugin : public flutter::Plugin { public: static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); @@ -108,6 +128,14 @@ class FlutterCefPlugin : public flutter::Plugin { std::unique_ptr pipe; std::unique_ptr process; std::thread exit_watcher; // waits on a dup'd process handle + // Agent control (P9): set when this host was spawned with the CDP-over-pipe + // transport (create arg agentControl:true). `cdp` owns the parent-side pipe + // ends + the lazily-created relay; `cdp_reader` continuously drains the CDP + // read pipe and delivers to cdp->relay. Both are moved into the reaper on + // teardown. Null / not-joinable for a non-agent-control host. + bool agent_control = false; + std::shared_ptr cdp; + std::thread cdp_reader; }; // One browser/view. Platform-thread confined. @@ -178,8 +206,26 @@ class FlutterCefPlugin : public flutter::Plugin { Host* ResolveOrSpawnHost(const std::string& key, const std::wstring& profile_dir, bool ephemeral, const std::wstring& host_exe, - const std::string& allowed_schemes); + const std::string& allowed_schemes, + bool agent_control); void DisposeSession(const std::string& session_id); + + // Agent control (P9). enableAgentControl starts (idempotently) the token-gated + // loopback CDP relay for the session's host and replies + // `{wsUrl, token, port}` (the macOS return shape exactly); it errors if the + // host was not created with agentControl:true. disableAgentControl tears the + // relay down (the tile keeps running). Both platform-thread only. + void EnableAgentControl( + const flutter::EncodableMap& args, + std::unique_ptr>& result); + void DisableAgentControl( + const flutter::EncodableMap& args, + std::unique_ptr>& result); + // The always-on CDP reader: drains a host's CDP read pipe (NUL-framed JSON) + // and delivers each complete message to the current relay. Runs on its own + // thread; touches no MethodChannel state. Static + shared_ptr-scoped so it can + // outlive a Host erase (joined in the reaper). + static void CdpReadLoop(std::shared_ptr transport); // Tear down a whole Host: mark closing, optionally send kOpShutdown, sweep // any lingering sessions, and hand pipe/process/watcher to a reaper thread // (bounded wait -> kill -> close; deletes an EPHEMERAL profile dir). diff --git a/packages/flutter_cef_windows/windows/host_process.cpp b/packages/flutter_cef_windows/windows/host_process.cpp index 74c37ba..1a3362c 100644 --- a/packages/flutter_cef_windows/windows/host_process.cpp +++ b/packages/flutter_cef_windows/windows/host_process.cpp @@ -1,5 +1,6 @@ #include "host_process.h" +#include #include namespace flutter_cef { @@ -26,7 +27,8 @@ HostProcess::~HostProcess() { Shutdown(); } bool HostProcess::Spawn(const std::wstring& cef_host_exe, const std::wstring& pipe_name, const std::wstring& profile_dir, bool ephemeral, - const std::string& allowed_schemes) { + const std::string& allowed_schemes, bool agent_control, + HANDLE* out_cdp_read, HANDLE* out_cdp_write) { if (process_) return false; // one-shot // CommandLineToArgvW-correct quoting: quote the exe and the --profile-dir @@ -43,6 +45,56 @@ bool HostProcess::Spawn(const std::wstring& cef_host_exe, cmd += L" --allowed-schemes=" + w; } + // ---- Agent control (P9): CDP-over-pipe plumbing (the S3 recipe) ---- + // Two anonymous pipes; ONLY the child-side ends are inheritable and land in + // the STARTUPINFOEX handle list, so this composes cleanly with the existing + // spawn (which inherits nothing). cmd_pipe: parent writes CDP -> child reads. + // out_pipe: child writes CDP -> parent reads. + HANDLE cmd_read = nullptr, cmd_write = nullptr; // cmd_read = child's read + HANDLE out_read = nullptr, out_write = nullptr; // out_write = child's write + std::vector attr_buf; + LPPROC_THREAD_ATTRIBUTE_LIST attrs = nullptr; + if (agent_control) { + if (!CreatePipe(&cmd_read, &cmd_write, nullptr, 0) || + !CreatePipe(&out_read, &out_write, nullptr, 0)) { + HostLog("CreatePipe (cdp) failed", GetLastError()); + if (cmd_read) CloseHandle(cmd_read); + if (cmd_write) CloseHandle(cmd_write); + if (out_read) CloseHandle(out_read); + if (out_write) CloseHandle(out_write); + return false; + } + SetHandleInformation(cmd_read, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + SetHandleInformation(out_write, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + // Child argv: --remote-debugging-io-pipes wants , where read is + // the handle the browser READS commands from (cmd_read) and write is the one + // it WRITES responses to (out_write). Decimal HANDLE values, cast to + // unsigned 32-bit (S3: `(unsigned)(uintptr_t)` — handle values fit in 32 + // bits for Chromium's int-parsing of the switch). + cmd += L" --cdp-io-pipes=" + + std::to_wstring(static_cast( + reinterpret_cast(cmd_read))) + + L"," + + std::to_wstring(static_cast( + reinterpret_cast(out_write))); + // Explicit inheritance list = exactly the two child-side ends. + SIZE_T attr_size = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attr_size); + attr_buf.resize(attr_size); + attrs = reinterpret_cast(attr_buf.data()); + HANDLE inherit[2] = {cmd_read, out_write}; + if (!InitializeProcThreadAttributeList(attrs, 1, 0, &attr_size) || + !UpdateProcThreadAttribute(attrs, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherit, sizeof(inherit), nullptr, nullptr)) { + HostLog("ProcThreadAttributeList (cdp) failed", GetLastError()); + CloseHandle(cmd_read); + CloseHandle(cmd_write); + CloseHandle(out_read); + CloseHandle(out_write); + return false; + } + } + // Kill-on-close Job Object, assigned while the child is suspended, so the // guarantee covers the child's entire lifetime (its own subprocesses run // under CEF's job management; Chromium children die with the browser). @@ -57,25 +109,46 @@ bool HostProcess::Spawn(const std::wstring& cef_host_exe, } } - STARTUPINFOW si = {}; - si.cb = sizeof(si); + // STARTUPINFOEX only when we have an attribute list (agent control); the + // non-agent path stays byte-identical (plain STARTUPINFOW, no handle list). + STARTUPINFOEXW six = {}; + six.StartupInfo.cb = agent_control ? sizeof(six) : sizeof(STARTUPINFOW); + if (agent_control) six.lpAttributeList = attrs; + DWORD flags = CREATE_SUSPENDED | CREATE_NO_WINDOW; + if (agent_control) flags |= EXTENDED_STARTUPINFO_PRESENT; PROCESS_INFORMATION pi = {}; std::vector cmd_buf(cmd.begin(), cmd.end()); cmd_buf.push_back(L'\0'); - if (!CreateProcessW(cef_host_exe.c_str(), cmd_buf.data(), - /*lpProcessAttributes=*/nullptr, - /*lpThreadAttributes=*/nullptr, - /*bInheritHandles=*/FALSE, - CREATE_SUSPENDED | CREATE_NO_WINDOW, - /*lpEnvironment=*/nullptr, - /*lpCurrentDirectory=*/nullptr, &si, &pi)) { + const BOOL created = CreateProcessW( + cef_host_exe.c_str(), cmd_buf.data(), + /*lpProcessAttributes=*/nullptr, + /*lpThreadAttributes=*/nullptr, + /*bInheritHandles=*/agent_control ? TRUE : FALSE, flags, + /*lpEnvironment=*/nullptr, + /*lpCurrentDirectory=*/nullptr, &six.StartupInfo, &pi); + if (agent_control) { + if (attrs) DeleteProcThreadAttributeList(attrs); + // Regardless of success, the parent never keeps the CHILD-side ends. + CloseHandle(cmd_read); + CloseHandle(out_write); + } + if (!created) { HostLog("CreateProcessW failed", GetLastError()); if (job_) { CloseHandle(job_); job_ = nullptr; } + if (agent_control) { + CloseHandle(cmd_write); + CloseHandle(out_read); + } return false; } + if (agent_control) { + // Hand the parent-side ends to the caller (it owns + closes them). + if (out_cdp_write) *out_cdp_write = cmd_write; else CloseHandle(cmd_write); + if (out_cdp_read) *out_cdp_read = out_read; else CloseHandle(out_read); + } if (job_ && !AssignProcessToJobObject(job_, pi.hProcess)) { // Best-effort (nested-job support exists since Win8): proceed without the // kernel guarantee rather than failing the spawn. diff --git a/packages/flutter_cef_windows/windows/host_process.h b/packages/flutter_cef_windows/windows/host_process.h index b423049..8f56afe 100644 --- a/packages/flutter_cef_windows/windows/host_process.h +++ b/packages/flutter_cef_windows/windows/host_process.h @@ -41,9 +41,27 @@ class HostProcess { // --allowed-schemes= (empty = omitted = allow all; mirrors // CefProfileHost.spawn, CefProfileHost.swift:289-291). Returns false on // spawn failure. + // + // AGENT CONTROL (P9): when `agent_control` is true, the spawn additionally + // sets up the CDP-over-pipe transport (the S3 recipe, mirroring macOS + // launchViaPosixSpawn's fds 3/4): it CreatePipe()s two anonymous pipes, marks + // ONLY the child-side ends inheritable, spawns cef_host with a + // STARTUPINFOEX PROC_THREAD_ATTRIBUTE_HANDLE_LIST containing exactly those two + // ends (so nothing else leaks — this composes with the existing spawn, which + // inherits NO handles: the IPC pipe is connected by NAME and the Job Object is + // assigned post-spawn), and passes `--cdp-io-pipes=,` + // (decimal HANDLE values) which cef_host's OnBeforeCommandLineProcessing + // translates into Chromium's --remote-debugging-pipe + + // --remote-debugging-io-pipes. The PARENT-side ends are returned via + // `out_cdp_read` (we read CDP responses/events here; child writes) and + // `out_cdp_write` (we write CDP commands here; child reads). The caller owns + + // closes them. When `agent_control` is false the spawn is byte-identical to + // the pre-P9 path (no handle inheritance, no extra pipes). bool Spawn(const std::wstring& cef_host_exe, const std::wstring& pipe_name, const std::wstring& profile_dir, bool ephemeral, - const std::string& allowed_schemes = std::string()); + const std::string& allowed_schemes = std::string(), + bool agent_control = false, HANDLE* out_cdp_read = nullptr, + HANDLE* out_cdp_write = nullptr); // Waits up to `timeout_ms` for exit; returns the exit code, or // kStillRunning on timeout / if never spawned. From 2db0809072e3d06f22d8c05acf89c70bd0c22b09 Mon Sep 17 00:00:00 2001 From: wenkai fan Date: Tue, 21 Jul 2026 13:44:32 -0400 Subject: [PATCH 7/9] fix(windows): resolve pre-merge audit findings (6 merge-blockers + 5 more) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-merge adversarial audit of the whole port (7 dimensions, 3-lens verification) surfaced 13 confirmed findings (6 merge-blocking); 4 were verified false and dropped. Fixes: BLOCKERS - Cross-tile CDP exposure: the relay is a browser-level passthrough on a per-HOST basis, so on a shared profile an agent granted tile A could Target.getTargets/attach sibling tiles — contradicting the documented per-tile guarantee (the N-tile Target filter is deferred). Enforce a single-tile invariant: refuse a 2nd tile joining a host with a live grant (HandleCreate) AND refuse a grant on a multi-tile host (EnableAgentControl). Co-locate an agent-controlled view on its own profile until CEF-2b lands. - CDP relay listener used SO_REUSEADDR (a Windows port-hijack/token-theft primitive) — replaced with SO_EXCLUSIVEADDRUSE (PLAN §4.5). - Same-profile rapid dispose+recreate spuriously failed processGone('locked'): the outgoing host holds the exclusive profile lock ~2-3s (cookie-flush defer + reaper grace) while the fresh host lost it instantly. The host now retries the lock with bounded backoff (~5s) before declaring locked. - CDP write-pipe handle now closed under write_mutex in the reaper (was a race vs the relay's send_to_pipe writer). - CI: added a windows-latest job (analyze the package + flutter test + build the example, compiling the ~5.5k lines of native code) and added flutter_cef_windows to the macOS analyze matrix. The package had zero CI. OTHER - Profile-name sanitizer: strip trailing dots + neutralize reserved DOS device names (CON/PRN/AUX/NUL/COM1-9/LPT1-9) so two names can't collide on one jar. - Download suggested_name (page-controlled) reduced to a bare leaf before joining to Downloads — a "..\..\x.exe" name could otherwise escape the folder. - CDP relay token now fails CLOSED (refuse to Start) on RNG failure instead of emitting a guessable all-zeros token. - HostClient::OnBeforeBrowse now calls router_->OnBeforeBrowse (main.mm:1563) so a channel query in flight across a navigation can't misfire/leak. - profile_probe.dart writes evidence under Directory.systemTemp, not a maintainer absolute path. - Root pubspec publish-order note includes the 4th (windows) package. Deferred (low, noted): ephemeral %TEMP% dir hardening (#11) — %TEMP% is already user-scoped. Verified: integrated build (CEF_ROOT unset) clean; agent-control single-tile probe still PASS (401/eval=2/teardown); macOS zero-diff; analyze clean (root + windows pkg); 164/164 tests. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 31 ++++++++++ example/lib/profile_probe.dart | 5 +- .../native/cef_host/cef_host_win.cc | 41 ++++++++++++-- .../flutter_cef_windows/windows/cdp_relay.cpp | 19 +++++-- .../windows/flutter_cef_plugin.cpp | 56 +++++++++++++++++++ pubspec.yaml | 3 +- 6 files changed, 143 insertions(+), 12 deletions(-) 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/example/lib/profile_probe.dart b/example/lib/profile_probe.dart index e6a82c1..292a4d0 100644 --- a/example/lib/profile_probe.dart +++ b/example/lib/profile_probe.dart @@ -33,7 +33,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_cef/flutter_cef.dart'; -const _evidenceDir = r'C:\dev\flutter_cef_spikes\profile_evidence'; +// 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'; 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 index 1608148..0bb5c69 100644 --- a/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc +++ b/packages/flutter_cef_windows/native/cef_host/cef_host_win.cc @@ -778,8 +778,20 @@ class HostClient : public CefClient, SendUtf8(slot_->browser_id, kOpDownload, suggested_name.ToString()); const std::wstring dir = GetDownloadsDir(); if (!dir.empty()) { + // SECURITY: suggested_name is page-controlled (Content-Disposition), so + // reduce it to a bare leaf before joining it to Downloads — a name like + // "..\..\Startup\x.exe" or an absolute path would otherwise escape the + // folder. Take the component after the last / or \, reject . / .. / + // empty, and drop any residual separators. + std::wstring leaf = suggested_name.ToWString(); + const size_t slash = leaf.find_last_of(L"/\\"); + if (slash != std::wstring::npos) leaf = leaf.substr(slash + 1); + leaf.erase(std::remove_if(leaf.begin(), leaf.end(), + [](wchar_t c) { return c == L'/' || c == L'\\'; }), + leaf.end()); + if (leaf.empty() || leaf == L"." || leaf == L"..") leaf = L"download"; CefString full; - full.FromWString(dir + L"\\" + suggested_name.ToWString()); + full.FromWString(dir + L"\\" + leaf); callback->Continue(full, /*show_dialog=*/false); } else { callback->Continue(CefString(), /*show_dialog=*/true); @@ -1032,8 +1044,12 @@ class HostClient : public CefClient, // Navigation scheme allowlist (main.mm:1528-1565). Empty allowlist = allow // all. Main-frame only; kOpLoadTrusted's exact-URL exemptions are consumed // here. - bool OnBeforeBrowse(CefRefPtr, CefRefPtr frame, + bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, CefRefPtr request, bool, bool) override { + // Clean up any pending message-router queries for the frame about to + // navigate (main.mm:1563) — otherwise a channel query in flight across a + // navigation can misfire or leak its callback. + if (router_) router_->OnBeforeBrowse(browser, frame); if (g_allowed_schemes.empty()) return false; // allow const std::string url = request->GetURL().ToString(); const bool main_frame = !frame || frame->IsMain(); @@ -2069,9 +2085,24 @@ extern "C" CEF_BOOTSTRAP_EXPORT int RunConsoleMain( // per-spawn and can never contend. if (!ephemeral) { const std::wstring lock_path = Widen(profile_dir + "\\.flutter_cef.lock"); - HANDLE lock = CreateFileW(lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, - /*dwShareMode=*/0, nullptr, OPEN_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); + // Retry with bounded backoff before declaring the profile locked. A rapid + // dispose+recreate of the SAME profile (route change / hot reload) races + // the OUTGOING host, which still holds this exclusive handle until it exits + // — it defers its quit up to ~2s to flush cookies, and the plugin's reaper + // waits up to 3s before Terminate. Without the retry the fresh host loses + // the lock instantly and surfaces a spurious processGone("locked") with no + // other app open. ~5s of 50ms tries covers the outgoing host's release; + // a GENUINE cross-app conflict still reports "locked" after the window. + HANDLE lock = INVALID_HANDLE_VALUE; + for (int attempt = 0; attempt < 100; ++attempt) { + lock = CreateFileW(lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, + /*dwShareMode=*/0, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (lock != INVALID_HANDLE_VALUE) break; + const DWORD gle = GetLastError(); + if (gle != ERROR_SHARING_VIOLATION && gle != ERROR_ACCESS_DENIED) break; + Sleep(50); + } if (lock == INVALID_HANDLE_VALUE) { SendLog(0, "profile-locked"); LogErr("[cef_host] profile already in use (%s, gle=%lu)", diff --git a/packages/flutter_cef_windows/windows/cdp_relay.cpp b/packages/flutter_cef_windows/windows/cdp_relay.cpp index 3c8d09e..40c89b8 100644 --- a/packages/flutter_cef_windows/windows/cdp_relay.cpp +++ b/packages/flutter_cef_windows/windows/cdp_relay.cpp @@ -88,14 +88,15 @@ std::string Base64(const uint8_t* p, size_t n) { } // CSPRNG token: 24 random bytes hex-encoded (48 chars), matching -// CdpRelay.swift randomToken(). Fails closed to an unguessable-but-unusable -// value if CNG somehow fails (Start() success is the gate; a bad token just -// means no client can connect). +// CdpRelay.swift randomToken(). Returns EMPTY on CNG failure so Start() can +// abort — an all-zeros token is NOT fail-closed, it is a guessable CONSTANT +// (an attacker who knows CNG failed connects with 48 zeros), so we must refuse +// to start rather than emit a usable weak token. std::string RandomToken() { uint8_t bytes[24] = {}; if (BCryptGenRandom(nullptr, bytes, sizeof(bytes), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { - // Never expected; keep the buffer zeroed (still 48 hex chars, unusable). + return std::string(); // Start() treats an empty token as fatal. } static const char* hex = "0123456789abcdef"; std::string s; @@ -143,6 +144,10 @@ CdpRelay::~CdpRelay() { // MARK: Lifecycle bool CdpRelay::Start() { + if (token_.empty()) { + DLog("refusing to start: token RNG failed (would be a guessable constant)"); + return false; + } WSADATA wsa = {}; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { DLog("WSAStartup failed"); @@ -155,8 +160,12 @@ bool CdpRelay::Start() { DLog("socket() failed"); return false; } + // SO_EXCLUSIVEADDRUSE, NOT SO_REUSEADDR (PLAN §4.5): on Windows SO_REUSEADDR + // is a port-HIJACK primitive — it lets another same-user socket bind our + // already-bound loopback port and intercept the client's next connection + // (stealing the bearer token off the upgrade). Exclusive-use forbids that. BOOL on = TRUE; - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&on), + setsockopt(fd, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast(&on), sizeof(on)); sockaddr_in addr = {}; diff --git a/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp b/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp index 16e91f5..cf20fe8 100644 --- a/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp +++ b/packages/flutter_cef_windows/windows/flutter_cef_plugin.cpp @@ -150,6 +150,23 @@ std::wstring SanitizeProfileLeaf(const std::string& profile) { if (c != '.') all_dots = false; } if (out.empty() || all_dots) return L"_"; + // Windows silently drops trailing dots/spaces from a path component, which + // would let "work." and "work" resolve to the SAME dir (jar confusion) and + // fight over the profile lock. Spaces already map to '_' via the allowlist; + // strip trailing dots here so the on-disk leaf is exactly what we keyed on. + while (!out.empty() && out.back() == L'.') out.pop_back(); + if (out.empty()) return L"_"; + // Reserved DOS device basenames (CON/PRN/AUX/NUL/COM1-9/LPT1-9) are illegal + // directory names even with an extension — neutralize by prefixing '_'. + std::wstring base = out.substr(0, out.find(L'.')); + for (wchar_t& ch : base) + if (ch >= L'a' && ch <= L'z') ch = static_cast(ch - 32); + static const wchar_t* kReserved[] = { + L"CON", L"PRN", L"AUX", L"NUL", L"COM1", L"COM2", L"COM3", L"COM4", + L"COM5", L"COM6", L"COM7", L"COM8", L"COM9", L"LPT1", L"LPT2", L"LPT3", + L"LPT4", L"LPT5", L"LPT6", L"LPT7", L"LPT8", L"LPT9"}; + for (const wchar_t* r : kReserved) + if (base == r) return L"_" + out; return out; } @@ -674,6 +691,29 @@ void FlutterCefPlugin::HandleCreate( return; } + // SECURITY (P9 single-tile invariant): the CDP relay is a BROWSER-LEVEL + // passthrough (the per-tile CEF-2b Target filter is deferred — PROTOCOL.md + // §8.4). So an active agent-control grant must own a host with exactly ONE + // tile; otherwise the agent driving this host could Target.getTargets/attach + // the new sibling tile. Refuse a second tile joining a host whose grant is + // live. (The complementary guard is in EnableAgentControl: no grant on a + // multi-tile host.) Co-locate an agent-controlled view on its OWN profile. + if (host->agent_control && !host->browsers.empty() && host->cdp) { + bool grant_active; + { + std::lock_guard lk(host->cdp->relay_mutex); + grant_active = host->cdp->relay != nullptr; + } + if (grant_active) { + result->Error( + "agent_control_active", + "cannot add a tile to a profile whose agent-control grant is active " + "(per-tile CDP scoping is not yet implemented on Windows — give the " + "agent-controlled view its own profile)"); + return; + } + } + const int64_t texture_id = texture_bridge_->RegisterSessionTexture(); if (texture_id < 0) { // Roll back a host we just spawned solely for this session (no other @@ -987,6 +1027,11 @@ void FlutterCefPlugin::TeardownHost(const std::string& host_key, if (cdp && cdp->read) CancelIoEx(cdp->read, nullptr); if (cdp_reader.joinable()) cdp_reader.join(); if (cdp) { + // Close under write_mutex: send_to_pipe (the relay's client->pipe + // writer) takes the same mutex and checks cdp->write, so serializing + // the close+null against it prevents a WriteFile on a freed handle + // even if a relay bridge thread outlived relay->Stop() above. + std::lock_guard lk(cdp->write_mutex); if (cdp->read) CloseHandle(cdp->read); if (cdp->write) CloseHandle(cdp->write); cdp->read = cdp->write = nullptr; @@ -1085,6 +1130,17 @@ void FlutterCefPlugin::EnableAgentControl( "agentControl: true"); return; } + // SECURITY (P9 single-tile invariant, see HandleCreate): the browser-level + // relay would expose every sibling tile on this host to the agent. Until the + // per-tile Target filter lands, refuse a grant while the host serves >1 tile. + if (h->browsers.size() > 1) { + result->Error( + "agent_control_shared", + "enableAgentControl needs a single-tile host: this profile has other " + "tiles that a browser-level grant would expose. Give the " + "agent-controlled view its own profile."); + return; + } std::shared_ptr cdp = h->cdp; std::shared_ptr relay; diff --git a/pubspec.yaml b/pubspec.yaml index e4cd132..c2bc552 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,8 @@ dependencies: # Federation members are wired as path deps: the repo is consumed from source # (path / git), not pub.dev. PUBLISHING (when it happens): `pub publish` # rejects path deps, so publish bottom-up — flutter_cef_platform_interface, - # then flutter_cef_macos, then this package — and at each step swap the sibling + # then flutter_cef_macos, then flutter_cef_windows, then this package — and at + # each step swap the sibling # `path:` deps for hosted caret constraints (`^0.1.x`, the # minor-compatible policy noted in the platform_interface pubspec), keeping the # `path:` entries under `dependency_overrides` for local dev. From a48e7566e0867d3fabbdf0e97f0e448f1be1b9e9 Mon Sep 17 00:00:00 2001 From: wenkai fan Date: Thu, 23 Jul 2026 21:49:49 -0400 Subject: [PATCH 8/9] fix(windows): implement real CEF auto-download in fetch_cef.ps1 (unblocks CI) The windows-build CI job (added in the previous commit) failed at the example build: a clean runner has no CEF and fetch_cef.ps1 only VERIFIED an already- present dist (download was a P11 TODO), so CMake FATAL_ERROR'd. Two bugs, both in the never-exercised "CEF absent" path that only CI (and fresh clones) hit: 1. fetch_cef.ps1 now DOWNLOADS the pinned tarball from cef-builds.spotifycdn.com when it is not cached, verifies its SHA-1 fail-closed, extracts with bsdtar into a temp dir, and moves it into %LOCALAPPDATA%/flutter_cef (S2 recipe). 2. Progress was Write-Host, which lands on STDOUT under powershell -File and would have polluted the path CMake captures via OUTPUT_VARIABLE (two lines, so the EXISTS check fails). All progress now goes to STDERR; only the resolved root reaches stdout. Also rewrote ASCII-only: em-dashes in a UTF-8-no-BOM .ps1 were misread under PowerShell 5.1 ANSI default and broke parsing. Verified locally on the exact CI path: with no cached CEF, flutter build windows fetched (SHA-1 4bdedf91... verified), extracted, compiled cef_host, and Built in 70s; fetch_cef.ps1 stdout is exactly one line (the path). Co-Authored-By: Claude Fable 5 --- .../native/cef_host/fetch_cef.ps1 | 97 +++++++++++++++---- 1 file changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 b/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 index 30ee5d1..5515a5b 100644 --- a/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 +++ b/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 @@ -1,38 +1,99 @@ -# fetch_cef.ps1 — resolve and print the root of the pinned CEF binary -# distribution for the Windows cef_host build. +# fetch_cef.ps1 - resolve (and, when missing, DOWNLOAD) the root of the pinned +# CEF binary distribution for the Windows cef_host build. Prints the resolved +# root on stdout (Write-Output) so windows/CMakeLists.txt and +# native/cef_host/CMakeLists.txt can capture it via execute_process; all +# progress goes to Info so it never pollutes the captured value. # -# SLICE SKELETON: today this script only VERIFIES an already-extracted -# distribution and prints the resolved root (it does NOT download anything yet). -# TODO(builder-hostmain / P11): real fetch — download the pinned tarball from -# cef-builds.spotifycdn.com, verify its digest fail-closed, extract with -# native tar.exe (bsdtar handles .tar.bz2 — SPIKES.md S6), and cache under -# %LOCALAPPDATA%\flutter_cef with a hash-stamp short-circuit (PLAN §4.6). +# Resolution order (shared with both CMakeLists): env CEF_ROOT, then +# %LOCALAPPDATA%/flutter_cef/. If neither exists, download the pinned +# tarball from cef-builds.spotifycdn.com, verify its SHA-1 fail-closed, extract +# with native tar.exe (bsdtar handles .tar.bz2, SPIKES.md S6), and cache it +# under %LOCALAPPDATA%/flutter_cef for later builds. $ErrorActionPreference = 'Stop' +# Progress goes to STDERR — Info lands on STDOUT under `powershell -File`, +# which would pollute the resolved path CMake captures via OUTPUT_VARIABLE. +# ONLY the final Write-Output (the CEF root) may reach stdout. +function Info($m) { [Console]::Error.WriteLine($m) } + # The pin (matches build_cef_host.sh:17 / SPIKES.md header). $CefVersion = '144.0.27+g3fae261+chromium-144.0.7559.254' $CefDistName = "cef_binary_${CefVersion}_windows64_minimal" -# Resolution order (shared with windows/CMakeLists.txt and -# native/cef_host/CMakeLists.txt): env CEF_ROOT > %LOCALAPPDATA%/flutter_cef/. $Candidates = @() if ($env:CEF_ROOT) { $Candidates += $env:CEF_ROOT } $Candidates += (Join-Path $env:LOCALAPPDATA "flutter_cef\$CefDistName") foreach ($root in $Candidates) { if (Test-Path (Join-Path $root 'cmake')) { - Write-Host "CEF_ROOT resolved: $root" + Info "fetch_cef: CEF_ROOT resolved: $root" Write-Output $root exit 0 } } -Write-Error @" -CEF distribution '$CefDistName' not found. Checked: -$($Candidates -join "`n") -Either set CEF_ROOT to an extracted copy, or download -https://cef-builds.spotifycdn.com/$([uri]::EscapeDataString($CefDistName)).tar.bz2 -and extract it to one of the paths above. (Automated fetch is a P11 TODO.) -"@ +# Not cached: download + verify + extract into the cache. +$CacheRoot = Join-Path $env:LOCALAPPDATA 'flutter_cef' +$Dest = Join-Path $CacheRoot $CefDistName +New-Item -ItemType Directory -Force $CacheRoot | Out-Null + +$enc = [uri]::EscapeDataString("$CefDistName.tar.bz2") +$url = "https://cef-builds.spotifycdn.com/$enc" +$tarball = Join-Path $CacheRoot "$CefDistName.tar.bz2" + +Info "fetch_cef: CEF not cached; downloading $url" +& curl.exe -fL --retry 3 -o $tarball $url +if ($LASTEXITCODE -ne 0) { + Write-Error "fetch_cef: download failed" + exit 1 +} + +# Fail-closed SHA-1 check against the published .sha1 (a bare hex digest). +$expected = '' +$sha1line = & curl.exe -fsL "$url.sha1" +if ($sha1line) { + $expected = ([string]$sha1line).Trim().ToLower() +} +if ($expected.Length -eq 40) { + $actual = (Get-FileHash -Algorithm SHA1 -Path $tarball).Hash.ToLower() + if ($actual -ne $expected) { + Remove-Item $tarball -Force -ErrorAction SilentlyContinue + Write-Error "fetch_cef: SHA-1 mismatch" + exit 1 + } + Info "fetch_cef: SHA-1 verified $actual" +} else { + Info "fetch_cef: WARNING no usable .sha1 digest; skipping integrity check" +} + +# Extract into a temp dir, then move into place, so a partial extract is never +# resolved by a concurrent build. +$tmp = Join-Path $CacheRoot ".extract-$PID" +if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp } +New-Item -ItemType Directory -Force $tmp | Out-Null +Info "fetch_cef: extracting" +& tar.exe -xf $tarball -C $tmp +if ($LASTEXITCODE -ne 0) { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + Write-Error "fetch_cef: tar extract failed" + exit 1 +} +$extracted = Join-Path $tmp $CefDistName +if (-not (Test-Path (Join-Path $extracted 'cmake'))) { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + Write-Error "fetch_cef: extracted tree missing cmake" + exit 1 +} +if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest -ErrorAction SilentlyContinue } +Move-Item $extracted $Dest +Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +Remove-Item $tarball -Force -ErrorAction SilentlyContinue + +if (Test-Path (Join-Path $Dest 'cmake')) { + Info "fetch_cef: CEF_ROOT resolved (downloaded): $Dest" + Write-Output $Dest + exit 0 +} +Write-Error "fetch_cef: post-download resolve failed" exit 1 From 0c050ec5f08bab5beeed44aeebd7cff3e4aee4ea Mon Sep 17 00:00:00 2001 From: wenkai fan Date: Thu, 23 Jul 2026 21:55:25 -0400 Subject: [PATCH 9/9] fix(windows): use system bsdtar by full path in fetch_cef.ps1 CI windows-build got past download+SHA but failed at 'tar extract': a bare tar.exe on the GitHub runner resolves to Git's bundled MSYS GNU tar, which reads the 'C:\...tarball' path as a host:path remote spec ('Cannot connect to C:'). Invoke %SystemRoot%\System32\tar.exe (libarchive/bsdtar) by full path; it handles .tar.bz2 + drive-letter paths. Verified: system bsdtar extracts the pinned tarball (exit 0, cmake/ present). Co-Authored-By: Claude Fable 5 --- .../flutter_cef_windows/native/cef_host/fetch_cef.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 b/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 index 5515a5b..44bec82 100644 --- a/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 +++ b/packages/flutter_cef_windows/native/cef_host/fetch_cef.ps1 @@ -73,7 +73,13 @@ $tmp = Join-Path $CacheRoot ".extract-$PID" if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp } New-Item -ItemType Directory -Force $tmp | Out-Null Info "fetch_cef: extracting" -& tar.exe -xf $tarball -C $tmp +# Use the Windows system bsdtar by FULL PATH. A bare `tar` on a CI runner +# resolves to Git's bundled MSYS GNU tar, which reads "C:\...tarball" as a +# host:path remote spec ("Cannot connect to C:"). System32\tar.exe is libarchive +# (bsdtar), handles .tar.bz2 and drive-letter paths natively (SPIKES.md S6). +$SystemTar = Join-Path $env:SystemRoot 'System32\tar.exe' +if (-not (Test-Path $SystemTar)) { $SystemTar = 'tar.exe' } +& $SystemTar -xf $tarball -C $tmp if ($LASTEXITCODE -ne 0) { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue Write-Error "fetch_cef: tar extract failed"