bevy_cef 0.11.0

Bevy CEF integration for web rendering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
//! [macOS GPU OSR — Approach 2] Import each webview's retained IOSurface and
//! blit it into an owned GPU texture inside a Bevy render-graph node, then inject
//! that owned texture into `RenderAssets<GpuImage>` so the webview mesh material
//! samples the live page.
//!
//! Why a render-graph node (not the `on_accelerated_paint` callback): Bevy owns
//! ordered command submission — its render graph collects all GPU commands into
//! one `RenderContext` and submits them once per frame, then presents. Doing an
//! out-of-band `queue.submit` from the CEF callback (which runs in the `Main`
//! schedule) races that ordered submit/present and corrupts rendering (the mesh
//! goes black, no validation error). So the callback only *retains* the latest
//! IOSurface; the import + blit is recorded into the frame's command encoder by
//! `WebviewBlitNode`, and Bevy submits it in order.
//!
//! Frame flow (macOS):
//! 1. Main world (`Update`): `allocate_webview_surfaces_for::<M>` (mesh, per
//!    registered material type),
//!    `allocate_ui_webview_surfaces` (bevy_ui), and
//!    `allocate_sprite_webview_surfaces` (2D `Sprite`) give every webview a
//!    placeholder `Handle<Image>` + `WebviewSurface` tag, and keep the material
//!    slot and the tag reconciled afterwards (material swaps re-wire instead of
//!    going permanently black). Mesh/UI store the handle on their material;
//!    sprites set it as `Sprite.image`. The collect → node → inject pipeline is
//!    keyed by `WebviewSurface`'s `AssetId<Image>`, so it is
//!    material/sprite-agnostic.
//! 2. Main world (`Update`, after allocation): `collect_webview_iosurfaces` pulls
//!    the latest retained IOSurface ptr per webview out of `Browsers` (`NonSend`)
//!    and pairs each with its surface `AssetId<Image>` into
//!    `PendingWebviewIoSurfaces`. It also schedules bind-group rebuilds by
//!    tagging the entity with the `WebviewSurfaceRebind` marker on first-frame /
//!    resize / re-key events, which the `mark_*` systems consume via a
//!    `With<WebviewSurfaceRebind>` filter — webview materials are NOT dirtied
//!    every frame.
//! 3. `ExtractSchedule`: `extract_webview_iosurfaces` copies the pending list into
//!    the render world (`ExtractedWebviewIoSurfaces`), and
//!    `extract_live_webview_surface_ids` records the live webviews' surface ids
//!    (so step 4 can prune entries for despawned webviews).
//! 4. Render `PrepareAssets` (after `prepare_assets::<GpuImage>`, before the
//!    material bind-group build): `inject_webview_gpu_images` prunes despawned
//!    surfaces, get-or-creates the owned `WebviewGpuSurface` for each id (it must
//!    exist before the material bind group is built), wraps each owned surface in a
//!    `GpuImage`, and inserts it into `RenderAssets<GpuImage>` for the surface id.
//! 5. Render graph (`WebviewBlitNode`, before `CameraDriverLabel`): import each
//!    retained IOSurface into a transient wgpu texture and record a blit into the
//!    frame's command encoder, filling the owned surface created in step 4. The
//!    transient texture is dropped immediately — wgpu keeps recorded resources
//!    (and, via the MTLTexture's own IOSurface reference, the surface) alive
//!    until the submitted command buffer completes on the GPU.
//!
//! The owned texture is a single stable buffer (MVP, no double-buffering): the
//! node blits into the same texture each frame, and the injected `GpuImage`
//! reuses the same `texture_view`, so the material bind group stays valid
//! between rebind events.

use crate::common::{WebviewIoSurface, WebviewSize, WebviewSource, WebviewTextureTarget};
use crate::prelude::{WebviewExtendStandardMaterial, WebviewSurface};
use crate::webview::texture_target::{WebviewGpuImageInjectSet, WebviewTextureSlot};
use crate::webview::ui::WebviewUiMaterial;
use bevy::asset::{AssetId, RenderAssetUsages};
use bevy::platform::collections::{HashMap, HashSet};
use bevy::prelude::*;
use bevy::render::{
    Extract, Render, RenderApp, RenderSystems,
    erased_render_asset::prepare_erased_assets,
    render_asset::{RenderAssets, prepare_assets},
    render_graph::{Node, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel},
    render_resource::{Extent3d, TextureFormat},
    renderer::{RenderContext, RenderDevice},
    texture::{DefaultImageSampler, GpuImage},
};
use bevy::ui_render::PreparedUiMaterial;
use bevy_cef_core::prelude::{Browsers, RetainedIoSurface, WebviewGpuSurface};

/// Number of consecutive main-world frames a rebind request stays active.
///
/// 2 = the frame the trigger was detected plus one echo frame. The echo is
/// required for custom mesh materials (`WebviewExtendedMaterial<E>`): their
/// bind-group build is deliberately unordered relative to
/// `inject_webview_gpu_images`, so on the trigger frame the rebuild may still
/// capture the placeholder view; on the echo frame the injected entry already
/// persists in `RenderAssets<GpuImage>`, making the rebuild order-independent.
const REBIND_FRAMES: u8 = 2;

/// Abstracts where a mesh webview material stores its surface `Handle<Image>`.
///
/// - `WebviewExtendStandardMaterial = ExtendedMaterial<StandardMaterial, WebviewMaterial>`
///   keeps it in `extension.surface`.
/// - `WebviewExtendedMaterial<E>     = ExtendedMaterial<WebviewMaterial, E>`
///   keeps it in `base.surface`.
///
/// `allocate_webview_surfaces_for` / `mark_webview_materials_changed_for` are
/// generic over any mesh material implementing this trait, so the standard and
/// custom materials share one GPU-injection code path.
pub(crate) trait WebviewSurfaceSlot: bevy::pbr::Material {
    fn webview_surface_slot(&self) -> &Option<Handle<Image>>;
    fn webview_surface_slot_mut(&mut self) -> &mut Option<Handle<Image>>;
}

/// Update-schedule phases for the macOS webview surface pipeline. Exposed so the
/// custom-material plugin can register its generic allocate/mark systems in the
/// right phase relative to `collect_webview_iosurfaces`.
#[derive(SystemSet, Clone, Debug, Hash, PartialEq, Eq)]
pub(crate) enum WebviewSurfaceSet {
    Allocate,
    Collect,
    MarkChanged,
}

/// Render-graph label for the webview IOSurface import + blit node.
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct WebviewBlitLabel;

/// [macOS GPU OSR] plugin: import each webview's retained IOSurface in a custom
/// render-graph node and inject the owned GPU texture into the render world so
/// the webview mesh renders the real page.
pub struct WebviewGpuInjectPlugin;

impl Plugin for WebviewGpuInjectPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<PendingWebviewIoSurfaces>()
            .configure_sets(
                Update,
                (
                    WebviewSurfaceSet::Allocate,
                    WebviewSurfaceSet::Collect,
                    WebviewSurfaceSet::MarkChanged,
                )
                    .chain(),
            )
            .add_systems(
                Update,
                (
                    allocate_webview_surfaces_for::<WebviewExtendStandardMaterial>,
                    allocate_ui_webview_surfaces,
                    allocate_sprite_webview_surfaces,
                    allocate_target_webview_surfaces,
                )
                    .in_set(WebviewSurfaceSet::Allocate),
            )
            .add_systems(
                Update,
                collect_webview_iosurfaces.in_set(WebviewSurfaceSet::Collect),
            )
            .add_systems(
                Update,
                (
                    mark_webview_materials_changed_for::<WebviewExtendStandardMaterial>,
                    mark_webview_ui_materials_changed,
                    mark_sprite_webview_images_changed,
                    mark_target_webview_images_changed,
                )
                    .in_set(WebviewSurfaceSet::MarkChanged),
            );

        let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
            warn!("[macos-gpu-osr] RenderApp sub-app missing; GPU texture injection disabled");
            return;
        };

        render_app
            .init_resource::<ExtractedWebviewIoSurfaces>()
            .init_resource::<WebviewGpuSurfaces>()
            .init_resource::<LiveWebviewSurfaceIds>()
            .add_systems(
                ExtractSchedule,
                (extract_webview_iosurfaces, extract_live_webview_surface_ids),
            )
            // Ordering: AFTER `prepare_assets::<GpuImage>` so the insert overwrites
            // the black placeholder GpuImage for the same AssetId; BEFORE each
            // material's bind-group build so the rebuilt bind group captures the
            // injected view. Custom MESH materials (`WebviewExtendedMaterial<E>`)
            // intentionally have NO `.before()` edge — their rebind path touches
            // only the material (never the image), so on the echo frame the
            // injected entry persists in `RenderAssets<GpuImage>` and the rebuild
            // is order-independent (at-most-1-frame warmup flash, spec §6).
            // Headless targets are DIFFERENT: their rebind path touches the image,
            // which re-uploads the CPU placeholder in the same frames the
            // consumer's bind group rebuilds — so consumers MUST order after
            // `WebviewGpuImageInjectSet` (the turnkey plugin does it per material
            // type) or they can capture the placeholder permanently.
            .add_systems(
                Render,
                inject_webview_gpu_images
                    .in_set(RenderSystems::PrepareAssets)
                    .in_set(WebviewGpuImageInjectSet)
                    .after(prepare_assets::<GpuImage>)
                    .before(prepare_erased_assets::<MeshMaterial3d<WebviewExtendStandardMaterial>>)
                    .before(prepare_assets::<PreparedUiMaterial<WebviewUiMaterial>>),
            );

        // Before the camera driver, so the main passes sample the updated texture.
        let mut render_graph = render_app.world_mut().resource_mut::<RenderGraph>();
        render_graph.add_node(WebviewBlitLabel, WebviewBlitNode);
        render_graph.add_node_edge(WebviewBlitLabel, bevy::render::graph::CameraDriverLabel);
    }
}

/// One webview's latest retained IOSurface paired with its material surface id.
///
/// The `surface` carries an owned +1 IOSurface use-count (a `RetainedIoSurface`,
/// which is `Send`/`Sync`), so it stays valid across the main→render world
/// handoff under pipelined rendering. Ownership flows: `Browsers` (drained) →
/// main world → render world → `WebviewBlitNode` (import), then released on the
/// next frame's extract (the Metal texture keeps its own IOSurface reference
/// until the submitted commands complete).
struct PendingIoSurface {
    id: AssetId<Image>,
    // Width/height are read from `surface.width`/`surface.height` (both `pub` on
    // `RetainedIoSurface`) at use sites, so we don't duplicate them here.
    surface: RetainedIoSurface,
}

/// Main-world store of the latest retained IOSurfaces drained this frame.
///
/// Wrapped in a `Mutex` so the `ExtractSchedule` system — which only gets
/// read-only access to the main world (`Extract<Res<_>>`, as `Extract` requires
/// `ReadOnlySystemParam`) — can still *move* the owned retains out into the
/// render world via `&Mutex` interior mutability.
#[derive(Resource, Default)]
struct PendingWebviewIoSurfaces(std::sync::Mutex<Vec<PendingIoSurface>>);

/// Marker: this webview's bind group must be rebuilt for the next
/// [`REBIND_FRAMES`] frames.
///
/// Attached by `collect_webview_iosurfaces` when the webview receives its first
/// IOSurface, changes size, or is re-keyed to a new surface id; removed by the
/// same system once `frames_left` runs out. The `mark_*` systems filter on
/// `With<Self>`, so steady-state frames iterate zero archetypes. This replaces
/// dirtying every webview material/image every frame, which forced a
/// per-webview bind-group rebuild each frame and — for sprites — a ~2.5 MB
/// placeholder re-upload each frame.
///
/// Known degenerate gap: when two webviews share one material handle and the
/// triggering entity despawns within the echo window, the surviving entity's
/// echo mark is lost (the marker dies with the entity).
#[derive(Component)]
pub(crate) struct WebviewSurfaceRebind {
    frames_left: u8,
}

/// The surface id last pushed for this webview, used to detect re-keying
/// (material swap → new surface handle) so the sticky IOSurface can be
/// re-pushed for the new id even when CEF delivers no new frame (a static page
/// never repaints under external begin-frames).
#[derive(Component)]
struct CollectedSurfaceId(AssetId<Image>);

/// Render-world copy of the latest retained IOSurfaces to import + blit this frame.
#[derive(Resource, Default)]
struct ExtractedWebviewIoSurfaces(Vec<PendingIoSurface>);

/// Render-world store of the per-webview owned destination textures, keyed by
/// the material's surface `AssetId<Image>`.
#[derive(Resource, Default)]
struct WebviewGpuSurfaces(HashMap<AssetId<Image>, WebviewGpuSurface>);

/// Render-world set of the surface ids of all webviews that are currently live in
/// the main world. Filled each frame from the authoritative `WebviewSurface`
/// query (in `ExtractSchedule`) and used by `inject_webview_gpu_images` to prune
/// `WebviewGpuSurfaces` entries whose webview has despawned — otherwise the owned
/// GPU texture (~2.5 MB each) leaks and a dead `GpuImage` is re-injected forever.
#[derive(Resource, Default)]
struct LiveWebviewSurfaceIds(HashSet<AssetId<Image>>);

/// Black, fully-opaque BGRA placeholder for a webview surface `Image`. The real
/// pixels are injected directly into `RenderAssets<GpuImage>` in the render
/// world.
///
/// Mesh/UI surfaces use a 1×1 placeholder: its pixels are never sampled after
/// the first injection, and the pre-first-frame alpha fallback treats any black
/// pixel as opaque regardless of dimensions — so there is no reason to pin a
/// multi-megabyte CPU copy per webview. Sprites pass their `WebviewSize`: the
/// sprite picking backend maps cursor positions against the main-world image
/// dimensions, so the placeholder must agree with the rendered quad.
fn placeholder_surface_image(size: UVec2) -> Image {
    Image::new_fill(
        Extent3d {
            width: size.x.max(1),
            height: size.y.max(1),
            depth_or_array_layers: 1,
        },
        bevy::render::render_resource::TextureDimension::D2,
        &[0, 0, 0, 255],
        TextureFormat::Bgra8UnormSrgb,
        RenderAssetUsages::all(),
    )
}

/// Main-world system: keep every mesh webview material of type `M` wired to a
/// surface `Handle<Image>` AND a matching `WebviewSurface` tag, since the macOS
/// accelerated-paint path produces no CPU frames and never allocates one.
///
/// Runs as a per-frame reconciliation (mirroring the CPU path's per-frame
/// `get_or_insert_with`), so a swapped or in-place-replaced material — whose
/// fresh `surface` slot is `None` — is re-wired instead of permanently binding
/// the `AsBindGroup` fallback texture. Reads are non-mutating; `Assets::get_mut`
/// (which flags the asset Modified) runs only when a write is needed.
pub(crate) fn allocate_webview_surfaces_for<M: WebviewSurfaceSlot>(
    mut commands: Commands,
    mut images: ResMut<Assets<Image>>,
    mut materials: ResMut<Assets<M>>,
    webviews: Query<
        (Entity, &MeshMaterial3d<M>, Option<&WebviewSurface>),
        (With<WebviewSource>, Without<WebviewTextureTarget>),
    >,
) {
    for (entity, material_handle, existing) in webviews.iter() {
        let Some(material) = materials.get(material_handle.id()) else {
            continue;
        };
        match (material.webview_surface_slot().clone(), existing) {
            (Some(handle), Some(surface)) if surface.0.id() == handle.id() => {}
            (Some(handle), _) => {
                commands.entity(entity).try_insert(WebviewSurface(handle));
            }
            (None, Some(surface)) => {
                if let Some(material) = materials.get_mut(material_handle.id()) {
                    *material.webview_surface_slot_mut() = Some(surface.0.clone());
                }
            }
            (None, None) => {
                let handle = images.add(placeholder_surface_image(UVec2::ONE));
                if let Some(material) = materials.get_mut(material_handle.id()) {
                    *material.webview_surface_slot_mut() = Some(handle.clone());
                }
                commands.entity(entity).try_insert(WebviewSurface(handle));
            }
        }
    }
}

/// Main-world system: keep every headless webview (one carrying
/// [`WebviewTextureTarget`]) wired to a `WebviewSurface` keyed by the
/// user-supplied handle. Writes the canonical placeholder INTO the user's
/// asset (preserving the handle, like the sprite path) so the image has the
/// pipeline's required format/usages before the first injected frame.
///
/// Per-frame reconciliation like the other allocate paths: a handle swap on
/// the pub field shows up as an id mismatch and re-keys the surface (the
/// existing `CollectedSurfaceId` machinery re-pushes the sticky IOSurface for
/// the new id without waiting for a CEF repaint).
fn allocate_target_webview_surfaces(
    mut commands: Commands,
    mut images: ResMut<Assets<Image>>,
    webviews: Query<(Entity, &WebviewTextureTarget, Option<&WebviewSurface>), With<WebviewSource>>,
    changed: Query<(), Changed<WebviewTextureTarget>>,
    // `warned` is shared by the stale-handle warning and the shared-handle
    // warning below. An id that already fired as stale will not re-fire as
    // shared (and vice versa) — one diagnostic signal per misconfigured id is
    // intentional; re-warning would be noisy with no new information.
    mut warned: Local<HashSet<AssetId<Image>>>,
) {
    for (entity, target, existing) in webviews.iter() {
        if target.0 == Handle::default() {
            bevy::log::warn_once!(
                "[bevy_cef] WebviewTextureTarget holds Handle::default(); create a \
                 dedicated image with `images.add(Image::default())` instead"
            );
            continue;
        }
        let id = target.0.id();
        if existing.is_none_or(|surface| surface.0.id() != id) {
            if let Err(err) = images.insert(id, placeholder_surface_image(UVec2::ONE)) {
                if warned.insert(id) {
                    warn!(
                        "[bevy_cef] WebviewTextureTarget handle is stale; surface not \
                         allocated for {entity}: {err}"
                    );
                }
                continue;
            }
            commands
                .entity(entity)
                .try_insert(WebviewSurface(target.0.clone()));
        }
    }

    // Shared-handle detection: two webviews blitting one asset id is
    // last-blit-wins. Scan only on frames where a target was added/changed
    // (`Changed` includes `Added`), and warn once per distinct id via the
    // `Local` set — each conflicting id is recorded once, ever (a conflict
    // resolved and later re-introduced on the same id will not re-warn).
    if !changed.is_empty() {
        let mut seen: HashMap<AssetId<Image>, Entity> = HashMap::default();
        for (entity, target, _) in webviews.iter() {
            if target.0 == Handle::default() {
                continue;
            }
            if let Some(first) = seen.insert(target.0.id(), entity)
                && warned.insert(target.0.id())
            {
                warn!(
                    "[bevy_cef] WebviewTextureTarget handle shared by {first} and \
                     {entity}; only one webview's frames will be visible (last blit wins)"
                );
            }
        }
    }
}

/// Main-world system: drain each webview's latest retained IOSurface out of
/// `Browsers` (transferring the retain) and pair it with the material's surface
/// `AssetId<Image>`. Webviews without an allocated surface are NOT drained —
/// their frame stays in the browser-side latest-wins slot until the surface
/// exists (CEF never repaints an undamaged page, so dropping the only frame
/// would leave a static page black forever).
///
/// Also detects the events that require a bind-group rebuild — first frame,
/// IOSurface size change, surface re-key — and tags the entity with
/// [`WebviewSurfaceRebind`] for the `mark_*` systems.
fn collect_webview_iosurfaces(
    mut commands: Commands,
    mut rebinds: Query<(Entity, &mut WebviewSurfaceRebind)>,
    mut webviews: Query<(
        Entity,
        &WebviewSurface,
        Option<&mut WebviewIoSurface>,
        Option<&mut CollectedSurfaceId>,
    )>,
    browsers: NonSend<Browsers>,
    pending: ResMut<PendingWebviewIoSurfaces>,
) {
    // Decrement BEFORE any trigger inserts below: the same command queue applies
    // FIFO, so a `try_remove` queued here followed by a re-trigger's `try_insert`
    // leaves the marker present with a fresh count.
    for (entity, mut rebind) in rebinds.iter_mut() {
        rebind.frames_left = rebind.frames_left.saturating_sub(1);
        if rebind.frames_left == 0 {
            commands.entity(entity).try_remove::<WebviewSurfaceRebind>();
        }
    }

    let Ok(mut pending) = pending.0.lock() else {
        return;
    };
    // Clearing releases (CFRelease) any retains a skipped extract never consumed.
    pending.clear();

    let mut new_frames: HashMap<Entity, RetainedIoSurface> = browsers
        .take_latest_webview_iosurfaces(|entity| webviews.contains(entity))
        .into_iter()
        .collect();

    for (entity, surface, io_surface, collected_id) in webviews.iter_mut() {
        let id = surface.0.id();
        if let Some(retained) = new_frames.remove(&entity) {
            // The sticky component keeps an independent retain (`clone()` =
            // CFRetain) for alpha hit-testing; the original moves to the render
            // path. `try_insert`: a despawn may already be queued at this sync point.
            let needs_rebind = if let Some(mut io_surface) = io_surface {
                let resized =
                    io_surface.0.width != retained.width || io_surface.0.height != retained.height;
                io_surface.0 = retained.clone();
                resized
            } else {
                commands
                    .entity(entity)
                    .try_insert(WebviewIoSurface(retained.clone()));
                true
            };
            let rekeyed = if let Some(mut collected_id) = collected_id {
                let rekeyed = collected_id.0 != id;
                collected_id.0 = id;
                rekeyed
            } else {
                commands.entity(entity).try_insert(CollectedSurfaceId(id));
                true
            };
            if needs_rebind || rekeyed {
                commands.entity(entity).try_insert(WebviewSurfaceRebind {
                    frames_left: REBIND_FRAMES,
                });
            }
            pending.push(PendingIoSurface {
                id,
                surface: retained,
            });
        } else if let (Some(io_surface), Some(mut collected_id)) = (io_surface, collected_id) {
            // No new frame, but the surface id was re-keyed (e.g. material swap):
            // re-push the sticky surface so the new id gets pixels — CEF never
            // repaints a static page on its own.
            if collected_id.0 != id {
                collected_id.0 = id;
                commands.entity(entity).try_insert(WebviewSurfaceRebind {
                    frames_left: REBIND_FRAMES,
                });
                pending.push(PendingIoSurface {
                    id,
                    surface: io_surface.0.clone(),
                });
            }
        }
    }
}

/// Extract the pending retained IOSurfaces into the render world, moving the
/// retains across the world boundary (they are `Send`). `Extract` only grants
/// read-only main-world access, so we move ownership out through the `Mutex`.
fn extract_webview_iosurfaces(
    mut extracted: ResMut<ExtractedWebviewIoSurfaces>,
    pending: Extract<Res<PendingWebviewIoSurfaces>>,
) {
    // Releasing the previous frame's retains here is safe even though the GPU
    // may still be executing that frame's blit: the imported Metal texture holds
    // its own IOSurface reference, and wgpu keeps the recorded texture alive
    // until the submission completes.
    extracted.0.clear();
    if let Ok(mut pending) = pending.0.lock() {
        extracted.0.append(&mut pending);
    }
}

/// Extract the surface ids of all live webviews from the main world so the render
/// world can prune `WebviewGpuSurfaces` entries belonging to despawned webviews.
///
/// This is the authoritative live set: a live webview always has its
/// `WebviewSurface` in this query, so its id is always present, and a webview that
/// has despawned drops out — which is exactly the signal `inject_webview_gpu_images`
/// uses to release the leaked owned texture.
fn extract_live_webview_surface_ids(
    mut live: ResMut<LiveWebviewSurfaceIds>,
    surfaces: Extract<Query<&WebviewSurface>>,
) {
    live.0.clear();
    live.0.extend(surfaces.iter().map(|s| s.0.id()));
}

/// Render-graph node: import each retained IOSurface into a transient wgpu texture
/// and record a blit into the frame's command encoder, targeting the owned
/// destination surface. Records only — Bevy submits the encoder once at frame end.
struct WebviewBlitNode;

impl Node for WebviewBlitNode {
    fn run<'w>(
        &self,
        _graph: &mut RenderGraphContext,
        render_context: &mut RenderContext<'w>,
        world: &'w World,
    ) -> Result<(), NodeRunError> {
        let extracted = world.resource::<ExtractedWebviewIoSurfaces>();
        if extracted.0.is_empty() {
            return Ok(());
        }
        let surfaces = world.resource::<WebviewGpuSurfaces>();

        let render_device = render_context.render_device().clone();
        let encoder = render_context.command_encoder();

        for entry in &extracted.0 {
            let Some(surface) = surfaces.0.get(&entry.id) else {
                continue;
            };
            if !surface.import_and_blit(&render_device, encoder, &entry.surface) {
                bevy::log::error_once!(
                    "[macos-gpu-osr] IOSurface import failed ({}x{}); webview textures will \
                     not update (the macOS GPU OSR path requires the Metal wgpu backend)",
                    entry.surface.width,
                    entry.surface.height
                );
            }
        }

        Ok(())
    }
}

/// Render-world system (`PrepareAssets`, after `prepare_assets::<GpuImage>` and
/// before the material's `prepare_erased_assets`): get-or-create the owned GPU
/// destination texture for each extracted webview surface id, wrap it in a
/// `GpuImage`, and overwrite the `RenderAssets<GpuImage>` entry for that id.
///
/// The owned texture is created here (not later in the frame) because it must
/// exist before the material bind group is built. The `WebviewBlitNode` (render
/// graph) fills this same texture's contents from the IOSurface each frame.
fn inject_webview_gpu_images(
    mut surfaces: ResMut<WebviewGpuSurfaces>,
    mut gpu_images: ResMut<RenderAssets<GpuImage>>,
    extracted: Res<ExtractedWebviewIoSurfaces>,
    render_device: Res<RenderDevice>,
    default_sampler: Res<DefaultImageSampler>,
    live: Res<LiveWebviewSurfaceIds>,
) {
    // Dropping a pruned entry releases its owned GPU texture; the matching
    // `RenderAssets` entry is removed by Bevy itself on `AssetEvent::Unused`.
    surfaces.0.retain(|id, _| live.0.contains(id));

    for entry in &extracted.0 {
        // A webview can despawn after collect ran this frame; creating a texture
        // and blitting for a dead id would be wasted work.
        if !live.0.contains(&entry.id) {
            continue;
        }
        surfaces
            .0
            .entry(entry.id)
            .or_insert_with(|| {
                WebviewGpuSurface::new(&render_device, entry.surface.width, entry.surface.height)
            })
            .ensure_size(&render_device, entry.surface.width, entry.surface.height);
    }

    if surfaces.0.is_empty() {
        return;
    }

    // The re-insert below must stay every-frame (not changed-only):
    // `prepare_assets::<GpuImage>` can re-prepare the CPU placeholder on
    // event-less frames (e.g. an upload deferred by `RenderAssetBytesPerFrame`),
    // and this insert is what guarantees the owned texture wins.
    //
    // Bevy's default image sampler is linear; building a fresh
    // `SamplerDescriptor::default()` here would be NEAREST and look aliased.
    let sampler = (**default_sampler).clone();

    for (id, surface) in surfaces.0.iter() {
        let gpu_image = GpuImage {
            texture: surface.texture.clone(),
            texture_view: surface.view.clone(),
            texture_format: TextureFormat::Bgra8UnormSrgb,
            texture_view_format: None,
            sampler: sampler.clone(),
            size: surface.texture.size(),
            mip_level_count: 1,
            had_data: true,
        };

        gpu_images.insert(*id, gpu_image);
    }
}

/// Main-world system: touch a mesh webview material of type `M` on rebind frames
/// (see [`WebviewSurfaceRebind`]) so Bevy re-extracts and rebuilds its bind
/// group, capturing the freshly injected owned-texture view rather than the
/// black placeholder. On steady-state frames no entity carries the marker, so
/// the query matches zero archetypes.
pub(crate) fn mark_webview_materials_changed_for<M: WebviewSurfaceSlot>(
    mut materials: ResMut<Assets<M>>,
    webviews: Query<&MeshMaterial3d<M>, (With<WebviewSource>, With<WebviewSurfaceRebind>)>,
) {
    for handle in webviews.iter() {
        let _ = materials.get_mut(handle.id());
    }
}

/// Main-world system: keep every UI webview's `WebviewUiMaterial.surface` wired
/// to a placeholder `Handle<Image>` and a matching `WebviewSurface` tag. The
/// macOS accelerated-paint path never fires `RenderTextureMessage`, so the
/// CPU-path `render_ui_surface` system never runs on macOS; we do its job here.
///
/// Mirrors `allocate_webview_surfaces_for`'s per-frame reconciliation (material
/// swaps re-wire instead of going permanently black); reads are non-mutating and
/// `get_mut` runs only when a write is needed.
fn allocate_ui_webview_surfaces(
    mut commands: Commands,
    mut images: ResMut<Assets<Image>>,
    mut materials: ResMut<Assets<WebviewUiMaterial>>,
    webviews: Query<
        (
            Entity,
            &MaterialNode<WebviewUiMaterial>,
            Option<&WebviewSurface>,
        ),
        (With<WebviewSource>, Without<WebviewTextureTarget>),
    >,
) {
    for (entity, material_handle, existing) in webviews.iter() {
        let Some(material) = materials.get(material_handle.id()) else {
            continue;
        };
        match (material.surface.clone(), existing) {
            (Some(handle), Some(surface)) if surface.0.id() == handle.id() => {}
            (Some(handle), _) => {
                commands.entity(entity).try_insert(WebviewSurface(handle));
            }
            (None, Some(surface)) => {
                if let Some(material) = materials.get_mut(material_handle.id()) {
                    material.surface = Some(surface.0.clone());
                }
            }
            (None, None) => {
                let handle = images.add(placeholder_surface_image(UVec2::ONE));
                if let Some(material) = materials.get_mut(material_handle.id()) {
                    material.surface = Some(handle.clone());
                }
                commands.entity(entity).try_insert(WebviewSurface(handle));
            }
        }
    }
}

/// Main-world system: touch a UI webview material on rebind frames so Bevy
/// rebuilds the `PreparedUiMaterial` bind group (capturing the injected
/// owned-texture view rather than the black placeholder).
fn mark_webview_ui_materials_changed(
    webviews: Query<
        &MaterialNode<WebviewUiMaterial>,
        (With<WebviewSource>, With<WebviewSurfaceRebind>),
    >,
    mut materials: ResMut<Assets<WebviewUiMaterial>>,
) {
    for handle in webviews.iter() {
        let _ = materials.get_mut(handle.id());
    }
}

/// Main-world system: give every sprite webview a placeholder surface `Image`,
/// point `Sprite.image` at it, and insert `WebviewSurface`.
///
/// Sprites have no material asset, so (unlike mesh/UI) there is nothing to write
/// a surface handle into — the sprite samples `Sprite.image` directly. The
/// placeholder is sized from the entity's `WebviewSize` so the pre-first-frame
/// quad and the picking backend's pixel-space mapping agree with the injected
/// texture.
///
/// When the user supplied their own `Sprite.image` asset, the placeholder is
/// written INTO that asset (preserving the handle) — on the CPU path the webview
/// pixels were written into that very asset, so other handles to it stayed live;
/// orphaning it with a fresh handle would silently break that. `Handle::default()`
/// is shared by every defaulted `Sprite` and must never be overwritten, so a
/// dedicated image is allocated in that case.
fn allocate_sprite_webview_surfaces(
    mut commands: Commands,
    mut images: ResMut<Assets<Image>>,
    mut webviews: Query<
        (Entity, &mut Sprite, &WebviewSize),
        (
            With<WebviewSource>,
            Without<WebviewSurface>,
            Without<WebviewTextureTarget>,
        ),
    >,
) {
    for (entity, mut sprite, size) in webviews.iter_mut() {
        let placeholder = placeholder_surface_image(size.0.as_uvec2());
        let handle = if sprite.image != Handle::default() && images.get(sprite.image.id()).is_some()
        {
            let _ = images.insert(sprite.image.id(), placeholder);
            sprite.image.clone()
        } else {
            let handle = images.add(placeholder);
            sprite.image = handle.clone();
            handle
        };
        commands.entity(entity).try_insert(WebviewSurface(handle));
    }
}

/// Main-world system: touch a sprite webview's `Image` on rebind frames to fire
/// `AssetEvent::Modified` for its id.
///
/// `bevy_sprite_render`'s `prepare_sprite_image_bind_groups` caches per-image
/// bind groups in a private `ImageBindGroups` map and only evicts an entry when
/// it sees an `AssetEvent::Modified { id }` for that image. Firing that event is
/// the only public lever to force a rebuild, so the rebuilt bind group samples
/// our freshly injected owned texture instead of the stale black placeholder.
/// (The Modified event also makes `prepare_assets::<GpuImage>` re-upload the CPU
/// placeholder that frame — harmless, since injection overwrites it — which is
/// exactly why this only fires on rebind frames instead of every frame.)
fn mark_sprite_webview_images_changed(
    webviews: Query<
        &WebviewSurface,
        (
            With<WebviewSource>,
            With<Sprite>,
            With<WebviewSurfaceRebind>,
        ),
    >,
    mut images: ResMut<Assets<Image>>,
) {
    for surface in webviews.iter() {
        let _ = images.get_mut(surface.0.id());
    }
}

/// Main-world system: touch a headless webview's target `Image` on rebind
/// frames (see [`WebviewSurfaceRebind`]) so `AssetEvent::Modified` fires for
/// its id. This is the PUBLIC rebind signal for third-party consumers: Bevy
/// only rebuilds a material's bind group on the *material's own* asset events,
/// never on a referenced image's, so a consumer must `get_mut` its material
/// when this event fires (or use `WebviewTargetUiMaterialPlugin`). Mirrors
/// `mark_sprite_webview_images_changed`; the CPU placeholder re-upload this
/// triggers is harmless — injection overwrites it the same frame.
fn mark_target_webview_images_changed(
    webviews: Query<
        &WebviewSurface,
        (
            With<WebviewSource>,
            With<WebviewTextureTarget>,
            With<WebviewSurfaceRebind>,
        ),
    >,
    mut images: ResMut<Assets<Image>>,
) {
    for surface in webviews.iter() {
        let _ = images.get_mut(surface.0.id());
    }
}

/// Main-world system: touch every third-party asset of type `M` that
/// references a rebinding headless webview target (see
/// `crate::webview::texture_target::WebviewTextureSlot`), so Bevy rebuilds its
/// bind group against the freshly injected texture. Registered per material
/// type by `WebviewTargetUiMaterialPlugin`.
pub(crate) fn mark_target_materials_changed_for<M: WebviewTextureSlot>(
    rebinding: Query<&WebviewSurface, (With<WebviewTextureTarget>, With<WebviewSurfaceRebind>)>,
    mut materials: ResMut<Assets<M>>,
) {
    let rebind_ids: HashSet<AssetId<Image>> =
        rebinding.iter().map(|surface| surface.0.id()).collect();
    if rebind_ids.is_empty() {
        return;
    }
    // Two-phase on purpose: `iter_mut` would flag every `M` asset Modified; a
    // read-only scan plus targeted `get_mut` touches only the matches. The
    // linear scan is fine — rebind frames are rare and material asset counts
    // are small (a reverse index was considered and rejected in the spec).
    let to_touch: Vec<AssetId<M>> = materials
        .iter()
        .filter(|(_, material)| {
            material
                .webview_targets()
                .any(|target| rebind_ids.contains(&target))
        })
        .map(|(id, _)| id)
        .collect();
    for id in to_touch {
        let _ = materials.get_mut(id);
    }
}