diff --git a/example/lib/device_frame_probe.dart b/example/lib/device_frame_probe.dart new file mode 100644 index 0000000..cace074 --- /dev/null +++ b/example/lib/device_frame_probe.dart @@ -0,0 +1,355 @@ +// Switching a view's device frame keeps it painting — END-TO-END probe (macOS). +// +// FlutterFlow's Test Mode lays its preview out at the emulated device's logical +// size inside a FittedBox and renders it at devicePixelRatio × zoom, where zoom +// fits the device into the panel. Switching the device frame therefore changes +// the view's size AND its render scale at once. When the slot that hosts the +// preview remounts, the preview is first parked for a frame (hidden, at a +// fallback size and zoom 1) and then shown at the new device. +// +// Each round switches to the next device one of three ways: +// plain size + render scale change in one frame +// blip one parked frame (hidden, 393×852, zoom 1), then the new device shown +// anim size + render scale lerp over ~15 frames +// and checks that, within LIMIT_MS, the page reports the new viewport and +// density, and that it still presents a frame after a DOM change. +// +// The regression: a scale change that reached CEF while it was still waiting +// to paint an earlier resize was applied later under the renderer's old surface +// id. viz rejected the renderer's next frame and dropped its frame sink, and the +// view never painted again: no crash, no stall report, JS still answering, and +// neither hide/show nor reload brought it back. A page slow to relayout (like a +// debug web build) makes that wait long, so by default the page blocks its main +// thread for PAGE_SLOW_MS on every resize and the app's UI thread is kept busy +// for JANK_MS per frame around each switch. With FIXED_DPR=true switches change +// only the size, which never wedged. +// +// Run: FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \ +// flutter run -d macos -t lib/device_frame_probe.dart +// [--dart-define=ROUNDS=30 --dart-define=LIMIT_MS=4000 +// --dart-define=PAGE_SLOW_MS=600 --dart-define=JANK_MS=40 +// --dart-define=FIXED_DPR=true --dart-define=PAGE_URL=] +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _rounds = int.fromEnvironment('ROUNDS', defaultValue: 30); +const _limitMs = int.fromEnvironment('LIMIT_MS', defaultValue: 4000); +const _slowMs = String.fromEnvironment('PAGE_SLOW_MS', defaultValue: '600'); +const _jankMs = int.fromEnvironment('JANK_MS', defaultValue: 40); +const _fixedDpr = bool.fromEnvironment('FIXED_DPR'); +// A page to preview instead of the built-in one, e.g. a served Flutter web app. +// It has to repaint on its own (a ticking clock) for the liveness check. +const _pageUrl = String.fromEnvironment('PAGE_URL'); + +// Like a Flutter web app: a WebGL canvas that redraws only on resize or on +// request, so it is static between switches. +const _page = ''' + +
0
+'''; + +class _Device { + const _Device(this.name, this.w, this.h); + final String name; + final double w, h; +} + +const _devices = [ + _Device('iPhone 16', 393, 852), + _Device('iPad Air', 820, 1180), + _Device('Desktop', 1440, 1024), + _Device('Pixel 8', 412, 915), + _Device('iPhone landscape', 852, 393), + _Device('Custom', 600, 700), +]; + +// The fallback size a parked FlutterFlow preview is laid out at. +const _parked = _Device('parked', 393, 852); + +void main() => runApp(const MaterialApp(home: ProbeApp())); + +class ProbeApp extends StatefulWidget { + const ProbeApp({super.key}); + @override + State createState() => _ProbeAppState(); +} + +class _ProbeAppState extends State { + final List _lines = []; + bool _pass = true; + final _controller = CefWebController(); + + // What the view is laid out at right now. + _Device _device = _devices[0]; + // Override [_device] while an animated switch runs. + Size? _animSize; + double? _animZoom; + bool _parkedNow = false; + Size _panel = const Size(800, 600); + double _screenDpr = 1.0; + + void _check(String name, bool cond, [Object? got]) { + if (!cond) _pass = false; + _log('${cond ? "PASS" : "FAIL"} $name${cond ? "" : " (got: $got)"}'); + } + + void _log(String s) { + _lines.add(s); + // ignore: avoid_print + print('CEF_PROBE_LOG $s'); + if (mounted) setState(() {}); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _run()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + double _fitZoom(_Device d) => + 0.9 * [_panel.width / d.w, _panel.height / d.h].reduce((a, b) => a < b ? a : b); + + double _renderScale(double zoom) => _screenDpr * (_fixedDpr ? 1.0 : zoom); + + Future _frame() => SchedulerBinding.instance.endOfFrame; + + Future _eval(String code) async { + try { + return '${await _controller.runJavaScriptReturningResult(code).timeout(const Duration(seconds: 3))}'; + } catch (e) { + return 'eval failed: $e'; + } + } + + Future _presents() async => (await _controller.sessionStats())?.presentCount ?? -1; + + /// Milliseconds until [cond] holds, or null past [limitMs]. + Future _until(Future Function() cond, int limitMs) async { + final sw = Stopwatch()..start(); + while (sw.elapsedMilliseconds < limitMs) { + if (await cond()) return sw.elapsedMilliseconds; + await Future.delayed(const Duration(milliseconds: 20)); + } + return null; + } + + Future _viewport() => + _eval('innerWidth + "x" + innerHeight + "@" + devicePixelRatio.toFixed(3)'); + + /// Whether the page presents a frame within 2 s of a DOM change. + Future _painting(String mark) async { + final before = await _presents(); + await _eval('(document.getElementById("n").textContent = "$mark", window.__redraw(), "ok")'); + return await _until(() async => await _presents() > before, 2000) != null; + } + + /// Keeps the UI thread busy for [_jankMs] after every frame until [until]. + void _jankUntil(DateTime until) { + if (_jankMs <= 0 || DateTime.now().isAfter(until)) return; + SchedulerBinding.instance.addPostFrameCallback((_) { + final sw = Stopwatch()..start(); + while (sw.elapsedMilliseconds < _jankMs) {} + _jankUntil(until); + }); + SchedulerBinding.instance.scheduleFrame(); + } + + Future _switchTo(_Device next, String how) async { + _jankUntil(DateTime.now().add(const Duration(milliseconds: 1500))); + switch (how) { + case 'plain': + setState(() => _device = next); + await _frame(); + case 'blip': + // As SandboxAppPreviewHost does when its slot remounts: parked (hidden, + // fallback size, zoom 1) for a frame, then the new device shown. + setState(() => _parkedNow = true); + await _controller.setVisible(false); + await _frame(); + await _frame(); + setState(() { + _parkedNow = false; + _device = next; + }); + await _controller.setVisible(true); + await _frame(); + case 'anim': + final from = Size(_device.w, _device.h); + final fromZoom = _fitZoom(_device); + const steps = 15; + for (var i = 1; i <= steps; i++) { + final t = Curves.easeInOut.transform(i / steps); + setState(() { + _animSize = Size.lerp(from, Size(next.w, next.h), t); + _animZoom = lerpDouble(fromZoom, _fitZoom(next), t); + }); + await _frame(); + } + setState(() { + _animSize = null; + _animZoom = null; + _device = next; + }); + await _frame(); + } + } + + Future _run() async { + final host = Platform.environment['FLUTTER_CEF_HOST']; + _check('FLUTTER_CEF_HOST is set', host != null && host.isNotEmpty, host); + final painted = await _until(() async => await _presents() > 0, 20000); + if (painted == null) { + _check('the view paints', false, '${await _controller.sessionStats()}'); + return _finish(); + } + await _until( + () async => + await _eval(_pageUrl.isEmpty + ? 'typeof window.__redraw' + : 'String(!!document.querySelector("flutter-view, flt-glass-pane"))') == + (_pageUrl.isEmpty ? 'function' : 'true'), + 15000); + _log('first frame after ${painted}ms; screen dpr $_screenDpr; ' + 'page slow ${_slowMs}ms, jank ${_jankMs}ms${_fixedDpr ? ", fixed dpr" : ""}'); + const hows = ['plain', 'blip', 'anim']; + var failed = 0; + final times = []; + for (var i = 1; i <= _rounds; i++) { + final how = hows[(i - 1) % hows.length]; + final next = _devices[i % _devices.length]; + final dpr = _renderScale(_fitZoom(next)).clamp(0.5, 8.0); + final want = '${next.w.round()}x${next.h.round()}@${dpr.toStringAsFixed(3)}'; + final label = 'round $i $how → ${next.name} $want'; + await _switchTo(next, how); + // The page sees the new viewport and density... + final sized = await _until(() async { + final p = (await _eval('[innerWidth, innerHeight, devicePixelRatio].join(",")')) + .split(',') + .map(double.tryParse) + .toList(); + return p.length == 3 && + p.every((v) => v != null) && + (p[0]! - next.w).abs() <= 1.5 && + (p[1]! - next.h).abs() <= 1.5 && + (p[2]! - dpr).abs() < 0.01; + }, _limitMs); + // ...and still paints. + final live = await _painting('$i'); + if (sized != null && live) { + times.add(sized); + _log('$label: ${sized}ms'); + continue; + } + failed++; + _log('$label: STUCK — viewport ${sized == null ? "stale" : "ok"}, ' + 'presents ${live ? "ok" : "stopped"}; page ${await _viewport()}, ' + '${await _controller.sessionStats()}'); + // A view that stopped painting stays stopped; the rounds after it prove nothing. + if (!live) break; + } + times.sort(); + final median = times.isEmpty ? 0 : times[times.length ~/ 2]; + final max = times.isEmpty ? 0 : times.last; + _log('$failed of $_rounds switches stuck; others: median ${median}ms, max ${max}ms'); + _check('every device switch repaints at the new size within ${_limitMs}ms', failed == 0, + '$failed of $_rounds'); + return _finish(); + } + + Future _finish() async { + // ignore: avoid_print + print('CEF_PROBE_RESULT ${_pass ? "PASS" : "FAIL"}'); + await Future.delayed(const Duration(milliseconds: 300)); + exit(_pass ? 0 : 1); + } + + @override + Widget build(BuildContext context) { + _screenDpr = MediaQuery.devicePixelRatioOf(context); + final d = _parkedNow ? _parked : _device; + final size = _animSize ?? Size(d.w, d.h); + final zoom = _parkedNow ? 1.0 : (_animZoom ?? _fitZoom(d)); + return Scaffold( + body: Row( + children: [ + Expanded( + flex: 3, + child: LayoutBuilder(builder: (context, c) { + _panel = Size(c.maxWidth, c.maxHeight); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: _parkedNow ? -10000 : 20, + top: _parkedNow ? -10000 : 20, + width: size.width * zoom, + height: size.height * zoom, + child: FittedBox( + fit: BoxFit.fill, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(size: size), + child: SizedBox( + width: size.width, + height: size.height, + child: CefWebView( + url: _pageUrl.isEmpty ? 'about:blank' : _pageUrl, + html: _pageUrl.isEmpty ? _page : null, + controller: _controller, + renderScale: _renderScale(zoom), + enableZoomShortcuts: false, + ), + ), + ), + ), + ), + ], + ); + }), + ), + Expanded( + flex: 2, + child: ListView( + children: [for (final l in _lines) Text(l, style: const TextStyle(fontSize: 10))], + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_cef_macos/CHANGELOG.md b/packages/flutter_cef_macos/CHANGELOG.md index a6708ba..ab75443 100644 --- a/packages/flutter_cef_macos/CHANGELOG.md +++ b/packages/flutter_cef_macos/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +* Fix: a view could stop painting for good after a resize that changed the + render scale, typically when switching the device frame in a preview of a page + slow to relayout. Hide/show and reload didn't bring it back. `cef_host` now + hands CEF one resize at a time: a new size or scale reaches CEF only after it + has painted the previous one, and a resize with no paint at its size gets the + renderer a fresh surface (1, 2, then 4 s apart). Probe: `device_frame`. The + prebuilt must be republished. + ## 0.3.0 * Removed the unused `native/cef_host/entitlements.browser.plist`, which diff --git a/packages/flutter_cef_macos/native/cef_host/browser_ops.h b/packages/flutter_cef_macos/native/cef_host/browser_ops.h index c7b6d25..ec493f7 100644 --- a/packages/flutter_cef_macos/native/cef_host/browser_ops.h +++ b/packages/flutter_cef_macos/native/cef_host/browser_ops.h @@ -60,4 +60,11 @@ void DoKey(const std::shared_ptr& slot, int type, uint32_t modifiers, uint32_t character); void DoInvalidate(const std::shared_ptr& slot); +// Resizes, one at a time (see ApplyGeometry): hand CEF the latest geometry, +// note each view paint, and check for a stall from the begin-frame pump. All on +// the CEF UI thread. +void ApplyGeometry(const std::shared_ptr& slot); +void NoteViewPaint(const std::shared_ptr& slot, int pixel_w, int pixel_h); +void CheckResizeStall(const std::shared_ptr& slot); + } // namespace cef_host diff --git a/packages/flutter_cef_macos/native/cef_host/browser_ops.mm b/packages/flutter_cef_macos/native/cef_host/browser_ops.mm index 29cdb75..36f235f 100644 --- a/packages/flutter_cef_macos/native/cef_host/browser_ops.mm +++ b/packages/flutter_cef_macos/native/cef_host/browser_ops.mm @@ -2,6 +2,8 @@ #import +#include +#include #include #include #include @@ -10,9 +12,12 @@ #include "authored_content.h" #include "host_client.h" +#include "include/base/cef_callback.h" #include "include/cef_cookie.h" #include "include/cef_devtools_message_observer.h" #include "include/cef_request_context.h" +#include "include/cef_task.h" +#include "include/wrapper/cef_closure_task.h" #include "include/wrapper/cef_helpers.h" #include "ipc.h" #include "mac_key_bindings.h" @@ -74,9 +79,9 @@ void DoCreateBrowser(uint32_t wire_id, int w, int h, double dpr, } auto slot = std::make_shared(); slot->browser_id = wire_id; - slot->width = w < 1 ? 1 : w; - slot->height = h < 1 ? 1 : h; - slot->dpr = dpr; + slot->width = slot->cef_width = w < 1 ? 1 : w; + slot->height = slot->cef_height = h < 1 ? 1 : h; + slot->dpr = slot->cef_dpr = dpr; { auto early = g_early_channels.find(wire_id); if (early != g_early_channels.end()) { @@ -234,41 +239,22 @@ void DoResize(const std::shared_ptr& slot, int w, int h, double dpr) { // EnsureSurfaceForPaint (in the composite path) reallocates slot->surface to match + the next // present hands the consumer the new id. So there is no IOSurfaceLookup/CFRelease-swap here // (that was the consumer-allocates handoff that could crop when src≠dst). dpr<=0 = unchanged. - bool dpr_changed = false; { std::lock_guard lock(slot->surface_mutex); slot->width = w; slot->height = h; - if (dpr > 0.0 && dpr != slot->dpr) { - slot->dpr = dpr; - dpr_changed = true; - } + if (dpr > 0.0) slot->dpr = dpr; // dst_mtl is rebuilt by EnsureSurfaceForPaint on the realloc; nil it here too so a same-size // relayout that doesn't realloc still drops a wrap that could be mid-rebuild (belt + suspenders). [slot->dst_mtl release]; slot->dst_mtl = nil; slot->dst_mtl_sid = 0; } - if (slot->browser) { - if (slot->visible) { - // A device-scale change needs the renderer told (screen info), not just a relayout. - if (dpr_changed) slot->browser->GetHost()->NotifyScreenInfoChanged(); - 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(); - } else { - // HIDDEN — the begin-frame pump is gated off (PumpBeginFrame skips while - // !visible), so WasResized()+SendExternalBeginFrame() here would never paint the - // freshly-swapped (blank) surface, yet the Swift resizeWatchdog would force-promote - // it to the live texture → permanent blank on a static page. The surface + dims are - // already swapped above (geometry is current); defer the screen-info re-assert + the - // repaint to DoSetVisible's hidden->visible edge. WasResized while hidden is - // pointless (no frame can result), so it is dropped, not deferred. - if (dpr_changed) slot->needs_screen_info_on_show = true; - } - } + // HIDDEN: the begin-frame pump is gated off (PumpBeginFrame skips while !visible), so + // nothing CEF could paint now would reach the screen, yet the Swift resizeWatchdog would + // force-promote the blank surface → permanent blank on a static page. DoSetVisible's + // hidden->visible edge hands CEF the new geometry instead. + if (slot->browser && slot->visible) ApplyGeometry(slot); } void DoNavigate(const std::shared_ptr& slot, const std::string& url) { @@ -374,24 +360,132 @@ void DoSetVisible(const std::shared_ptr& slot, bool visible) { if (!slot->browser) return; slot->browser->GetHost()->WasHidden(!visible); // On the hidden->visible edge, FORCE a fresh full-viewport repaint at the - // current geometry. WasHidden(false) alone does NOT repaint, and three things can have left - // the live texture blank/stale while hidden: (a) a resize landed while the pump was gated off - // (DoResize deferred its paint here); (b) a dpr/screen-info change was deferred; (c) Chromium's - // FrameEvictionManager reclaimed the off-screen compositor frame entirely (happens past ~5 - // browsers / under memory pressure) so there is nothing to show even though geometry is - // unchanged. Re-assert screen info (if a dpr change was deferred) + size, then drive a - // guaranteed frame — mirrors DoResize/DoInvalidate. Unconditional on the edge because the - // eviction case carries no resize to key off. + // current geometry. WasHidden(false) alone does NOT repaint, and two things can have left + // the live texture blank/stale while hidden: (a) a resize or dpr change landed while the pump + // was gated off (DoResize left it for here); (b) Chromium's FrameEvictionManager reclaimed the + // off-screen compositor frame entirely (happens past ~5 browsers / under memory pressure) so + // there is nothing to show even though geometry is unchanged. Hand CEF the new geometry, re- + // assert size, then drive a guaranteed frame — mirrors DoResize/DoInvalidate. Unconditional on + // the edge because the eviction case carries no resize to key off. if (visible && !was_visible) { - if (slot->needs_screen_info_on_show) { - slot->browser->GetHost()->NotifyScreenInfoChanged(); - slot->needs_screen_info_on_show = false; - } + // A resize still in flight gets a fresh wait: nothing paints while hidden. + slot->resize_wait_since = std::chrono::steady_clock::now(); + ApplyGeometry(slot); slot->browser->GetHost()->WasResized(); slot->browser->GetHost()->Invalidate(PET_VIEW); slot->browser->GetHost()->SendExternalBeginFrame(); } } + +// Hand CEF a new size or density only once it has painted the last one. +// +// CEF's OSR view keeps one resize in flight: a change to the view size or scale +// "holds" until a paint arrives at the new pixel size, and a change made during +// the hold is queued and applied when it releases. A queued DENSITY change is +// applied through RenderWidgetHostViewBase::UpdateScreenInfo, which sends the +// renderer the new scale under the surface id it already has. The renderer's +// next frame then has a different scale for the same surface id, viz rejects it +// (a "surface invariants violation") and drops the renderer's frame sink. On +// macOS that view never draws again: the renderer comes back under a surface id +// it allocated itself, which CEF 144 never embeds (DidUpdateVisualProperties is +// compiled out for Mac), or the external begin-frame source waits forever on +// the dropped sink. Hide/show and reload don't bring it back. A slow page makes +// the hold long, so switching device frames on one froze Test Mode. +// +// NotifyScreenInfoChanged applies a density change safely: it retires the surface +// id first. It only gets to do that when no hold is on, so CEF sees geometry +// through cef_width/cef_height/cef_dpr, and those change only when no resize is +// in flight. Changes the plugin asks for meanwhile collapse into the latest one. +namespace { +constexpr int kResizePaintWaitMs = 1000; +constexpr int kResizePaintMaxWaitMs = 4000; +constexpr int kResizeMaxKicks = 6; + +// The pixel size CEF paints a view at: gfx::ScaleToCeiledSize in float, as +// CefRenderWidgetHostViewOSR computes it, so a paint matches it exactly. +int CefPixels(int logical, double dpr) { + return static_cast( + std::ceil(static_cast(logical) * static_cast(dpr))); +} +} // namespace + +void ApplyGeometry(const std::shared_ptr& slot) { + if (!slot->browser || !slot->visible || slot->resize_in_flight) return; + bool dpr_changed; + { + std::lock_guard lock(slot->surface_mutex); + if (slot->cef_width == slot->width && slot->cef_height == slot->height && + slot->cef_dpr == slot->dpr) + return; + dpr_changed = static_cast(slot->cef_dpr) != static_cast(slot->dpr); + slot->cef_width = slot->width; + slot->cef_height = slot->height; + slot->cef_dpr = slot->dpr; + slot->resize_want_w = CefPixels(slot->cef_width, slot->cef_dpr); + slot->resize_want_h = CefPixels(slot->cef_height, slot->cef_dpr); + } + CefRefPtr host = slot->browser->GetHost(); + // NotifyScreenInfoChanged applies the new size along with the new scale. + if (dpr_changed) + host->NotifyScreenInfoChanged(); + else + host->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. + host->Invalidate(PET_VIEW); + host->SendExternalBeginFrame(); + slot->resize_in_flight = true; + slot->resize_wait_since = std::chrono::steady_clock::now(); + slot->resize_wait_ms = kResizePaintWaitMs; + slot->resize_kicks = 0; +} + +void NoteViewPaint(const std::shared_ptr& slot, int pixel_w, int pixel_h) { + if (!slot->resize_in_flight || pixel_w != slot->resize_want_w || + pixel_h != slot->resize_want_h) + return; + if (slot->resize_kicks > 0) + SendLog(slot->browser_id, "resize: painted " + std::to_string(pixel_w) + "x" + + std::to_string(pixel_h) + " after " + + std::to_string(slot->resize_kicks) + " kick(s)"); + slot->resize_in_flight = false; + // CEF releases its hold after this paint callback returns; apply what came in + // meanwhile after that. + CefPostTask(TID_UI, base::BindOnce(&ApplyGeometry, slot)); +} + +// The fallback if a resize still doesn't paint. With the hold on and the +// geometry CEF sees unchanged, NotifyScreenInfoChanged only swaps in a fresh +// surface id, which gets the renderer to paint again. Waits back off (1, 2, +// 4 s) so a page that is merely slow to lay out isn't kicked over and over. +void CheckResizeStall(const std::shared_ptr& slot) { + if (!slot->resize_in_flight || !slot->visible || !slot->browser) return; + const auto now = std::chrono::steady_clock::now(); + if (now - slot->resize_wait_since < + std::chrono::milliseconds(slot->resize_wait_ms)) + return; + const std::string want = std::to_string(slot->resize_want_w) + "x" + + std::to_string(slot->resize_want_h); + if (slot->resize_kicks >= kResizeMaxKicks) { + SendLog(slot->browser_id, "resize: no paint at " + want + " after " + + std::to_string(kResizeMaxKicks) + + " kicks; moving on"); + slot->resize_in_flight = false; + ApplyGeometry(slot); + return; + } + slot->resize_kicks++; + SendLog(slot->browser_id, "resize: no paint at " + want + " in " + + std::to_string(slot->resize_wait_ms) + + "ms; kick " + std::to_string(slot->resize_kicks)); + CefRefPtr host = slot->browser->GetHost(); + host->NotifyScreenInfoChanged(); + host->Invalidate(PET_VIEW); + host->SendExternalBeginFrame(); + slot->resize_wait_since = now; + slot->resize_wait_ms = std::min(slot->resize_wait_ms * 2, kResizePaintMaxWaitMs); +} void DoSetAudioMuted(const std::shared_ptr& slot, bool muted) { if (slot->browser) slot->browser->GetHost()->SetAudioMuted(muted); } diff --git a/packages/flutter_cef_macos/native/cef_host/host_client.mm b/packages/flutter_cef_macos/native/cef_host/host_client.mm index 650b94f..95b4a62 100644 --- a/packages/flutter_cef_macos/native/cef_host/host_client.mm +++ b/packages/flutter_cef_macos/native/cef_host/host_client.mm @@ -7,6 +7,7 @@ #include #include "authored_content.h" +#include "browser_ops.h" #include "include/cef_request_context.h" #include "include/wrapper/cef_helpers.h" #include "include/wrapper/cef_message_router.h" @@ -551,6 +552,9 @@ void OnAfterCreated(CefRefPtr browser) override { slot_->begin_frame_pump_started = true; PumpBeginFrame(slot_->browser_id); } + // A resize that arrived while the browser was being created: CEF built its view at the + // create size, so hand it the latest geometry now (hidden: DoSetVisible does it on show). + ApplyGeometry(slot_); } // CefLifeSpanHandler: route popups (window.open / target=_blank) to the host diff --git a/packages/flutter_cef_macos/native/cef_host/host_state.h b/packages/flutter_cef_macos/native/cef_host/host_state.h index 898be11..55731aa 100644 --- a/packages/flutter_cef_macos/native/cef_host/host_state.h +++ b/packages/flutter_cef_macos/native/cef_host/host_state.h @@ -145,9 +145,14 @@ struct Slot { // is. Guarded by surface_mutex. MRC: holds the +1 from newTextureWithDescriptor. id dst_mtl = nil; uint32_t dst_mtl_sid = 0; - int width = 800; // logical (DIP) — GetViewRect; CEF scales by dpr. + int width = 800; // logical (DIP) size the plugin asked for. int height = 600; - double dpr = 1.0; // device pixel ratio; the IOSurface is logical*dpr px. + double dpr = 1.0; // device pixel ratio the plugin asked for. + // The geometry CEF sees (GetViewRect / GetScreenInfo). It trails width/height/dpr + // while a resize is in flight: see ApplyGeometry in browser_ops.mm. + int cef_width = 800; + int cef_height = 600; + double cef_dpr = 1.0; // Popup widgets ( dropdown) painted on top of the view. @@ -450,8 +453,8 @@ void CompositeSoftwareLocked(IOSurfaceRef view_src) { IOSurfaceUnlock(view_src, kIOSurfaceLockReadOnly, nullptr); } if (slot_->popup_visible && !slot_->popup_buf.empty()) { - const int px = static_cast(slot_->popup_rect.x * slot_->dpr); - const int py = static_cast(slot_->popup_rect.y * slot_->dpr); + const int px = static_cast(slot_->popup_rect.x * slot_->cef_dpr); + const int py = static_cast(slot_->popup_rect.y * slot_->cef_dpr); BlitBGRA(dst, ds, dw, dh, slot_->popup_buf.data(), slot_->popup_w, slot_->popup_h, px, py); } @@ -632,6 +635,9 @@ void OnAcceleratedPaint(CefRefPtr, PaintElementType type, SendLog(slot_->browser_id, "OnAcceleratedPaint: null io_surface"); return; } + if (type == PET_VIEW) + NoteViewPaint(slot_, static_cast(IOSurfaceGetWidth(src)), + static_cast(IOSurfaceGetHeight(src))); std::lock_guard lock(slot_->surface_mutex); // PRODUCER-ALLOCATES: the surface is minted lazily by the FIRST view paint (and re-minted on // any size change) inside the composite path — so we must NOT early-return on a null surface diff --git a/tool/run_probes.sh b/tool/run_probes.sh index 6642fe3..3b02cfe 100755 --- a/tool/run_probes.sh +++ b/tool/run_probes.sh @@ -41,6 +41,7 @@ APP="$ROOT/example/build/macos/Build/Products/Debug/flutter_cef_example.app/Cont # soak or Windows-only: listed, never run) PROBES=( "authored_origin|authored_origin_probe|120|default|-|loadHtmlString(baseUrl:) serves the document at that origin" + "device_frame|device_frame_probe|300|default|-|switching size and render scale keeps a slow page painting" "document_start|document_start_probe|120|default|-|document-start scripts run before page scripts" "hidden_at_create|hidden_at_create_probe|120|default|-|a view created hidden paints once shown" "host_start_failure|host_start_failure_probe|60|false|-|a host that exits at once reports createFailed"