diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..105d8dc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Native sources are hashed by tool/cef_host_hash.sh to key the prebuilt +# cef_host artifact in GCS. The hash is over RAW BYTES, so a Windows checkout +# with core.autocrlf=true would compute a different digest than the macOS +# publisher for the same commit — forever missing the prebuilt. Pin LF for +# everything the hash covers (and shell scripts, which break under CRLF). +packages/**/native/** text eol=lf +packages/**/tool/*.sh text eol=lf +*.sh text eol=lf +# Windows batch files MUST be CRLF (cmd.exe misparses LF-only .bat, dropping +# characters). This overrides the native/** LF pin above for *.bat only — the +# hash key stays deterministic because CRLF is pinned on BOTH platforms. +*.bat text eol=crlf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f937cd6..9459b78 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,7 @@ jobs: flutter analyze (cd packages/flutter_cef_platform_interface && flutter pub get && flutter analyze) (cd packages/flutter_cef_macos && flutter pub get && flutter analyze) + (cd packages/flutter_cef_windows && flutter pub get && flutter analyze) (cd example && flutter pub get && flutter analyze) - name: Test run: flutter test @@ -35,3 +36,33 @@ jobs: # swiftc suite runs without Xcode/CocoaPods. (A regression here previously went # unnoticed precisely because CI did not run it.) run: ./packages/flutter_cef_macos/test/run_filter_tests.sh + + # Windows: analyze the endorsed windows implementation + build the example so + # the ~5.5k lines of native plugin/cef_host/CDP-relay code (profiles, cookies, + # JS bridge, agent control) are actually compiled by CI, not merged blind. + windows-build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.38.8 + channel: stable + - run: flutter --version + - name: Analyze (windows implementation package) + shell: bash + run: | + flutter pub get + flutter analyze + (cd packages/flutter_cef_windows && flutter pub get && flutter analyze) + - name: Dart tests (includes the Windows key/accelerator + verb twins) + run: flutter test + - name: Build example for Windows (compiles the plugin + cef_host) + shell: bash + # CEF (~200 MB) is fetched by the plugin CMake from the pinned dist; if + # the fetch/build is unavailable on the runner this step surfaces it + # rather than letting native regressions merge unbuilt. + run: | + cd example + flutter pub get + flutter build windows --debug diff --git a/PORTING.md b/PORTING.md index 4665088..19027c2 100644 --- a/PORTING.md +++ b/PORTING.md @@ -57,7 +57,12 @@ lives in the Flutter app's process and: - Spawns **one `cef_host` subprocess per profile** (`--ipc --cdp-port --profile-dir --allowed-schemes --ephemeral` as argv), then creates **N browsers in that process** via `opCreateBrowser` frames carrying `url, width, - height, dpr, iosurface-id` + a `browserId`. `--profile-dir` is always passed; a + height, dpr` + a `browserId`. **The create carries no surface id** — the + surface model is *producer-allocates*: `cef_host` mints (and on resize + re-mints) the shared surface itself and announces it to the host via + `opPresent` frames carrying `{surface-id, srcW, srcH}` (the host promotes a + frame only when its dims match the expected size — see + `docs/OSR_SCALE_MISMATCH.md`). `--profile-dir` is always passed; a persistent (named) profile points it at a stable cache dir, while an ephemeral profile points it at a unique throwaway temp dir and adds `--ephemeral` (so the host's CDP / mock-keychain guards fire only for a real persistent profile — @@ -85,11 +90,11 @@ reused verbatim. Only these seams are macOS-specific (file:line are into | Seam | macOS reference | What your platform provides | | --- | --- | --- | -| **Shared surface** — receive painted frames and present them to the host texture. CEF delivers either software `OnPaint` (CPU buffer) or `OnAcceleratedPaint` (a shared GPU texture handle). | `OnPaint` / `OnAcceleratedPaint` + the `IOSurface*` ops (~lines 346–450); `g_surface` (~133). macOS uses an IOSurface-backed `CVPixelBuffer`. | **Windows**: a shared D3D11 texture / DXGI keyed-mutex handle. **Linux**: shared memory or a DMA-buf, presented via the platform texture. Look the surface up by the `--iosurface-id`-equivalent handle the host passes. | +| **Shared surface** — receive painted frames and present them to the host texture. CEF delivers either software `OnPaint` (CPU buffer) or `OnAcceleratedPaint` (a shared GPU texture handle). | `OnPaint` / `OnAcceleratedPaint` + the `IOSurface*` ops (~lines 346–450); `g_surface` (~133). macOS uses an IOSurface-backed `CVPixelBuffer`. | **Windows**: a shared D3D11 texture. Note (verified against CEF 144 `cef_types_win.h` + Flutter engine `external_texture_d3d.cc`): CEF's `OnAcceleratedPaint` delivers an **NT** handle (no keyed mutex, non-owning, pool-released when the callback returns — open it synchronously via `ID3D11Device1::OpenSharedResource1`), while Flutter's ANGLE compositor requires a **legacy** `D3D11_RESOURCE_MISC_SHARED` handle (`EGL_D3D_TEXTURE_2D_SHARE_HANDLE_ANGLE`); the two are mutually exclusive on one texture, so exactly one `CopyResource` NT→legacy is structurally mandatory (macOS also blits — `CompositeMetalLocked`). **Linux**: shared memory or a DMA-buf, presented via the platform texture. Either way the model is **producer-allocates**: `cef_host` mints the shared surface and announces it via `opPresent` — the host does NOT pass a surface id in the create (the macOS field name differs too: `shared_texture_io_surface` vs Windows `shared_texture_handle`, so the accelerated-paint glue takes a per-OS `#if`, not a typedef). | | **IPC transport** — a framed bidirectional byte stream to the host. Wire format: 4-byte big-endian length prefix, then `[u32 browserId][opcode][payload]` (`browserId 0` = process-level: `opReady`, process logs, `opShutdown`). | `WriteAll`/`ReadAll` (207–235), `SendFrame` (~237–249), `ConnectUnixSocket` (1341+), the read loop (~1140). Unix domain socket. | **Windows**: a named pipe. **Linux**: a Unix domain socket (reuse as-is). Keep the same length-prefixed framing and the `browserId` dimension. | | **App / run loop** — give CEF a host application + message loop, and a per-profile cache dir. | `CefHostApplication : NSApplication` (304–330); `@autoreleasepool` + `sharedApplication` (1420+); `--profile-dir` → `settings.root_cache_path` cache; `_NSGetExecutablePath` (1335). | **Windows/Linux**: the platform's CEF message-loop integration (`CefRunMessageLoop` or OS loop) and the caller-supplied `--profile-dir` (per-profile, persistent) as the cache path. | -| **Agent-control CDP pipe (optional)** — for `agentControl`, the host launches `cef_host` so Chromium's `--remote-debugging-pipe` (NUL-framed CDP JSON) rides **inherited file descriptors 3=read / 4=write** instead of a TCP port. | `CefProfileHost.launchViaPosixSpawn` (Swift): two `pipe()` pairs `dup2`'d onto fds 3/4 via `posix_spawn_file_actions`, parent ends marked `FD_CLOEXEC`; `cef_host` appends `remote-debugging-pipe` in `OnBeforeCommandLineProcessing`. The relay (`CdpRelay.swift`) + per-tile Target filter are platform-agnostic; only the fd placement is OS-specific. | **Linux**: reuse the `posix_spawn_file_actions` fd-3/4 recipe verbatim. **Windows**: a different model — `CreatePipe` + `STARTUPINFOEX` `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` with `bInheritHandles`, and Chromium takes the two pipe **HANDLEs** as `--remote-debugging-pipe` args (not fds 3/4). The default (non-agent) launch also needs a native `exec`/`CreateProcess` (Foundation.Process is macOS-only). | -| **Sandbox** — bring the child processes into the Chromium sandbox in a signed/release build. | `process_helper.mm`: `CefScopedSandboxContext` (release only); `settings.no_sandbox` toggled by `CEF_HOST_ADHOC` (1426/1433). | **Windows**: link the `cef_sandbox` static lib + `CefScopedSandboxContext`. **Linux**: the SUID / user-namespace sandbox helper. | +| **Agent-control CDP pipe (optional)** — for `agentControl`, the host launches `cef_host` so Chromium's `--remote-debugging-pipe` (NUL-framed CDP JSON) rides **inherited file descriptors 3=read / 4=write** instead of a TCP port. | `CefProfileHost.launchViaPosixSpawn` (Swift): two `pipe()` pairs `dup2`'d onto fds 3/4 via `posix_spawn_file_actions`, parent ends marked `FD_CLOEXEC`; `cef_host` appends `remote-debugging-pipe` in `OnBeforeCommandLineProcessing`. The relay (`CdpRelay.swift`) + per-tile Target filter are platform-agnostic; only the fd placement is OS-specific. | **Linux**: reuse the `posix_spawn_file_actions` fd-3/4 recipe verbatim. **Windows**: a different model — `CreatePipe` + `SetHandleInformation(HANDLE_FLAG_INHERIT)` + `STARTUPINFOEX` `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`, and Chromium takes the two inherited pipe **HANDLE values** via the separate switch `--remote-debugging-io-pipes=,` (uint32-serialized HANDLEs, passed **in addition to** the bare `--remote-debugging-pipe` — verified against `content_switches.cc` at chromium 144.0.7559.254; `--remote-debugging-pipe` itself takes no arguments on any platform). The default (non-agent) launch also needs a native `exec`/`CreateProcess` (Foundation.Process is macOS-only). | +| **Sandbox** — bring the child processes into the Chromium sandbox in a signed/release build. | `process_helper.mm`: `CefScopedSandboxContext` (release only); `settings.no_sandbox` toggled by `CEF_HOST_ADHOC` (1426/1433). | **Windows**: CEF 144 ships **no `cef_sandbox.lib`** — the current model (`cef_sandbox_win.h`, CEF issue #3824) is: build your app as a **DLL** exporting `RunWinMain`/`RunConsoleMain` and launch it through the prebuilt `bootstrap.exe`/`bootstrapc.exe` from the CEF distribution, which establishes the sandbox before entering your code. **Linux**: the SUID / user-namespace sandbox helper. | | **Framework / resource path** — point CEF at the CEF binary distribution. | `CefScopedLibraryLoader::LoadInMain`/`LoadInHelper`; `framework_dir_path` / `main_bundle_path` (1453/1466). | The equivalent paths for your bundle layout. | | **Build + bundle + sign** | `native/build_cef_host.sh` (CMake, `CEF_MULTI_PROCESS` / `CEF_HOST_ADHOC` flags), `tool/bundle_cef_host.sh`. | A platform build that produces `cef_host` + the CEF runtime, and a bundling step into the host app. | diff --git a/README.md b/README.md index 6b0739c..471b158 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ final cookies = await c.getCookies(); // read/enumerate; pass url: to scope c.deleteCookie(url: 'https://example.com/', name: 'sid'); c.clearCookies(); c.scrollTo(0, 200); c.scrollBy(0, -50); await c.getScrollPosition(); c.clearLocalStorage(); await c.getTitle(); await c.getUserAgent(); -c.onDownload = (suggestedName) {}; // downloads land in ~/Downloads +c.onDownload = (suggestedName) {}; // a download started (a native Save panel is shown) // open the Chrome DevTools inspector for this view in its own window c.openDevTools(); @@ -223,11 +223,15 @@ final c = CefWebController(profile: 'work'); CefWebView(url: startUrl, controller: c); ``` -- **Persistent.** A named profile is stored on disk at - `//flutter_cef/profiles/` (the directory - is created `0700`, owner-only, and the profile name is sanitized to - `[A-Za-z0-9._-]`). CEF is started with `persist_session_cookies` on, so a - login survives `cef_host` and host-app relaunch. +- **Persistent.** A named profile is stored on disk — on macOS at + `//flutter_cef/profiles/`, on Windows at + `%LOCALAPPDATA%\flutter_cef\profiles\`. The directory is created + owner-only (`0700` on macOS; a current-user-SID-protected DACL on Windows) and + the profile name is sanitized to `[A-Za-z0-9._-]`. CEF is started with + `persist_session_cookies` on, so a login survives `cef_host` and host-app + relaunch. On Windows the `profiles\` root is **not** namespaced by app, so every + flutter_cef app for a user shares it — a `profile: 'work'` in two apps resolves + to the same directory (co-locate only mutually-trusting apps on a shared name). - **Shared.** Every view constructed with the same non-null `profile` is served by **one `cef_host` process with one cookie jar** — they share one login. Cookie writes are therefore process-wide: `clearCookies()` / @@ -273,6 +277,17 @@ persist under the mock keychain anyway (dev convenience only — do not ship it) The downgrade leaks nothing: the refusal happens before any browser is created, so nothing is ever written to the persistent directory. +**Windows is different — no downgrade, and no signing requirement.** On Windows, +OSCrypt encrypts the cookie store with **DPAPI**, which is **always available and +independent of code signing**. There is no mock-keychain state, so the +ad-hoc-downgrade rule above has no Windows analogue: a named `profile:` on Windows +simply persists, encrypted at rest, in every build. The trade-off is that DPAPI's +user-tier protection is **same-user-readable** — any process running as the same +Windows user can decrypt the store (`CryptUnprotectData`). This is **weaker than +the macOS Keychain**: at-rest protection covers other users and offline disk theft +(with BitLocker as the full-disk backstop), but **not** other same-user processes. +As on macOS, `localStorage` / IndexedDB are stored unencrypted regardless. + Two further caveats even under a signed build: `localStorage` and IndexedDB are **not** encrypted by OSCrypt (they sit in the profile directory as plaintext — FileVault is again the backstop), and **CDP (`enableCdp`) is incompatible with a @@ -366,12 +381,15 @@ SIGABRT'd the instant the hardware is touched. Declare ### A named `profile:` silently behaves as ephemeral (login doesn't persist) -The default ad-hoc dev build (`CEF_HOST_ADHOC=ON`) has only a mock keychain and -can't encrypt cookies at rest, so it **downgrades a named profile to ephemeral** -rather than persisting a login to a plaintext store (it logs a warning). For real -persistence ship a signed release build (`CEF_HOST_ADHOC=OFF`, real -Keychain/OSCrypt). For dev only, set `FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1` — -**never ship that override.** +**macOS only.** The default ad-hoc dev build (`CEF_HOST_ADHOC=ON`) has only a mock +keychain and can't encrypt cookies at rest, so it **downgrades a named profile to +ephemeral** rather than persisting a login to a plaintext store (it logs a +warning). For real persistence ship a signed release build (`CEF_HOST_ADHOC=OFF`, +real Keychain/OSCrypt). For dev only, set `FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1` — +**never ship that override.** On **Windows** this never happens: OSCrypt uses +DPAPI, which is always available and signing-independent, so a named profile +persists in every build (see [Secrets at rest](#secrets-at-rest) for the +same-user-readable caveat). ### `onProcessGone` fires with reason `'locked'` @@ -438,7 +456,13 @@ Next: `flutter_cef_platform_interface` + `flutter_cef_macos`); a new platform is a sibling `flutter_cef_` package. The CEF logic + IPC protocol are portable; each OS supplies its own host plugin + shared-texture / transport / sandbox - glue. See [`PORTING.md`](PORTING.md) for the full contract and seam map. + glue. See [`PORTING.md`](PORTING.md) for the full contract and seam map. A + Windows port (`flutter_cef_windows`) is in progress: the Dart controller / + widget surface is already cross-platform (the JS bridge, JS dialogs, + find-in-page, content zoom, and downloads all speak the same + method-channel/wire protocol on both OSes — see + `packages/flutter_cef_windows/native/cef_host/PROTOCOL.md`), with the Windows + host's sandbox + code-signing story still pending. ## Credits diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..9547b20 --- /dev/null +++ b/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "bd7a4a6b5576630823ca344e3e684c53aa1a0f46" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46 + base_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46 + - platform: windows + create_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46 + base_revision: bd7a4a6b5576630823ca344e3e684c53aa1a0f46 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/lib/agentcontrol_probe.dart b/example/lib/agentcontrol_probe.dart new file mode 100644 index 0000000..2e41f66 --- /dev/null +++ b/example/lib/agentcontrol_probe.dart @@ -0,0 +1,304 @@ +// Agent-control (P9) end-to-end probe — drives the Windows token-gated loopback +// CDP relay through a real CDP WebSocket client, entirely in-process (no +// Playwright / node / python needed: dart:io's WebSocket does the RFC-6455 +// handshake and forwards a custom Authorization header). +// +// It: +// 1. creates a CefWebController with agentControl:true, loads a page, +// 2. calls enableAgentControl() -> {wsUrl, token, port} and writes it to +// C:\tmp\cef_agentcontrol.json, +// 3. GATE 2a (401): connects a ws client WITHOUT the token -> asserts 401, +// 4. GATE 2b (evaluate=2): connects WITH `Authorization: Bearer `, +// drives Target.getTargets -> attachToTarget(flatten) -> Runtime.evaluate +// ("1+1") and asserts the result is 2, +// 5. GATE 3 (teardown): disableAgentControl() then asserts a fresh connect +// to the port fails (the relay is gone). +// +// Run (cef_host must be built + staged; CEF cached): +// cd example +// run -d windows -t lib/agentcontrol_probe.dart +// Result: `CEF_AGENTCONTROL_RESULT …` on stdout + C:\tmp\cef_agentcontrol.json. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _resultPath = r'C:\tmp\cef_agentcontrol.json'; + +void main() => runApp(const ProbeApp()); + +class ProbeApp extends StatefulWidget { + const ProbeApp({super.key}); + @override + State createState() => _ProbeAppState(); +} + +class _ProbeAppState extends State { + final CefWebController _c = CefWebController(); + bool _started = false; + String _status = 'starting…'; + final List _log = []; + + void _note(String s) { + // ignore: avoid_print + print('CEF_AGENTCONTROL $s'); + _log.add(s); + if (mounted) setState(() => _status = s); + } + + @override + void initState() { + super.initState(); + _c.onPageStarted = (url) { + _note('onPageStarted url=$url'); + if (!_started) { + _started = true; + // Give the page target a beat to commit, then drive the gates. + Future.delayed(const Duration(seconds: 2), _run); + } + }; + _c.onConsoleMessage = (m) => _note('console: ${m.message}'); + } + + Future _run() async { + final out = { + 'enable_ok': false, + 'gate_401': false, + 'gate_evaluate_2': false, + 'gate_teardown': false, + 'log': _log, + }; + try { + // ---- 1. enableAgentControl -> {wsUrl, token, port} ---- + final ep = await _c.enableAgentControl(); + if (ep == null) { + _note('FAIL enableAgentControl returned null'); + _write(out); + return; + } + out['enable_ok'] = true; + out['wsUrl'] = ep.wsUrl; + out['token'] = ep.token; + out['port'] = ep.port; + _note('enableAgentControl OK port=${ep.port} wsUrl=${ep.wsUrl}'); + + final base = 'ws://127.0.0.1:${ep.port}/devtools/browser'; + + // ---- GATE 2a: upgrade WITHOUT a token -> HTTP 401 ---- + // A raw upgrade request surfaces the exact status line (dart:io's + // WebSocketException hides the code). Cross-check that the SAME request + // WITH the token upgrades (101), proving the token is what gates it. + final statusNoToken = await _rawUpgradeStatusLine(ep.port); + final statusWithToken = + await _rawUpgradeStatusLine(ep.port, token: ep.token); + final is401 = statusNoToken.contains('401'); + out['gate_401'] = is401 && statusWithToken.contains('101'); + out['gate_401_no_token_status'] = statusNoToken; + out['gate_401_with_token_status'] = statusWithToken; + _note('GATE 401 no-token -> "$statusNoToken"'); + _note('GATE 401 with-token -> "$statusWithToken"'); + _note('GATE 401 ${out['gate_401'] == true ? 'PASS' : 'FAIL'}'); + + // ---- GATE 2b: connect WITH the bearer token, evaluate 1+1 == 2 ---- + // The with-token raw probe above briefly held the single-client slot; + // give the relay a beat to release it so this real upgrade isn't 503'd. + await Future.delayed(const Duration(milliseconds: 500)); + final ws = await WebSocket.connect( + base, + headers: {'Authorization': 'Bearer ${ep.token}'}, + ); + _note('connected with bearer token'); + final cdp = _CdpConn(ws); + + final targets = await cdp.send('Target.getTargets'); + final infos = (targets['result']?['targetInfos'] as List?) ?? const []; + _note('Target.getTargets -> ${infos.length} target(s)'); + final page = infos.cast>().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/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 52b0982..13a492a 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'; @@ -43,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 @@ -67,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 @@ -93,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( @@ -197,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(); } @@ -243,16 +299,30 @@ and committed text — including emoji — should appear intact.

tooltip: 'getCookies() for the current page', onPressed: _dumpCookies, ), + IconButton( + icon: const Icon(Icons.forum_outlined), + tooltip: 'Post a message from the page over a JS channel', + onPressed: _pingChannel, + ), + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Find in page (also ⌘F / Ctrl+F)', + onPressed: _openFindBar, + ), IconButton( icon: const Icon(Icons.bug_report_outlined), tooltip: 'openDevTools()', onPressed: _controller.openDevTools, ), - IconButton( - icon: const Icon(Icons.emoji_emotions_outlined), - tooltip: 'showEmojiPicker() (focus a field first)', - onPressed: _emojiPicker, - ), + // macOS-only: showEmojiPicker drives the AppKit Character + // Palette; there is no supported Win32 equivalent (PLAN §6), + // so the button is hidden per-platform. + if (Platform.isMacOS) + IconButton( + icon: const Icon(Icons.emoji_emotions_outlined), + tooltip: 'showEmojiPicker() (focus a field first)', + onPressed: _emojiPicker, + ), IconButton( icon: const Icon(Icons.keyboard), tooltip: 'Load the IME / text-input test page', @@ -294,6 +364,7 @@ and committed text — including emoji — should appear intact.

? const LinearProgressIndicator(minHeight: 2) : const SizedBox(height: 2), ), + if (_findVisible) _buildFindBar(), ValueListenableBuilder( valueListenable: _controller.title, builder: (_, title, _) => Align( @@ -320,6 +391,9 @@ and committed text — including emoji — should appear intact.

url: _startUrl, controller: _controller, focusNode: _webFocus, + // ⌘F / Ctrl+F opens the host's find bar (the view has none of + // its own); it drives controller.find / stopFind. + onFind: _openFindBar, allowedSchemes: _allowedSchemes, // Persistent, shared profile: login survives relaunch and is // shared by every view with the same name. Null (default) is an @@ -338,6 +412,52 @@ and committed text — including emoji — should appear intact.

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