From 96d2a8cace6e0e5386aedb0d6456fe5be7177d44 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Thu, 23 Jul 2026 20:21:16 -0700 Subject: [PATCH 1/2] feat(session): freeze/thaw tile discard + audio-mute + frame-cadence ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freeze() tears down one session's native browser — and, when it was the profile's last live browser, the whole cef_host process tree — while the FlutterTexture keeps serving the last painted frame (the consumer-held CVPixelBuffer retains the producer's IOSurface, a kernel object that survives the producer's exit). thaw() recreates a browser onto the SAME session/texture through the normal create pacer + establishment watchdogs, reusing the C2 re-home attach() path; the frozen frame shows until the new first paint adopts, so there is no flash. detachForFreeze() resets the establishment counters (firstPresentSeen / presentCount / lastPresentNs / livenessNudgedAt) so the thawed browser reads as a FRESH establishment to the pacer and first-present watchdog — a stale presentCount would pin the pacer slot until the timeout backstop and repeat-fire paintStalled. Side levers, policy left to the consumer: - setAudioMuted (kOpSetAudioMuted 0x3a): a hidden AND muted page regains Chromium's intensive wake-up throttling (audible pages are exempt). - setFrameInterval (kOpSetPumpInterval 0x3b): per-slot visible begin-frame cadence, clamped [8, 250]ms — 33ms halves an unengaged tile's paint/blit CPU. kCefHostProtocolVersion 4 -> 5 (new ops; the opReady handshake refuses skewed prebuilts). Co-Authored-By: Claude Fable 5 --- lib/src/cef_web_controller.dart | 68 +++++++++++ .../macos/Classes/CefProfileHost.swift | 2 +- .../macos/Classes/CefWebSession.swift | 40 +++++++ .../macos/Classes/FlutterCefPlugin.swift | 112 +++++++++++++++++- .../flutter_cef_macos/native/cef_host/main.mm | 32 ++++- 5 files changed, 248 insertions(+), 6 deletions(-) diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 4475d01..23a8686 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -864,6 +864,74 @@ 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 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 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 freeze() async { + if (_disposed || textureId == null || _frozen) return false; + final ok = await _channel + .invokeMethod('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 (or a regenerated authored document) for fidelity; + /// omitted, the browser reloads what [create] was originally given. + /// Returns false when the session wasn't frozen. + Future thaw({String? url}) async { + if (_disposed || !_frozen) return false; + final res = await _channel.invokeMapMethod( + 'thawSession', + {'sessionId': sessionId, if (url != null) 'url': url}); + 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. diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift index af17455..4369331 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift @@ -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 diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift index a381c9f..b5ee446 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift @@ -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. @@ -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 @@ -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)) diff --git a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift index de1c4b0..1336ae9 100644 --- a/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift +++ b/packages/flutter_cef_macos/macos/Classes/FlutterCefPlugin.swift @@ -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 = [] // 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 = [] @@ -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) { @@ -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, @@ -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() @@ -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 diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index cb55af9..791ab5f 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -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; @@ -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, @@ -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 @@ -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 + @@ -2017,6 +2024,14 @@ void DoSetVisible(const std::shared_ptr& slot, bool visible) { slot->browser->GetHost()->SendExternalBeginFrame(); } } +void DoSetAudioMuted(const std::shared_ptr& slot, bool muted) { + if (slot->browser) slot->browser->GetHost()->SetAudioMuted(muted); +} +void DoSetPumpInterval(const std::shared_ptr& 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, const std::string& text, bool forward, bool match_case, bool find_next) { if (slot->browser) @@ -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; From c7c7200962f2d4be6e56f7e799d33a570e409ce3 Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Fri, 24 Jul 2026 09:33:59 -0700 Subject: [PATCH 2/2] feat(session): thaw({html}) for host-side-authored documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent_ui discard path regenerates its document from host-side state, so thaw needs the same html->data-URL entry create/loadHtml have (html wins over url, mirroring create). Purely Dart-side: converts to a data: URL and rides the existing thawSession url param — no native change. Without this, a consumer that froze an authored-document session can only thaw by url, losing its live state. Co-Authored-By: Claude Fable 5 --- lib/src/cef_web_controller.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/src/cef_web_controller.dart b/lib/src/cef_web_controller.dart index 23a8686..4f1c3fb 100644 --- a/lib/src/cef_web_controller.dart +++ b/lib/src/cef_web_controller.dart @@ -911,14 +911,16 @@ class CefWebController { /// 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 (or a regenerated authored document) for fidelity; - /// omitted, the browser reloads what [create] was originally given. - /// Returns false when the session wasn't frozen. - Future thaw({String? url}) async { + /// 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 thaw({String? url, String? html}) async { if (_disposed || !_frozen) return false; + final thawUrl = html != null ? _htmlDataUrl(html) : url; final res = await _channel.invokeMapMethod( 'thawSession', - {'sessionId': sessionId, if (url != null) 'url': url}); + {'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