diff --git a/lib/src/cef_web_view.dart b/lib/src/cef_web_view.dart index 019e12e..cd3647d 100644 --- a/lib/src/cef_web_view.dart +++ b/lib/src/cef_web_view.dart @@ -1,3 +1,4 @@ + import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -229,6 +230,9 @@ class _CefWebViewState extends State } if (_textureId != null && _lastSize != size) { _lastSize = size; + // Resize on every layout change. The native session (CefWebSession) flow-controls the + // sends to cef_host's paint rate — it keeps one resize in flight and coalesces to the + // latest size — so the page reflows live during the drag without us pacing here. _controller.resize(w, h, dpr: dpr); } } diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift index 3d9e12e..e27bc41 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift @@ -39,6 +39,7 @@ final class CefWebSession: NSObject, FlutterTexture { private static let opNewWindow: UInt8 = 0x0d private static let opPointer: UInt8 = 0x10 private static let opResize: UInt8 = 0x11 + private static let opInvalidate: UInt8 = 0x37 // us -> cef_host: force a repaint (re-kick a stuck resize) private static let opKey: UInt8 = 0x12 private static let opFindResult: UInt8 = 0x0e private static let opJsDialog: UInt8 = 0x0f @@ -110,6 +111,28 @@ final class CefWebSession: NSObject, FlutterTexture { private var ioSurface: IOSurfaceRef? private var pixelBuffer: CVPixelBuffer? + // Resize-flash fix: on resize we point cef_host at a fresh (zero-filled) surface but + // keep SERVING the old `pixelBuffer` to Flutter until cef_host has actually painted + // the new one — otherwise Flutter composites the blank surface for the frames before + // the async cross-process repaint lands. `pendingBuffer` is the new buffer, promoted + // to `pixelBuffer` in handleFrame(opPresent) when a present arrives tagged with + // `pendingSurfaceId` (the new surface's id). All under bufferLock. + private var pendingBuffer: CVPixelBuffer? + private var pendingSurfaceId: UInt32 = 0 + // Resize flow-control: keep at most ONE resize in flight (sent, not yet promoted by its + // present). cef_host coalesces rapid resizes and only paints the latest, so sending at the + // full drag rate left every present tagged with an already-superseded surface id → the + // texture promoted only at drag pauses (~1-2s). Instead send one resize, wait for its + // present, then send the latest size requested since — so every paint promotes and the page + // reflows at cef_host's actual rate. All guarded by bufferLock. + private var resizeInFlight = false + private var pendingRequestedW = 0 + private var pendingRequestedH = 0 + private var resizeSentAtNs: UInt64 = 0 + // Bumped on every sendResize. The resize watchdog captures it and bails if a newer resize + // has since gone out — so during a smoothly-advancing drag the watchdog is a no-op, and it + // only acts when a resize wedges (generation stops advancing because no present came). + private var resizeGen: UInt64 = 0 private let bufferLock = NSLock() /// The live IOSurface id this session's buffer is backed by, or 0 before @@ -161,23 +184,109 @@ final class CefWebSession: NSObject, FlutterTexture { func resize(width newW: Int, height newH: Int) { let w = max(1, newW), h = max(1, newH) bufferLock.lock() - let unchanged = (w == width && h == height) + // Always record the latest requested size; it's what maybeSendNextResize sends when the + // in-flight resize promotes. + pendingRequestedW = w + pendingRequestedH = h + let blocked = resizeInFlight + let same = (w == width && h == height) bufferLock.unlock() - if unchanged { return } - // H4: create the new surface OUTSIDE the lock (expensive), then publish surface + - // new dims ATOMICALLY in one bufferLock section — so a concurrent host read - // (sendCreate's createSnapshot on the reader thread) can never see the new surface - // with the old dims. Released before sendFrame — no bufferLock→writeLock nest. + // While a resize is still painting, just record the latest size (above). Its present sends + // the next one (maybeSendNextResize); if cef_host drops that paint, the resizeWatchdog + // re-kicks it. This one-in-flight pacing keeps the page reflowing at cef_host's actual rate + // instead of racing ahead (which tagged every present with an already-superseded surface id + // → froze mid-drag). NOTE: no inline timeout here — racing ahead on a slow/heavy page is + // exactly what desynced the presents and left the page stuck; the watchdog handles wedges. + if blocked || same { return } + sendResize(w, h) + } + + /// Allocate the new surface, point cef_host at it, and send the resize — marking it + /// in-flight so the next size waits for this one's present (see resize()/maybeSendNextResize). + /// Only ever called on the main thread (resize / maybeSendNextResize), so sendFrame stays + /// serialized. + private func sendResize(_ w: Int, _ h: Int) { + // Create the new surface OUTSIDE the lock (expensive). H4: publish surface id + new + // dims ATOMICALLY in one bufferLock section so a concurrent host read (createSnapshot + // on the reader thread) can't see new dims with the old surface id. guard let (surf, buffer) = makeBuffers(w, h) else { return } - let sid = publishBuffers(surf, buffer, w, h) + let sid = IOSurfaceGetID(surf) guard sid != 0 else { return } + // Resize-flash fix: point the host at the NEW surface (ioSurface drives surfaceId / + // createSnapshot → cef_host paints into it) and adopt the new dims, but DON'T swap + // the live `pixelBuffer` — keep serving the OLD surface to Flutter (the old + // CVPixelBuffer retains its IOSurface, so it stays valid) until cef_host paints the + // new one. The new buffer is promoted in handleFrame(opPresent) on the matching present. + bufferLock.lock() + ioSurface = surf + pendingBuffer = buffer + pendingSurfaceId = sid + width = w + height = h + resizeInFlight = true + resizeSentAtNs = nowNs() + resizeGen &+= 1 + let gen = resizeGen + bufferLock.unlock() var payload = [UInt8]() appendU32(&payload, UInt32(w)) appendU32(&payload, UInt32(h)) appendU32(&payload, sid) sendFrame(Self.opResize, payload) + // Re-kick this resize if its present never lands (see resizeWatchdog). During a smoothly + // advancing drag gen keeps moving and this no-ops; it only bites a genuine wedge. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + self?.resizeWatchdog(gen) + } + } + + /// Re-kick a wedged resize. Bails immediately if a newer resize has gone out (gen advanced) + /// or this one already promoted (not in flight). Otherwise the post-resize present never + /// matched — nudge cef_host to repaint the pending surface (opInvalidate), retrying every + /// ~80ms. After ~0.3s of failed re-kicks, FORCE-promote the pending surface: cef_host's + /// begin-frame pump has been painting into it the whole time, so it holds the correct new-size + /// content — a single dropped/mis-tagged present (the failure mode on a STATIC page like + /// flutter.dev, which produces exactly one frame per resize) can't leave the tile wedged. + /// Main-thread only, so sendFrame / textureFrameAvailable stay serialized. + private func resizeWatchdog(_ gen: UInt64) { + bufferLock.lock() + let active = resizeInFlight && gen == resizeGen + let givenUp = active && (nowNs() &- resizeSentAtNs) > 300_000_000 + var promotedTid: Int64 = 0 + if givenUp { + if let pending = pendingBuffer { + pixelBuffer = pending + pendingBuffer = nil + pendingSurfaceId = 0 + promotedTid = textureId + } + resizeInFlight = false + } + bufferLock.unlock() + if givenUp { + if promotedTid != 0 { registry?.textureFrameAvailable(promotedTid) } + maybeSendNextResize() + return + } + guard active else { return } + sendFrame(Self.opInvalidate, []) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + self?.resizeWatchdog(gen) + } + } + + /// Main-thread follow-up after a present promotes: if the page was resized again while the + /// last resize painted, send the newest size now so the reflow keeps pace with the drag. + private func maybeSendNextResize() { + bufferLock.lock() + let w = pendingRequestedW, h = pendingRequestedH + let need = !resizeInFlight && w > 0 && (w != width || h != height) + bufferLock.unlock() + if need { sendResize(w, h) } } + private func nowNs() -> UInt64 { DispatchTime.now().uptimeNanoseconds } + func navigate(_ url: String) { sendFrame(Self.opNavigate, Array(url.utf8)) } @@ -308,6 +417,11 @@ final class CefWebSession: NSObject, FlutterTexture { textureId = 0 pixelBuffer = nil ioSurface = nil + pendingBuffer = nil // drop any un-promoted resized surface + pendingSurfaceId = 0 + resizeInFlight = false + pendingRequestedW = 0 + pendingRequestedH = 0 bufferLock.unlock() if tid != 0 { registry?.unregisterTexture(tid) } } @@ -349,7 +463,8 @@ final class CefWebSession: NSObject, FlutterTexture { NSLog("[cef] CVPixelBufferCreateWithIOSurface failed rc=\(rc)") return nil } - NSLog("[cef] allocated IOSurface id=\(IOSurfaceGetID(surf)) \(pw)x\(ph) (logical \(w)x\(h) @\(dpr)x) stride=\(bytesPerRow)") + // NOTE: no success log here — makeBuffers runs once PER resize step (~60/s during a drag), + // and a synchronous NSLog on that hot path measurably hurts resize smoothness. return (surf, buffer) } @@ -387,6 +502,20 @@ final class CefWebSession: NSObject, FlutterTexture { // Read textureId under bufferLock — dispose() writes it under the same // lock on the main thread, so this avoids a data race on the Int64. bufferLock.lock() + // Resize-flash fix: the present is tagged with the surface id cef_host painted + // (BE u32). If it's our pending (resized) surface, promote it to live now — we + // kept serving the old surface until this exact frame so Flutter never sampled the + // blank new one. A present for the old/current surface just advances the frame. + if payload.count >= 4 { + let psid = (UInt32(payload[0]) << 24) | (UInt32(payload[1]) << 16) + | (UInt32(payload[2]) << 8) | UInt32(payload[3]) + if let pending = pendingBuffer, psid != 0, psid == pendingSurfaceId { + pixelBuffer = pending + pendingBuffer = nil + pendingSurfaceId = 0 + resizeInFlight = false // its paint landed; free to send the next size + } + } let tid = textureId bufferLock.unlock() if tid != 0 { @@ -399,6 +528,9 @@ final class CefWebSession: NSObject, FlutterTexture { let live = self.textureId self.bufferLock.unlock() if live != 0 { self.registry?.textureFrameAvailable(live) } + // A resize may have promoted above — send the newest requested size now so the + // reflow advances at cef_host's paint rate (on main, so sendFrame stays serialized). + self.maybeSendNextResize() } } case Self.opLog: diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index d2b4305..975bbfe 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -46,6 +46,7 @@ #import #import +#import #include #include @@ -223,6 +224,13 @@ // so reusing a fixed id silently drops the 2nd+ probe, which hung a re-enable of // agent-control (disable then enable again). UI-thread only, like dialog_next. int target_info_msg = 0; + + // External begin-frame pump (see PumpBeginFrame). With external_begin_frame_enabled, CEF's + // internal frame timer is OFF — frames are produced ONLY when we drive them — so a per-slot + // pump calls SendExternalBeginFrame on a cadence. `visible` gates it (UI-thread only, set by + // DoSetVisible); `begin_frame_pump_started` guards a double-start. UI-thread only. + bool visible = true; + bool begin_frame_pump_started = false; }; // Routing map from a wire browser id to its Slot. MUTATED ONLY ON THE CEF UI @@ -244,6 +252,39 @@ return it == g_slots_by_wire_id.end() ? nullptr : it->second; } +// External begin-frame pump. window_info.external_begin_frame_enabled (set in DoCreateBrowser) +// turns OFF CEF's internal frame timer, so the GPU/Viz compositor produces a frame ONLY when we +// call SendExternalBeginFrame — which, unlike Invalidate(), deterministically drives one frame +// the scheduler cannot coalesce away. We are now the frame clock. This re-posts itself per live +// slot on the CEF UI thread; it dies when the slot is disposed (LookupWireId -> null) and idles +// to a slow poll while the tile is hidden (no begin-frame -> the off-screen browser costs ~zero, +// and WasHidden(true) already stopped its rendering). Started in HostClient::OnAfterCreated. +// Runs on TID_UI, so slot->visible / slot->browser need no lock (only UI-thread code touches them). +void PumpBeginFrame(uint32_t wire_id) { + std::shared_ptr slot = LookupWireId(wire_id); + if (!slot || !slot->browser) return; // disposed mid-flight — let the pump die + if (slot->visible) slot->browser->GetHost()->SendExternalBeginFrame(); + CefPostDelayedTask(TID_UI, base::BindOnce(&PumpBeginFrame, wire_id), + slot->visible ? 16 : 100); +} + +// Process-wide Metal context for the GPU-blit present path (CompositeMetalLocked). One device + +// queue for the whole cef_host process; created lazily on first accelerated paint. MRC build, so +// these are owned singletons we intentionally never release. EnsureMetal() returns false (once, +// then cached) if Metal is unavailable — callers fall back to the CPU composite. +static id g_mtl_device = nil; +static id g_mtl_queue = nil; +static bool EnsureMetal() { + static bool tried = false; + if (g_mtl_device) return true; + if (tried) return false; + tried = true; + g_mtl_device = MTLCreateSystemDefaultDevice(); + if (!g_mtl_device) return false; + g_mtl_queue = [g_mtl_device newCommandQueue]; + return g_mtl_queue != nil; +} + // Host-set navigation scheme allowlist (lowercased; `--allowed-schemes=a,b`). // Empty = allow all. `about` is always allowed (the blank placeholder). // Enforced in HostClient::OnBeforeBrowse so it covers the initial load, @@ -480,6 +521,20 @@ bool GetScreenInfo(CefRefPtr, CefScreenInfo& info) override { return true; } + // Present the just-painted slot surface, TAGGING the frame with its IOSurface id + // (BE u32). The host (Swift) uses this to promote a resized "pending" surface to the + // Flutter texture only once a paint into THAT surface has actually landed — until + // then it keeps serving the old surface, so a resize never flashes the fresh, + // zero-filled IOSurface. Caller holds slot_->surface_mutex. + void SendPresentLocked() { + uint32_t sid = slot_->surface ? IOSurfaceGetID(slot_->surface) : 0; + uint8_t p[4] = {static_cast((sid >> 24) & 0xff), + static_cast((sid >> 16) & 0xff), + static_cast((sid >> 8) & 0xff), + static_cast(sid & 0xff)}; + SendFrame(slot_->browser_id, kOpPresent, p, 4); + } + void OnPaint(CefRefPtr, PaintElementType type, const RectList&, const void* buffer, int width, int height) override { std::lock_guard lock(slot_->surface_mutex); @@ -512,7 +567,7 @@ void OnPaint(CefRefPtr, PaintElementType type, const RectList&, popup_py); } IOSurfaceUnlock(slot_->surface, 0, nullptr); - SendFrame(slot_->browser_id, kOpPresent, nullptr, 0); + SendPresentLocked(); } void OnPopupShow(CefRefPtr browser, bool show) override { @@ -587,7 +642,90 @@ void CompositeSoftwareLocked(IOSurfaceRef view_src) { slot_->popup_h, px, py); } IOSurfaceUnlock(slot_->surface, 0, nullptr); - SendFrame(slot_->browser_id, kOpPresent, nullptr, 0); + SendPresentLocked(); + } + + // GPU-blit composite: copy CEF's accelerated view surface into the host-owned slot_->surface + // with a Metal blit instead of the CPU IOSurfaceLock+memcpy in CompositeSoftwareLocked. CEF's + // contract reclaims view_src back to its pool when this callback returns, so the blit's GPU READ + // of view_src MUST complete before we return — hence waitUntilCompleted (the existing CPU path's + // IOSurfaceLock+memcpy is likewise synchronous). The win is keeping frame data on the GPU + // end-to-end: on discrete-GPU / Windows / Linux this avoids the GPU->CPU readback the memcpy + // forces; on unified-memory Apple Silicon it's ~neutral. Caller holds slot_->surface_mutex. + // Falls back to the CPU composite if Metal is unavailable or the IOSurface->texture wrap fails. + // Popups never take this path — an open dropdown is open we must CPU-composite so the popup is drawn + // over the view; otherwise take the GPU-blit path (no CPU readback). + if (slot_->popup_visible) { + CompositeSoftwareLocked(src); // GPU-composited view + the open popup + } else { + CompositeMetalLocked(src); // GPU blit, no CPU readback + } } // Report the composition caret rect (DIP, view coords) so the host can place @@ -848,7 +992,16 @@ void OnAfterCreated(CefRefPtr browser) override { // H3: a dispose arrived during the async-create window and recorded intent — honor // it now (OnBeforeClose then does the normal map-erase + surface release + retain- // cycle break) so we don't leak a live orphan browser the Swift side already forgot. - if (slot_->close_requested) browser->GetHost()->CloseBrowser(true); + if (slot_->close_requested) { + browser->GetHost()->CloseBrowser(true); + return; + } + // Start the external begin-frame pump now that the browser is bound. We turned the internal + // frame timer OFF (external_begin_frame_enabled), so without this nothing ever paints. + if (!slot_->begin_frame_pump_started) { + slot_->begin_frame_pump_started = true; + PumpBeginFrame(slot_->browser_id); + } } // CefLifeSpanHandler: route popups (window.open / target=_blank) to the host @@ -1112,6 +1265,13 @@ void DoCreateBrowser(uint32_t wire_id, int w, int h, double dpr, uint32_t sid, // at runtime under a signed build — see CONTRACT H.6). window_info.shared_texture_enabled = true; #endif + // Own the frame clock. Without this CEF's internal scheduler decides when to paint and can + // skip the frame after a resize (a resize is viewport-only damage on an idle page), leaving + // the tile stuck at the old size until real input forces a tick. With external begin-frame WE + // drive every frame via SendExternalBeginFrame (the per-slot PumpBeginFrame), so a resize — + // and all rendering — always produces a frame. NOTE: this turns the internal timer OFF, so the + // pump MUST run for anything to render at all (started in OnAfterCreated). + window_info.external_begin_frame_enabled = true; CefBrowserSettings settings; settings.windowless_frame_rate = 60; CefRefPtr client = new HostClient(slot); @@ -1183,7 +1343,13 @@ void DoResize(const std::shared_ptr& slot, int w, int h, slot->width = w; slot->height = h; } - if (slot->browser) slot->browser->GetHost()->WasResized(); + if (slot->browser) { + slot->browser->GetHost()->WasResized(); + // Drive a frame right now at the new size. With external begin-frame this is a guaranteed + // tick (not a coalesce-able Invalidate request), so the re-laid-out content composites into + // the new surface immediately; PumpBeginFrame's ongoing ticks cover the heavy-page settle. + slot->browser->GetHost()->SendExternalBeginFrame(); + } } void DoNavigate(const std::shared_ptr& slot, const std::string& url) { @@ -1230,6 +1396,7 @@ void DoSetZoom(const std::shared_ptr& slot, double level) { // alive, so this is a cheap pause/resume — not a teardown. The host pauses a // tile that scrolls fully out of the canvas viewport and resumes it on return. void DoSetVisible(const std::shared_ptr& slot, bool visible) { + slot->visible = visible; // PumpBeginFrame reads this to idle the begin-frame pump while hidden if (slot->browser) slot->browser->GetHost()->WasHidden(!visible); } void DoFind(const std::shared_ptr& slot, const std::string& text, @@ -1550,8 +1717,12 @@ void DoKey(const std::shared_ptr& slot, int type, uint32_t modifiers, // frame self-heals a dropped/raced first paint instead of a permanently blank texture. void DoInvalidate(const std::shared_ptr& slot) { CEF_REQUIRE_UI_THREAD(); - if (slot && slot->browser && slot->browser->GetHost()) + if (slot && slot->browser && slot->browser->GetHost()) { slot->browser->GetHost()->Invalidate(PET_VIEW); + // With external begin-frame the internal timer is off, so Invalidate alone may never paint — + // drive a guaranteed frame so a watchdog re-kick actually delivers. + slot->browser->GetHost()->SendExternalBeginFrame(); + } } // Tear down the WHOLE process: close every browser, then quit the message loop.