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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: No audit tags or line citations in comments
run: tool/check_comment_tags.sh
- uses: subosito/flutter-action@v2
with:
# Pin to the Flutter version the consumer (work_canvas) ships against
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ and memory stay bounded under recreate churn).
changes over broad refactors. The Dart, Swift, and CMake all carry dense
explanatory comments at the non-obvious seams — keep that up; a tricky fix
should explain *why*, not just *what*.
- **Say the reason in words.** Comments carry no audit or port-plan tags and
no `file.ext:<line>` citations; CI runs `tool/check_comment_tags.sh`, which
fails on them.
- **Document threading assumptions in the native layers.** The Swift/native code
spans the Flutter platform thread, CEF's UI thread, the GPU/Viz process, and
the socket relay. When you touch a method that must run on (or hand off to) a
Expand Down
12 changes: 6 additions & 6 deletions example/lib/channel_probe_shared.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// 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.
// on a SHARED cef_host (a named profile → one host for many sessions). 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> \
Expand Down
2 changes: 1 addition & 1 deletion example/lib/profile_probe.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// (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
// Results are written to %TEMP%\flutter_cef_profile_evidence\ as a JSON
// transcript (authoritative) and rendered on screen (for the screenshot), and a
// `CEF_PROBE_RESULT …` line is printed to stdout.
//
Expand Down
2 changes: 2 additions & 0 deletions packages/flutter_cef_macos/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Unreleased

* Removed the unused `native/cef_host/entitlements.browser.plist`, which
changes the `cef_host` input hash.
* Document-start scripts and create-time JS channels, sent in the browser's
`extra_info` and installed by the renderer in `OnContextCreated`.
* `hostGroup`: ephemeral sessions in one group share a `cef_host`.
Expand Down
56 changes: 28 additions & 28 deletions packages/flutter_cef_macos/macos/Classes/CdpRelay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import Foundation
import CryptoKit
import Security

/// The token-gated, per-tile-scoped localhost CDP relay (CEF-2a transport + CEF-2b
/// isolation). It re-exposes the CEF-1 CDP-over-pipe to a standard CDP client
/// (`agent-browser`) as a loopback HTTP+WebSocket endpoint, confined (when scoped) to
/// a single tile's CDP target — see the per-tile-isolation note below.
/// The token-gated, per-tile-scoped localhost CDP relay. It re-exposes cef_host's
/// CDP-over-pipe to a standard CDP client (`agent-browser`) as a loopback
/// HTTP+WebSocket endpoint, confined (when scoped) to a single tile's CDP target —
/// see the per-tile-isolation note below.
///
/// Why a hand-rolled server (no SwiftNIO/Starscream): the security review demanded
/// a minimal supply-chain surface, and the codebase already speaks raw BSD sockets
Expand Down Expand Up @@ -34,11 +34,11 @@ import Security
/// Strictly better than raw Chrome's fixed, always-open, multi-client
/// `--remote-debugging-port`.
///
/// Per-tile isolation (CEF-2b): the CDP pipe is browser-wide, so when constructed
/// Per-tile isolation: the CDP pipe is browser-wide, so when constructed
/// with a `scopeTargetId` the relay applies a Target-domain filter that exposes the
/// client ONLY that tile's target (and its sub-targets) — sibling tiles in the same
/// shared-profile process are hidden and unreachable. Constructed without a scope it
/// is a raw browser-level passthrough (CEF-2a; dev/test only).
/// is a raw browser-level passthrough (dev/test only).
final class CdpRelay {
/// Forwards a CDP message (one JSON line) to cef_host over the pipe. Captures the
/// host weakly so the host↔relay ownership (host strongly holds the relay) is not
Expand All @@ -54,7 +54,7 @@ final class CdpRelay {
private var running = false
private let stateLock = NSLock()

/// The single active ws client (CEF-2a supports one connection per relay; a
/// The single active ws client (one connection per relay; a
/// second upgrade is rejected, avoiding any fd-replacement double-close race).
/// Guarded by `clientLock`, which also serializes writes to it.
private var clientFd: Int32 = -1
Expand All @@ -78,10 +78,10 @@ final class CdpRelay {
/// or buggy client must not be able to make us allocate unbounded.
private static let maxFrame = 64 << 20

// CEF-2b: per-tile isolation filter. When `scopeTargetId` is set, the relay
// Per-tile isolation filter. When `scopeTargetId` is set, the relay
// exposes the client ONLY this CDP target (the opted-in tile) and its descendant
// sub-targets — sibling tiles in the same shared-profile process are hidden. When
// nil, the relay is a raw browser-level passthrough (CEF-2a; dev-only). The CDP
// nil, the relay is a raw browser-level passthrough (dev-only). The CDP
// pipe is browser-wide, so this filter is the per-tile security boundary.
private let scopeTargetId: String?
private var ourSessionId: String? // learned from our target's attachedToTarget
Expand All @@ -92,7 +92,7 @@ final class CdpRelay {
private var ourBrowserContextId: String?
private let filterLock = NSLock()

// CEF-2b multiplex: N relays share ONE browser-wide pipe with ONE CDP id space.
// Multiplex: N relays share ONE browser-wide pipe with ONE CDP id space.
// Session-routed traffic is demuxed by sessionId, but BROWSER-LEVEL commands
// (no sessionId — Playwright's connect handshake) would collide. We rewrite
// EVERY outgoing command id to a pipe id taken from the host's shared
Expand All @@ -103,7 +103,7 @@ final class CdpRelay {
private let pipeIds: CdpPipeIds
private var pipeIdToClientId: [Int: Int] = [:]
private let multiplexLock = NSLock()
// H2: this relay's OWN Target.attachToTarget pipe id (used to learn our page's CDP
// This relay's OWN Target.attachToTarget pipe id (used to learn our page's CDP
// session order-independently, instead of passively witnessing a fire-once
// browser-wide attachedToTarget event we may register too late to see), plus the
// client setAutoAttach ids to ack once we've attached. multiplexLock.
Expand Down Expand Up @@ -279,7 +279,7 @@ final class CdpRelay {
close(fd); return
}

// CEF-2a: one active client per relay. Reject a second concurrent upgrade
// One active client per relay. Reject a second concurrent upgrade
// (avoids any fd-replacement double-close race); the slot frees on disconnect.
clientLock.lock()
if clientFd >= 0 {
Expand Down Expand Up @@ -311,7 +311,7 @@ final class CdpRelay {
if owned { clientFd = -1 }
clientLock.unlock()
if owned { close(fd) }
// H2: the relay persists past this client — drop the in-flight attach so a late
// The relay persists past this client — drop the in-flight attach so a late
// self-attach response isn't delivered to the next client (a stale ack / spurious
// attachedToTarget), and its id mappings. A client that connected in the meantime
// already reset them (noteClientConnected) and may have state of its own, so this
Expand Down Expand Up @@ -477,7 +477,7 @@ final class CdpRelay {
assembling = !fin
if fin {
if assemblingText, let s = String(bytes: msg, encoding: .utf8) {
if let out = filterClientToPipe(s) { sendToPipe(rewriteOutgoingId(out)) } // CEF-2b scope filter + id remap
if let out = filterClientToPipe(s) { sendToPipe(rewriteOutgoingId(out)) } // scope filter + id remap
}
msg.removeAll(keepingCapacity: true)
assemblingText = false
Expand All @@ -495,26 +495,26 @@ final class CdpRelay {
}
}

/// Deliver a CDP message from the pipe to the connected client. Applies the CEF-2b
/// Deliver a CDP message from the pipe to the connected client. Applies the
/// multiplex demux + scope filter (drops sibling-tile traffic) before writing.
/// Called off the CDP reader thread.
func deliverToClient(_ json: String) {
guard let out = demuxPipeToClient(json) else { return } // dropped: not our tile
sendRawToClient(out)
}

/// CEF-2b pure decision seam (no socket IO — unit-testable): map one inbound pipe
/// Pure decision seam (no socket IO — unit-testable): map one inbound pipe
/// message to the bytes this relay should hand its client, or nil to DROP it.
///
/// Multiplex demux (scoped relays only): a pipe message with a top-level id and NO
/// method is a command RESPONSE, owned by exactly the relay that issued that unique
/// pipe id. Restore the client's original id, or drop if it's a sibling relay's
/// response. Events (method present) + the CEF-2a passthrough fall through to the
/// response. Events (method present) + the unscoped passthrough fall through to the
/// scope filter unchanged.
func demuxPipeToClient(_ json: String) -> String? {
if scopeTargetId != nil, let m = parseJson(json), m["method"] == nil,
let pipeId = m["id"] as? Int {
// H2: our OWN Target.attachToTarget response — learn the page session + hand the
// Our OWN Target.attachToTarget response — learn the page session + hand the
// client the synthesized attachedToTarget; never forward the raw response.
multiplexLock.lock(); let isSelfAttach = (pipeId == selfAttachPipeId); multiplexLock.unlock()
if isSelfAttach { handleSelfAttachResponse(m); return nil }
Expand All @@ -523,7 +523,7 @@ final class CdpRelay {
var restored = m; restored["id"] = clientId
return jsonString(restored)
}
return filterPipeToClient(json) // events / browser-level / CEF-2a passthrough
return filterPipeToClient(json) // events / browser-level / unscoped passthrough
}

/// Write a raw (already-filtered / self-originated) JSON text frame to the client.
Expand Down Expand Up @@ -572,7 +572,7 @@ final class CdpRelay {
}
}

// MARK: CEF-2b — per-tile Target-domain filter (deny-by-default, fail-closed)
// MARK: Per-tile Target-domain filter (deny-by-default, fail-closed)
//
// The CDP pipe is browser-wide, so this filter is THE per-tile security boundary —
// built to be safe against a hostile client, not just to support Playwright:
Expand Down Expand Up @@ -601,7 +601,7 @@ final class CdpRelay {
// page work routed by that top-level sessionId (+ nested sub-target sessions).

/// pipe → client. Returns the JSON to forward, or nil to drop. nil scope =
/// passthrough (CEF-2a, dev only). (Internal, not private, so the standalone
/// passthrough (dev only). (Internal, not private, so the standalone
/// filter unit test — CdpRelayFilterTests.swift — can exercise it directly.)
func filterPipeToClient(_ json: String) -> String? {
guard let tid = scopeTargetId else { return json }
Expand All @@ -618,7 +618,7 @@ final class CdpRelay {
let childSession = params?["sessionId"] as? String
if sid == nil { // browser-level attach of a top-level target (a tile)
guard attachedTid == tid else { return nil } // sibling tile — hide
// H2: our active Target.attachToTarget (beginPageAttach) triggers THIS real
// Our active Target.attachToTarget (beginPageAttach) triggers THIS real
// browser-level event for our page. FORWARD it as-is — it carries the genuine
// targetInfo (incl. a non-empty browserContextId that Playwright's
// CRBrowser._onAttachedToTarget asserts on; a synthesized empty one crashes the
Expand Down Expand Up @@ -690,7 +690,7 @@ final class CdpRelay {
}

// Session-routed command (flatten): allow only for our (allowed) sessions. The
// brief lock is released before any error IO (H4).
// brief lock is released before any error IO.
if let s = sid {
filterLock.lock()
let allowed = allowedSessions.contains(s)
Expand Down Expand Up @@ -736,7 +736,7 @@ final class CdpRelay {
guard (params?["flatten"] as? Bool) == true else {
sendClientError(id, "non-flatten setAutoAttach is not permitted"); return nil
}
// H2: a BROWSER-LEVEL setAutoAttach (no sessionId — reached here because the
// A BROWSER-LEVEL setAutoAttach (no sessionId — reached here because the
// sessionId branch above didn't claim it) is browser-context-wide. Forwarding
// it (a) lets us change a SIBLING tile's auto-attach params (cross-tile control
// leak) and (b) relies on a fire-once attachedToTarget storm we'll miss if we
Expand Down Expand Up @@ -804,7 +804,7 @@ final class CdpRelay {
sendClientJson(["id": id, "result": ["targetInfos": [info]]])
}

/// H2: ensure this relay knows its page's CDP session, then hand the client the page's
/// Ensure this relay knows its page's CDP session, then hand the client the page's
/// Target.attachedToTarget directly — independent of the browser-wide auto-attach
/// storm (fire-once, and which we must not forward: it would change sibling tiles'
/// auto-attach). If we already learned our session, synthesize now; otherwise issue
Expand Down Expand Up @@ -834,7 +834,7 @@ final class CdpRelay {
if let s = jsonString(cmd) { sendToPipe(s) }
}

/// H2: our scoped attachToTarget came back — record the page session and (if the
/// Our scoped attachToTarget came back — record the page session and (if the
/// client that issued it is still attached) ack every queued setAutoAttach. The page's
/// attachedToTarget is delivered by FORWARDING the real browser-level event (see the
/// filter), not synthesized here — so the client gets the genuine targetInfo. If the
Expand All @@ -855,7 +855,7 @@ final class CdpRelay {
for ack in acks { synthesizeOk(ack) }
}

/// H2: fabricate the page's Target.attachedToTarget for the client (flatten mode) so
/// Fabricate the page's Target.attachedToTarget for the client (flatten mode) so
/// Playwright/connectOverCDP discovers our page without us forwarding the browser-wide
/// auto-attach — mirrors synthesizeGetTargets' single-tile view.
private func synthesizeAttachedToTarget(sessionId: String) {
Expand Down Expand Up @@ -910,7 +910,7 @@ final class CdpRelay {
}

// Rewrite an outgoing command's top-level id to a globally-unique pipe id and
// record the mapping. No-op for the CEF-2a passthrough (nil scope) and for
// record the mapping. No-op for the unscoped passthrough (nil scope) and for
// messages without a top-level Int id (none, in practice clients only send
// commands). Called for client->pipe traffic only. Internal (not private) so the
// standalone filter tests can drive the rewrite↔demux round-trip directly.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ extension CefProfileHost {
cdpWriteLock.unlock()
}

/// CEF-2b: deliver one CDP pipe message to EVERY live relay. Snapshot the relays
/// Deliver one CDP pipe message to EVERY live relay. Snapshot the relays
/// under cdpHandlerLock, then call deliverToClient OUTSIDE the lock on each —
/// deliverToClient does blocking IO and takes the relay's own locks, so holding
/// cdpHandlerLock across it would invert the lock order (and could deadlock /
Expand All @@ -38,7 +38,7 @@ extension CefProfileHost {
for r in relays { r.deliverToClient(msg) }
}

/// CEF-2b: start (lazily) a token-gated CDP relay SCOPED to `browserId`'s tile and
/// Start (lazily) a token-gated CDP relay SCOPED to `browserId`'s tile and
/// return the brokered endpoint Campus hands an agent. Async: first resolves the
/// browser's CDP targetId (round-trip to cef_host), then creates a relay whose
/// Target-domain filter exposes only that tile, then starts it (so no client ever
Expand Down Expand Up @@ -84,7 +84,7 @@ extension CefProfileHost {
scopeTargetId: tid, pipeIds: self.cdpPipeIds)
guard relay.start() else { self.cdpHandlerLock.unlock(); completion(nil); return }
// Install the fan-out pipe → relays handler ONCE, when the first relay appears,
// CHAINING any prior handler (preserves the debug CEF-1 validation probe) rather
// CHAINING any prior handler (preserves the debug pipe-validation probe) rather
// than clobbering it. Subsequent relays just join cdpRelays; deliverCdpToRelays
// snapshots the dict per message, so it picks them up automatically.
if self.cdpRelays.isEmpty {
Expand All @@ -103,7 +103,7 @@ extension CefProfileHost {
("ws://127.0.0.1:\(r.port)/devtools/browser?token=\(r.token)", r.token, Int(r.port))
}

/// CEF-2b: resolve `browserId`'s CDP targetId via cef_host (Target.getTargetInfo).
/// Resolve `browserId`'s CDP targetId via cef_host (Target.getTargetInfo).
/// All waiters for that browserId fire exactly once — on the response or a 5s
/// timeout, whichever removes the entry first. Concurrent calls for the SAME
/// browserId COALESCE onto one in-flight resolve (a second call appends its waiter
Expand Down Expand Up @@ -171,7 +171,7 @@ extension CefProfileHost {
waiters.forEach { $0(nil) }
}

/// CEF-2a/b: tear down `browserId`'s relay (closes the listener + any client,
/// Tear down `browserId`'s relay (closes the listener + any client,
/// invalidates the token). Idempotent — a no-op if that tile has no relay. When
/// the LAST relay goes, drop the fan-out onCdpMessage too. The pipe itself stays
/// up (the tile keeps running). The relay is stopped OUTSIDE the lock: stop() may
Expand Down Expand Up @@ -223,7 +223,7 @@ extension CefProfileHost {
}
}

/// CEF-1 validation gate: prove the pipe round-trips end to end. Only when
/// Debug validation gate: prove the CDP pipe round-trips end to end. Only when
/// agent-control AND FLUTTER_CEF_DEBUG is set, install a temporary CDP handler
/// and send {"id":1,"method":"Browser.getVersion"}; the first response line is
/// NSLogged. Behind the debug env so it never runs in normal flow, and it
Expand Down
Loading
Loading