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
70 changes: 70 additions & 0 deletions lib/src/cef_web_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,76 @@ class CefWebController {
'setVisible', {'sessionId': sessionId, 'visible': visible});
}

/// Mute or unmute the page's audio output. Besides silencing it, a hidden
/// AND muted page regains Chromium's intensive wake-up throttling (audible
/// pages are exempt), so muting on hide keeps a background tile's timers
/// cheap. Policy is the caller's — muting is user-visible for media tiles.
Future<void> setAudioMuted(bool muted) => _channel
.invokeMethod('setAudioMuted', {'sessionId': sessionId, 'muted': muted});

/// Set the visible frame cadence: milliseconds between begin-frames (the OSR
/// frame clock). 16 ≈ 60fps (the default), 33 ≈ 30fps for a
/// visible-but-unengaged tile. Clamped natively to [8, 250]. Hidden views
/// produce no frames regardless (see [setVisible]).
Future<void> setFrameInterval(int milliseconds) => _channel.invokeMethod(
'setFrameInterval', {'sessionId': sessionId, 'ms': milliseconds});

bool _frozen = false;

/// Whether the native browser is currently frozen — torn down by [freeze]
/// while the texture keeps serving the last painted frame.
bool get isFrozen => _frozen;

/// Tear down the native browser — and, when it was the last live browser on
/// its profile, the entire cef_host process tree — while KEEPING the
/// texture, which continues to show the last painted frame. Reclaims
/// essentially the view's whole native cost (renderer process, compositor,
/// surfaces); use it for tiles culled long enough that a stale still image
/// is acceptable.
///
/// Page state (DOM, scroll, JS heap) is lost. Cookies/localStorage survive
/// for a named [profile] (disk-backed jar) but not for an ephemeral session.
/// [thaw] recreates the browser on the same texture. Returns false when
/// there was nothing to freeze (not created, already frozen, or the native
/// process was already gone).
Future<bool> freeze() async {
if (_disposed || textureId == null || _frozen) return false;
final ok = await _channel
.invokeMethod<bool>('freezeSession', {'sessionId': sessionId});
if (ok != true) return false;
_frozen = true;
// No host is left to answer in-flight round-trips.
_failPendingEvals('the session is frozen');
_failPendingCookies('the session is frozen');
return true;
}

/// Recreate the native browser for a [freeze]-d session on the SAME texture.
/// The frozen frame keeps showing until the new browser's first paint lands
/// (no flash). [url] overrides the original create URL — pass the page's
/// current address for fidelity; [html] (winning over [url], mirroring
/// [create]) re-authors the document directly, for consumers whose content
/// state lives host-side. Omitted, the browser reloads what [create] was
/// originally given. Returns false when the session wasn't frozen.
Future<bool> thaw({String? url, String? html}) async {
if (_disposed || !_frozen) return false;
final thawUrl = html != null ? _htmlDataUrl(html) : url;
final res = await _channel.invokeMapMethod<String, dynamic>(
'thawSession',
{'sessionId': sessionId, if (thawUrl != null) 'url': thawUrl});
if (res == null) return false;
_frozen = false;
// Same post-bind re-assert as _createSession: the thawed browser's fresh
// slot is default-shown, so push the consumer's last explicit visibility
// (a culled tile thawed for e.g. room preload must come back hidden). JS
// channels re-flush natively in attach().
if (_visibilityExplicitlySet) {
_channel.invokeMethod(
'setVisible', {'sessionId': sessionId, 'visible': _lastVisible});
}
return true;
}

/// Start (or advance) a find-in-page search for [text]. Results arrive on
/// [onFindResult]. Pass `findNext: true` to move to the next/previous match of
/// the same query; toggle [forward] for direction.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class CefProfileHost {
// processGone) instead of silently mis-parsing frames into frozen/blank tiles; the
// skew vectors are FLUTTER_CEF_HOST overrides, stale from-source builds, and stale
// embedded copies (the content-hash fetch can't drift on the normal path).
static let protocolVersion: UInt8 = 4
static let protocolVersion: UInt8 = 5

// Profile identity / config.
let profileId: String
Expand Down
40 changes: 40 additions & 0 deletions packages/flutter_cef_macos/macos/Classes/CefWebSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ final class CefWebSession: NSObject, FlutterTexture {
private static let opLoadTrusted: UInt8 = 0x34
private static let opSetVisible: UInt8 = 0x35
private static let opOpenAuthWindow: UInt8 = 0x39
private static let opSetAudioMuted: UInt8 = 0x3a // {u8 muted} -> CefBrowserHost::SetAudioMuted
private static let opSetPumpInterval: UInt8 = 0x3b // {u16 BE ms} visible begin-frame cadence

// Event callbacks (fired off the main thread). The registrar relays each to a
// Dart channel message.
Expand Down Expand Up @@ -207,6 +209,29 @@ final class CefWebSession: NSObject, FlutterTexture {
for name in channels { sendFrame(Self.opAddChannel, Array(name.utf8)) }
}

/// Freeze support: unbind from the (already-unregistered, about-to-close)
/// browser while KEEPING the texture and the adopted pixelBuffer — its
/// CVPixelBuffer retains the producer's IOSurface (a kernel object), so the
/// tile keeps serving its last painted frame even after the whole cef_host
/// process exits. Also resets the establishment counters: the thaw's
/// recreated browser must look FRESH to the create pacer and first-present
/// watchdog (both gate on the FIRST present, which a stale presentCount
/// would never report). Call AFTER CefProfileHost.removeBrowser — once the
/// browser is out of the host's map no reader thread touches these fields,
/// so the pacer counters are safe to reset unlocked.
func detachForFreeze() {
bufferLock.lock()
hidden = true
resizeInFlight = false
bufferLock.unlock()
host = nil
browserId = 0
firstPresentSeen = false
presentCount = 0
lastPresentNs = 0
livenessNudgedAt = 0
}

// MARK: FlutterTexture

private var diagCopyCount = 0 // DIAG
Expand Down Expand Up @@ -401,6 +426,21 @@ final class CefWebSession: NSObject, FlutterTexture {
sendFrame(Self.opSetVisible, [visible ? 1 : 0])
}

/// Mute/unmute the page's audio. Besides silencing it, a hidden AND muted
/// page regains Chromium's intensive wake-up throttling (audible pages are
/// exempt), so muting on hide keeps a background tile's timers cheap.
func setAudioMuted(_ muted: Bool) {
sendFrame(Self.opSetAudioMuted, [muted ? 1 : 0])
}

/// Set the visible begin-frame pump interval (ms) — the OSR frame clock for
/// this browser. 16 ≈ 60fps (default), 33 ≈ 30fps; cef_host clamps to
/// [8, 250]. Hidden tiles produce no frames regardless.
func setFrameInterval(_ ms: Int) {
let clamped = UInt16(clamping: ms)
sendFrame(Self.opSetPumpInterval, [UInt8(clamped >> 8), UInt8(clamped & 0xff)])
}

func find(_ text: String, forward: Bool, matchCase: Bool, findNext: Bool) {
var p: [UInt8] = [forward ? 1 : 0, matchCase ? 1 : 0, findNext ? 1 : 0]
p.append(contentsOf: Array(text.utf8))
Expand Down
112 changes: 109 additions & 3 deletions packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
// C2: per-session create args, so when a shared host turns out to be ad-hoc and
// refuses its named profile we can re-home EVERY session on it onto ephemeral hosts
// (not just the last one whose closure was installed), preserving each session's
// url + schemes + agent-control transport.
private var sessionCreateArgs: [String: (url: String, allowedSchemes: String, agentControl: Bool)] = [:]
// url + schemes + agent-control transport. Also the freeze/thaw recipe: thaw
// respawns a host of the ORIGINAL kind (profile / enableCdp) and recreates the
// browser at the original url unless the thaw call overrides it.
private var sessionCreateArgs: [String: (url: String, allowedSchemes: String,
agentControl: Bool, profile: String?,
enableCdp: Bool)] = [:]
// Sessions whose native browser was torn down by freezeSession while the
// session + texture live on serving the last painted frame. Not in
// sessionHost/sessionKey while frozen (their host may be gone entirely).
private var frozenSessions: Set<String> = []
// C2: named profiles a running ad-hoc host already refused — future creates for them
// go straight to ephemeral instead of racing onto a doomed shared host.
private var adhocBlockedProfiles: Set<String> = []
Expand Down Expand Up @@ -138,6 +146,14 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
case "setVisible":
withSession(args) { $0.setVisible(args["visible"] as? Bool ?? true) }
result(nil)
case "setAudioMuted":
withSession(args) { $0.setAudioMuted(args["muted"] as? Bool ?? true) }
result(nil)
case "setFrameInterval":
withSession(args) { $0.setFrameInterval(args["ms"] as? Int ?? 16) }
result(nil)
case "freezeSession": freezeSession(args, result)
case "thawSession": thawSession(args, result)
case "find":
if let text = args["text"] as? String {
withSession(args) {
Expand Down Expand Up @@ -439,7 +455,8 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
sessions[sessionId] = session
sessionHost[sessionId] = host
sessionKey[sessionId] = key
sessionCreateArgs[sessionId] = (url, allowedSchemes, agentControl) // C2 re-home
// C2 re-home + freeze/thaw recipe.
sessionCreateArgs[sessionId] = (url, allowedSchemes, agentControl, profile, enableCdp)
result([
"textureId": session.textureId, "width": width, "height": height,
"cdpPort": host.cdpPort,
Expand Down Expand Up @@ -690,6 +707,8 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
sessionHost[id] = nil
sessionKey[id] = nil
sessionCreateArgs[id] = nil
// A frozen session has no host on record; the guard below releases it.
frozenSessions.remove(id)
guard let host = host else {
// No host on record (shouldn't happen) — just release the session.
session.dispose()
Expand All @@ -705,6 +724,93 @@ public class FlutterCefPlugin: NSObject, FlutterPlugin {
}
}

/// Freeze one session: close its native browser — and, when that was the
/// host's last live browser, the whole cef_host process tree — while KEEPING
/// the session + texture. The consumer-held CVPixelBuffer retains the
/// producer's IOSurface (a kernel object), so the tile keeps serving its
/// last painted frame; thawSession recreates a browser onto the same
/// session. Returns false when there is nothing to freeze (unknown session,
/// already frozen, or its host already died — processGone handles that path).
private func freezeSession(_ a: [String: Any], _ result: @escaping FlutterResult) {
dispatchPrecondition(condition: .onQueue(.main))
guard let id = a["sessionId"] as? String, let session = sessions[id],
!frozenSessions.contains(id), let host = sessionHost[id] else {
result(false)
return
}
let key = sessionKey[id]
// Same F.3 ordering discipline as disposeSession: removeBrowser unregisters
// under lock (reader stops routing to this session), and a last-browser
// host is fully shut down (reader joined) BEFORE the session's unlocked
// establishment counters are reset in detachForFreeze.
let remaining = host.removeBrowser(session.browserId)
if remaining == 0 {
host.shutdown()
if let key = key { profiles[key] = nil }
}
session.detachForFreeze()
frozenSessions.insert(id)
sessionHost[id] = nil
sessionKey[id] = nil
result(true)
}

/// Thaw a frozen session: resolve/spawn a host of the ORIGINAL kind (same
/// profile / transport, from sessionCreateArgs) and recreate a browser onto
/// the SAME session + texture — attach() re-binds the wire id and re-flushes
/// JS channels, and the create pacer / first-present watchdog treat it as a
/// fresh establishment. The frozen frame keeps showing until the new
/// browser's first paint adopts (no flash). `url` overrides the original
/// create URL (pass the page's current address for fidelity). Returns nil
/// when there is nothing to thaw.
private func thawSession(_ a: [String: Any], _ result: @escaping FlutterResult) {
dispatchPrecondition(condition: .onQueue(.main))
guard let id = a["sessionId"] as? String, frozenSessions.contains(id),
let session = sessions[id], let args = sessionCreateArgs[id] else {
result(nil)
return
}
guard let cefHost = resolveCefHostPath() else {
result(FlutterError(code: "no_cef_host",
message: "cef_host not found (set FLUTTER_CEF_HOST)",
details: nil))
return
}
let url = (a["url"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? args.url
let profile = args.profile
let namedProfile = profile != nil && !profile!.isEmpty
// Mirrors create(): skip a named profile a running ad-hoc host already refused.
let effectiveNamed = namedProfile && !adhocBlockedProfiles.contains(profile ?? "")
let (profileDir, isEphemeral) = resolveProfileDir(effectiveNamed ? profile : nil)
let key = effectiveNamed ? profile! : "~ephemeral~" + id
guard let host = resolveOrSpawnHost(
key: key, profileDir: profileDir, isEphemeral: isEphemeral,
cefHostPath: cefHost, enableCdp: args.enableCdp,
allowedSchemes: args.allowedSchemes, agentControl: args.agentControl)
else {
result(FlutterError(code: "spawn_failed",
message: "failed to spawn cef_host", details: nil))
return
}
// F.5 wiring, same as create(): an ad-hoc host refusing the named profile
// re-homes every session on it onto ephemeral hosts.
if effectiveNamed {
host.onInsecureProfileRefused = { [weak self, weak host] in
DispatchQueue.main.async {
guard let self = self, let host = host, let prof = profile else { return }
self.respawnHostEphemeral(host, refusedProfile: prof)
}
}
}
frozenSessions.remove(id)
_ = host.createBrowser(session, url: url, allowedSchemes: args.allowedSchemes)
sessionHost[id] = host
sessionKey[id] = key
// sessionCreateArgs keeps the ORIGINAL url: a later thaw without an
// override falls back to it again (the thaw url is transient).
result(["textureId": session.textureId])
}

/// Resolve the on-disk cache dir for a profile. F.4: a null/empty profile gets
/// a unique throwaway temp dir (ephemeral, removed on host shutdown); a named
/// profile gets a stable 0700 dir under Application Support that survives
Expand Down
32 changes: 30 additions & 2 deletions packages/flutter_cef_macos/native/cef_host/main.mm
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
// stale embedded copy). BUMP THIS on any semantic change to the kOp wire protocol
// below, together with CefProfileHost.protocolVersion (Swift side) — the two must
// stay equal. Hosts predating the handshake send a 1-byte payload and read as v0.
constexpr uint8_t kCefHostProtocolVersion = 4;
constexpr uint8_t kCefHostProtocolVersion = 5;

// ---- Opcodes ----
constexpr uint8_t kOpPresent = 0x01;
Expand Down Expand Up @@ -163,6 +163,8 @@
constexpr uint8_t kOpInvalidate = 0x37; // {} C1: force a repaint (Invalidate PET_VIEW) to re-kick a stalled first frame
constexpr uint8_t kOpEditCommand = 0x38; // {u8 cmd} run a focused-frame edit command (0=copy 1=cut 2=paste 3=selectAll 4=undo 5=redo)
constexpr uint8_t kOpOpenAuthWindow = 0x39; // {utf8 url} open a windowed Chrome-runtime browser for a WebAuthn/Touch ID ceremony the OSR tile can't host (shares the tile's cookie jar)
constexpr uint8_t kOpSetAudioMuted = 0x3a; // {u8 muted} -> CefBrowserHost::SetAudioMuted; a hidden AND muted page regains intensive wake-up throttling (audible pages are exempt)
constexpr uint8_t kOpSetPumpInterval = 0x3b; // {u16 BE ms} visible begin-frame cadence for this slot, clamped to [8, 250]; hidden slots stay on the 100ms no-op poll

// ---- Shared runtime state ----
// Atomic: the reader thread reads it (ReadAll), SendFrame on any thread reads it,
Expand Down Expand Up @@ -256,6 +258,11 @@
// DoSetVisible); `begin_frame_pump_started` guards a double-start. UI-thread only.
bool visible = true;
bool begin_frame_pump_started = false;
// Visible begin-frame cadence (ms) for this slot — the OSR frame clock.
// 16 ≈ 60fps (default); the host clamps kOpSetPumpInterval to [8, 250] so a
// consumer can drop an unengaged tile to ~30fps without touching hidden
// gating. UI-thread only, like `visible`.
int pump_interval_ms = 16;
// F-1/F-2: a dpr/screen-info change that lands while the slot is HIDDEN is deferred —
// the begin-frame pump is gated off while hidden, so notifying + painting now would
// composite into a surface nothing displays and mislead the Swift resize watchdog into
Expand Down Expand Up @@ -313,7 +320,7 @@ void PumpBeginFrame(uint32_t wire_id) {
" paints=" + std::to_string(slot->diag_paint_count) +
" visible=" + std::to_string(slot->visible ? 1 : 0));
CefPostDelayedTask(TID_UI, base::BindOnce(&PumpBeginFrame, wire_id),
slot->visible ? 16 : 100);
slot->visible ? slot->pump_interval_ms : 100);
}

// Process-wide Metal context for the GPU-blit present path (CompositeMetalLocked). One device +
Expand Down Expand Up @@ -2017,6 +2024,14 @@ void DoSetVisible(const std::shared_ptr<Slot>& slot, bool visible) {
slot->browser->GetHost()->SendExternalBeginFrame();
}
}
void DoSetAudioMuted(const std::shared_ptr<Slot>& slot, bool muted) {
if (slot->browser) slot->browser->GetHost()->SetAudioMuted(muted);
}
void DoSetPumpInterval(const std::shared_ptr<Slot>& slot, int ms) {
// Clamp: <8ms buys nothing over 60fps begin-frames + risks pump starvation;
// >250ms visible would read as a frozen tile.
slot->pump_interval_ms = ms < 8 ? 8 : (ms > 250 ? 250 : ms);
}
void DoFind(const std::shared_ptr<Slot>& slot, const std::string& text,
bool forward, bool match_case, bool find_next) {
if (slot->browser)
Expand Down Expand Up @@ -2494,6 +2509,19 @@ void IpcReadLoop() {
CefPostTask(TID_UI, base::BindOnce(&DoSetVisible, slot, vis));
break;
}
case kOpSetAudioMuted: {
if (!slot) break;
bool muted = plen >= 1 ? p[0] != 0 : true;
CefPostTask(TID_UI, base::BindOnce(&DoSetAudioMuted, slot, muted));
break;
}
case kOpSetPumpInterval: {
if (!slot) break;
if (plen < 2) break;
int ms = (int{p[0]} << 8) | int{p[1]};
CefPostTask(TID_UI, base::BindOnce(&DoSetPumpInterval, slot, ms));
break;
}
case kOpFind: {
if (!slot) break;
if (plen < 3) break;
Expand Down
Loading