Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,19 @@ CefWebView(url: startUrl, controller: c);
by **one `cef_host` process with one cookie jar** — they share one login.
Cookie writes are therefore process-wide: `clearCookies()` /
`deleteCookie()` clear the cookie for *all* views in the profile, by design.
- **One trust domain per profile.** Because a named profile is one process with
one cookie jar, sessions sharing a profile are **not isolated from each
other**. The cookie jar is common (a page in one view can read another's
cookies via `getCookies`), and registered JS channels
(`addJavaScriptChannel`) are process-global, so a page in one view can observe
a channel name another view registered. Per-message *routing* stays
per-session — a channel message is delivered only to the view whose page sent
it (`OnQuery` stamps the originating browser), so this is an information-
sharing boundary, not a message-spoofing one — but the rule is the same:
**co-locate only mutually-trusting content on one profile.** For
mutually-distrusting content (e.g. arbitrary third-party pages from different
authors), give each its own `profile`, or use the ephemeral default — each
gets its own process, cookie jar, and channel namespace.

### Secrets at rest

Expand Down
156 changes: 156 additions & 0 deletions example/lib/channel_probe.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// page->host JS-channel probe — reproduces the Campus "peer edit never reaches
// the host" symptom in isolation (no Campus, no multiplayer).
//
// A Campus PEER mounts a FRESH CefWebController, registers a JS channel
// (addJavaScriptChannel), loads an HTML doc ON the first onPageStarted (session
// ready), and the page calls window.<channel>.postMessage(...). Live, that never
// invokes the Dart onMessageReceived (host->page works; page->host is dead).
// This probe exercises exactly that path and reports — via document.title (an
// INDEPENDENT page->host signal, OnTitleChange, NOT the cefQuery channel under
// test) — whether window.probeHost / window.cefQuery exist and whether a direct
// cefQuery succeeds or fails.
//
// Run (cef_host must be built; CEF cached):
// FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \
// flutter run -d macos -t lib/channel_probe.dart
// Result: `CEF_CHANNEL_PROBE_RESULT …` stdout + /tmp/cef_channel_probe.json.
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_channel_probe.json';

const _probeHtml = r'''<!doctype html><meta charset="utf-8">
<body style="font:20px system-ui;margin:24px;color:#111;background:#fff">
<h2>page&rarr;host channel probe</h2>
<button id="b" style="font:18px system-ui;padding:10px 18px">postMessage to host</button>
<div id="log" style="margin-top:16px;color:#555"></div>
<script>
var log = document.getElementById('log');
function rep(s) { document.title = s; if (log) log.textContent = s; }
function probe(tag) {
var hasHost = !!(window.probeHost && window.probeHost.postMessage);
var hasCq = !!window.cefQuery;
if (hasHost) { try { window.probeHost.postMessage('shim:' + tag); } catch (e) {} }
if (hasCq) {
try {
window.cefQuery({
request: 'ch:probeHost:direct:' + tag,
persistent: false,
onSuccess: function (r) { rep('host=' + (hasHost?'Y':'N') + ' cq=Y SUCCESS @' + tag); },
onFailure: function (c, m) { rep('host=' + (hasHost?'Y':'N') + ' cq=Y FAIL ' + c + ' ' + m); }
});
} catch (e) { rep('host=' + (hasHost?'Y':'N') + ' cq=Y THREW ' + e); }
} else {
rep('host=' + (hasHost?'Y':'N') + ' cq=N @' + tag);
}
}
document.getElementById('b').onclick = function () { probe('click'); };
probe('load');
var n = 0;
var t = setInterval(function () { probe('auto' + n); if (++n > 18) clearInterval(t); }, 700);
</script>''';

void main() => runApp(const ProbeApp());

class ProbeApp extends StatefulWidget {
const ProbeApp({super.key});
@override
State<ProbeApp> createState() => _ProbeAppState();
}

class _ProbeAppState extends State<ProbeApp> {
// Fresh, ephemeral controller — exactly like a Campus peer mirror.
final CefWebController _c = CefWebController();
final List<String> _received = [];
bool _hostGot = false;
bool _loaded = false;
String _status = 'starting…';

@override
void initState() {
super.initState();
_c.addJavaScriptChannel('probeHost', onMessageReceived: (m) {
_received.add(m);
_hostGot = true;
// ignore: avoid_print
print('CEF_CHANNEL_PROBE host received: $m');
});
_c.title.addListener(() {
// ignore: avoid_print
print('CEF_CHANNEL_PROBE title=${_c.title.value}');
});
// Mirror _CefSurfaceView on a peer: load the doc on the first onPageStarted
// (the initial about:blank — session is up by then), NOT before it exists.
_c.onPageStarted = (url) {
// ignore: avoid_print
print('CEF_CHANNEL_PROBE onPageStarted url=$url');
if (!_loaded) {
_loaded = true;
_c.loadHtmlString(_probeHtml);
}
};
_c.onPageFinished = (url) {
// ignore: avoid_print
print('CEF_CHANNEL_PROBE onPageFinished url=$url');
};
WidgetsBinding.instance.addPostFrameCallback((_) => _run());
}

Future<void> _run() async {
setState(() => _status = 'waiting for page + channel…');
final deadline = DateTime.now().add(const Duration(seconds: 18));
while (!_hostGot && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 200));
}
final out = <String, dynamic>{
'pass': _hostGot,
'received_count': _received.length,
'received': _received.take(5).toList(),
};
try {
File(_resultPath).writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(out),
);
} catch (_) {}
// ignore: avoid_print
print('CEF_CHANNEL_PROBE_RESULT ${jsonEncode(out)}');
if (mounted) {
setState(() => _status = _hostGot
? 'PASS — host received ${_received.length} message(s)'
: 'FAIL — host received NOTHING (page->host channel dead)');
}
}

@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('channel probe — $_status',
style: const TextStyle(fontWeight: FontWeight.w600)),
),
Expanded(
child: CefWebView(url: 'about:blank', controller: _c),
),
],
),
),
),
);
}
}
170 changes: 170 additions & 0 deletions example/lib/channel_probe_shared.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// SHARED-HOST page->host channel probe — the single-controller channel_probe.dart
// PASSES, so the basic channel works. Campus's peer tile differs in that it runs
// on a SHARED cef_host (a named profile → one host for many sessions, per the
// #138 consolidation). This probe mounts TWO controllers on ONE named profile
// (= one shared cef_host), each registering the SAME channel name 'probeHost'
// (exactly like every Campus agent_ui uses 'campusHost'), each page posting a
// DISTINCT tag. It verifies each controller's handler receives ONLY its own tag
// — i.e. OnQuery's slot_->browser_id routing stays correct across sessions.
//
// Run:
// FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \
// flutter run -d macos -t lib/channel_probe_shared.dart
// Result: `CEF_SHARED_PROBE_RESULT …` + /tmp/cef_channel_probe_shared.json.
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_cef/flutter_cef.dart';

const _profile = 'chprobe';
const _resultPath = '/tmp/cef_channel_probe_shared.json';

String _html(String tag) => '''<!doctype html><meta charset="utf-8">
<body style="font:18px system-ui;margin:20px"><h3>session $tag</h3><div id=log></div>
<script>
var log = document.getElementById('log');
function probe() {
var h = !!(window.probeHost && window.probeHost.postMessage);
var q = !!window.cefQuery;
document.title = '$tag host=' + (h?'Y':'N') + ' cq=' + (q?'Y':'N');
// shim path (under test)
if (h) { try { window.probeHost.postMessage('$tag-shim'); } catch(e){} }
// direct cefQuery path (bypasses the shim) — proves transport vs shim.
if (q) { try { window.cefQuery({request:'ch:probeHost:$tag-direct',persistent:false,onSuccess:function(){},onFailure:function(){}}); } catch(e){} }
}
probe();
var n=0, t=setInterval(function(){ probe(); if(++n>18) clearInterval(t); }, 700);
</script>''';

void main() => runApp(const SharedProbeApp());

class SharedProbeApp extends StatefulWidget {
const SharedProbeApp({super.key});
@override
State<SharedProbeApp> createState() => _SharedProbeAppState();
}

class _SharedProbeAppState extends State<SharedProbeApp> {
final CefWebController _a = CefWebController(profile: _profile);
final CefWebController _b = CefWebController(profile: _profile);
final Set<String> _aRecv = {};
final Set<String> _bRecv = {};
bool _aLoaded = false, _bLoaded = false;
String _status = 'starting…';

void _wire(CefWebController c, String tag, Set<String> recv,
bool Function() loaded, void Function() setLoaded) {
c.addJavaScriptChannel('probeHost', onMessageReceived: (m) {
recv.add(m);
// ignore: avoid_print
print('CEF_SHARED_PROBE $tag-handler received: $m');
});
c.title.addListener(() {
// ignore: avoid_print
print('CEF_SHARED_PROBE $tag title=${c.title.value}');
});
c.onPageStarted = (url) {
// ignore: avoid_print
print('CEF_SHARED_PROBE $tag onPageStarted ${url.length > 30 ? url.substring(0, 30) : url}');
if (!loaded()) {
setLoaded();
c.loadHtmlString(_html(tag));
}
};
c.onPageFinished = (url) {
// ignore: avoid_print
print('CEF_SHARED_PROBE $tag onPageFinished ${url.length > 30 ? url.substring(0, 30) : url}');
};
}

@override
void initState() {
super.initState();
_wire(_a, 'A', _aRecv, () => _aLoaded, () => _aLoaded = true);
_wire(_b, 'B', _bRecv, () => _bLoaded, () => _bLoaded = true);
WidgetsBinding.instance.addPostFrameCallback((_) => _run());
}

Future<void> _run() async {
setState(() => _status = 'waiting for both sessions…');
final deadline = DateTime.now().add(const Duration(seconds: 20));
while ((_aRecv.isEmpty || _bRecv.isEmpty) &&
DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 200));
}
// Correct routing = A's handler got ONLY A-tags, B's got ONLY B-tags.
final aOk = _aRecv.any((m) => m.startsWith('A')) &&
!_aRecv.any((m) => m.startsWith('B'));
final bOk = _bRecv.any((m) => m.startsWith('B')) &&
!_bRecv.any((m) => m.startsWith('A'));
final out = <String, dynamic>{
'pass': aOk && bOk,
'a_handler_received': _aRecv.toList(),
'b_handler_received': _bRecv.toList(),
'a_ok': aOk,
'b_ok': bOk,
};
try {
File(_resultPath).writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(out),
);
} catch (_) {}
// ignore: avoid_print
print('CEF_SHARED_PROBE_RESULT ${jsonEncode(out)}');
if (mounted) {
setState(() => _status = (aOk && bOk)
? 'PASS — both sessions routed correctly'
: 'FAIL — A=$_aRecv B=$_bRecv (a_ok=$aOk b_ok=$bOk)');
}
}

@override
void dispose() {
_a.dispose();
_b.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('shared-host channel probe — $_status',
style: const TextStyle(fontWeight: FontWeight.w600)),
),
Expanded(
child: Row(
children: [
Expanded(
child: CefWebView(
key: const ValueKey('A'),
url: 'about:blank',
controller: _a,
profile: _profile),
),
const VerticalDivider(width: 1),
Expanded(
child: CefWebView(
key: const ValueKey('B'),
url: 'about:blank',
controller: _b,
profile: _profile),
),
],
),
),
],
),
),
),
);
}
}
28 changes: 24 additions & 4 deletions packages/flutter_cef_macos/macos/Classes/CdpRelay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ final class CdpRelay {
/// Guarded by `clientLock`, which also serializes writes to it.
private var clientFd: Int32 = -1
private let clientLock = NSLock()
// Per-relay SERIAL queue for client writes. sendRawToClient hops onto this so a
// stuck client (writeFrameLocked blocks up to SO_SNDTIMEO ~2s) can't stall the
// SHARED CDP reader thread — which fans delivery to every relay — and thereby
// starve sibling agent-controlled tiles on the same host. Serial = frame order
// for this client is preserved; per-relay = one wedged client is isolated.
private let clientWriteQueue = DispatchQueue(label: "flutter_cef.cdp-relay.client-write")

/// In-flight connection (handler-thread) count, guarded by `stateLock`. Caps
/// concurrent handshakes so a flood of half-open connections can't exhaust
Expand Down Expand Up @@ -506,10 +512,18 @@ final class CdpRelay {
}

/// Write a raw (already-filtered / self-originated) JSON text frame to the client.
/// Hops onto the per-relay serial queue so the SHARED CDP reader thread is never
/// blocked by a stuck client; clientFd is re-validated under clientLock inside, so
/// a concurrent stop()/handler-close makes this a graceful no-op (or a failed
/// write that writeFrameLocked already handles).
private func sendRawToClient(_ json: String) {
clientLock.lock(); defer { clientLock.unlock() }
guard clientFd >= 0 else { return }
writeFrameLocked(clientFd, opcode: 0x1, payload: Array(json.utf8))
let payload = Array(json.utf8)
clientWriteQueue.async { [weak self] in
guard let self = self else { return }
self.clientLock.lock(); defer { self.clientLock.unlock() }
guard self.clientFd >= 0 else { return }
self.writeFrameLocked(self.clientFd, opcode: 0x1, payload: payload)
}
}

/// Write a server frame (unmasked). Takes clientLock itself (callers that already
Expand Down Expand Up @@ -808,9 +822,15 @@ final class CdpRelay {
// crashes the daemon). Only reached on RECONNECT, where the page is already attached
// so no fresh event fires and ourBrowserContextId is already cached.
filterLock.lock(); let bctx = ourBrowserContextId; filterLock.unlock()
// Playwright's CRBrowser._onAttachedToTarget asserts a NON-EMPTY
// browserContextId; synthesizing one with "" crashes the daemon. If we never
// captured the real id for this relay (the page's own attach event hadn't
// fired yet), SKIP the synthesized event rather than emit a poisoned one — the
// real Target.attachedToTarget will carry the correct id when it arrives.
guard let bctx = bctx, !bctx.isEmpty else { return }
let info: [String: Any] = ["targetId": tid, "type": "page", "title": "", "url": "",
"attached": true, "canAccessOpener": false,
"browserContextId": bctx ?? ""]
"browserContextId": bctx]
sendClientJson(["method": "Target.attachedToTarget",
"params": ["sessionId": sessionId, "targetInfo": info,
"waitingForDebugger": false]])
Expand Down
Loading
Loading