1
//! Screen-capture widget — a "dumb widget" identical in architecture to the
2
//! [`CameraWidget`](super::camera), only the source differs (a display /
3
//! window). SUPER_PLAN_2 §4 P6, widget pivot.
4
//!
5
//! `ScreenCaptureWidget::create(config).dom()` → an `<img>` a background
6
//! capture thread keeps fed; each frame goes through
7
//! [`super::capture_common::present_frame`] (GL-texture install-once /
8
//! re-upload + recomposite). The shared core lives in `capture_common`; this
9
//! widget is its config + worker. Test-pattern worker (a moving band) stands
10
//! in for the real ScreenCaptureKit / MediaProjection / PipeWire worker.
11

            
12
use alloc::vec::Vec;
13

            
14
use azul_core::callbacks::Update;
15
use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter};
16
use azul_core::refany::{OptionRefAny, RefAny};
17
use azul_core::resources::{ImageRef, RawImageFormat};
18
use azul_core::screencap::ScreenCaptureConfig;
19
use azul_core::task::{ThreadId, ThreadReceiver};
20

            
21
use azul_core::video::VideoFrame;
22

            
23
use super::capture_common::{
24
    invoke_on_frame, present_frame, screen_backend, terminate_requested, OnVideoFrame,
25
    OnVideoFrameCallback, OptionOnVideoFrame,
26
};
27
use crate::callbacks::{Callback, CallbackInfo, CallbackType};
28
use crate::thread::{
29
    Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
30
};
31

            
32
/// Default capture size for the test pattern (the real backend reports the
33
/// source's actual size).
34
const DEFAULT_W: u32 = 1280;
35
const DEFAULT_H: u32 = 720;
36

            
37
/// Live state for one screencap widget, carried across relayout by
38
/// [`merge_screencap_state`].
39
#[derive(Debug)]
40
pub struct ScreenCaptureWidgetState {
41
    /// The requested capture configuration (the control POD).
42
    pub config: ScreenCaptureConfig,
43
    /// `true` once the capture thread has been started.
44
    pub started: bool,
45
    /// The stable external GL texture id once installed.
46
    pub gl_texture_id: Option<u32>,
47
    /// Optional user hook invoked with each captured frame (effects / save /
48
    /// send). Re-set on every fresh build (see [`merge_screencap_state`]).
49
    pub on_frame: OptionOnVideoFrame,
50
}
51

            
52
/// A screen-capture widget. `create(config).dom()` yields an `<img>` the
53
/// capture thread keeps fed.
54
#[repr(C)]
55
#[derive(Debug)]
56
pub struct ScreenCaptureWidget {
57
    /// What to capture + fps + format.
58
    pub config: ScreenCaptureConfig,
59
    /// Optional per-frame user hook (effects / save / send - azul-meet).
60
    pub on_frame: OptionOnVideoFrame,
61
}
62

            
63
impl ScreenCaptureWidget {
64
    /// Create a screencap widget for the given config.
65
66
    #[must_use] pub const fn create(config: ScreenCaptureConfig) -> Self {
66
66
        Self {
67
66
            config,
68
66
            on_frame: OptionOnVideoFrame::None,
69
66
        }
70
66
    }
71

            
72
    /// Set a hook invoked with every captured frame - for live effects, saving
73
    /// frames into your data model, or sending them over the network
74
    /// (azul-meet). The backreference DI pattern (see `architecture.md`).
75
28
    pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
76
28
        self.on_frame = Some(OnVideoFrame {
77
28
            refany: data,
78
28
            callback: on_frame.into(),
79
28
        })
80
28
        .into();
81
28
    }
82

            
83
    /// Builder form of [`set_on_frame`](Self::set_on_frame).
84
    #[must_use]
85
9
    pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
86
9
        mut self,
87
9
        data: RefAny,
88
9
        on_frame: C,
89
9
    ) -> Self {
90
9
        self.set_on_frame(data, on_frame);
91
9
        self
92
9
    }
93

            
94
    /// Build the widget's DOM: a single `<img>` node, fed by a background
95
    /// capture thread started on mount.
96
22
    #[must_use] pub fn dom(self) -> Dom {
97
22
        let state = ScreenCaptureWidgetState {
98
22
            config: self.config,
99
22
            started: false,
100
22
            gl_texture_id: None,
101
22
            on_frame: self.on_frame,
102
22
        };
103
22
        let dataset = RefAny::new(state);
104

            
105
22
        let placeholder = ImageRef::null_image(
106
22
            DEFAULT_W as usize,
107
22
            DEFAULT_H as usize,
108
22
            RawImageFormat::BGRA8,
109
22
            b"azul-screencap-placeholder".to_vec(),
110
        );
111

            
112
22
        Dom::create_image(placeholder)
113
22
            .with_dataset(OptionRefAny::Some(dataset.clone()))
114
22
            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_screencap_state))
115
22
            .with_callback(
116
22
                EventFilter::Component(ComponentEventFilter::AfterMount),
117
22
                dataset,
118
22
                Callback::from_ptr(screencap_on_after_mount),
119
            )
120
22
    }
121
}
122

            
123
/// `AfterMount`: start the background capture thread exactly once.
124
5
extern "C" fn screencap_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
125
    {
126
5
        let Some(mut s) = data.downcast_mut::<ScreenCaptureWidgetState>() else {
127
2
            return Update::DoNothing;
128
        };
129
3
        if s.started {
130
3
            return Update::DoNothing;
131
        }
132
        s.started = true;
133
    }
134
    info.add_thread(
135
        ThreadId::unique(),
136
        Thread::create(
137
            RefAny::new(()),
138
            data.clone(),
139
            ThreadCallback::new(screencap_worker),
140
        ),
141
    );
142
    Update::DoNothing
143
5
}
144

            
145
/// Background worker (test pattern): a downward-moving white band on dark grey,
146
/// ~30x/s. Replaced by the real `ScreenCaptureKit` / `MediaProjection` worker.
147
1
extern "C" fn screencap_worker(
148
1
    _init: RefAny,
149
1
    mut sender: ThreadSender,
150
1
    mut recv: ThreadReceiver,
151
1
) {
152
    // Real platform capture if the dll registered a screen backend
153
    // (ScreenCaptureKit / X11 / DXGI; Wayland stays a dummy); else the test pattern.
154
1
    if let Some(backend) = screen_backend() {
155
1
        let handle = (backend.open)(0, DEFAULT_W, DEFAULT_H);
156
1
        if handle != 0 {
157
1
            let mut buf: Vec<u8> = Vec::new();
158
            loop {
159
                // See `capture_common::terminate_requested`: without this the
160
                // screen worker never observed `TerminateThread` and was
161
                // DETACHED at shutdown.
162
1
                if terminate_requested(&mut recv) {
163
1
                    break;
164
                }
165
                let (fw, fh) = (backend.read)(handle, &mut buf);
166
                if fw == 0 || fh == 0 {
167
                    break;
168
                }
169
                let frame = VideoFrame {
170
                    width: fw,
171
                    height: fh,
172
                    bytes: buf.clone().into(),
173
                };
174
                if !sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
175
                    WriteBackCallback::new(screencap_writeback),
176
                    RefAny::new(frame),
177
                ))) {
178
                    break;
179
                }
180
            }
181
1
            (backend.close)(handle);
182
1
            return;
183
        }
184
    }
185

            
186
    // Reaching here means a ScreenCaptureWidget is on screen and about to show
187
    // the TEST PATTERN instead of the screen — say why, once. The dll-side
188
    // [screencap] lines (if any) carry the detailed cause right above.
189
    {
190
        static TEST_PATTERN_ANNOUNCE: std::sync::Once = std::sync::Once::new();
191
        let have_backend = screen_backend().is_some();
192
        TEST_PATTERN_ANNOUNCE.call_once(|| {
193
            if have_backend {
194
                eprintln!(
195
                    "[azul][screencap] the platform screen-capture backend failed to \
196
                     open (see [screencap] lines above for the cause) — showing the \
197
                     moving-band TEST PATTERN instead of the screen"
198
                );
199
            } else {
200
                eprintln!(
201
                    "[azul][screencap] no screen-capture backend is registered in this \
202
                     build/OS — showing the moving-band TEST PATTERN instead of the \
203
                     screen"
204
                );
205
            }
206
        });
207
    }
208

            
209
    let (w, h) = (DEFAULT_W as usize, DEFAULT_H as usize);
210
    let mut tick: u32 = 0;
211
    loop {
212
        if terminate_requested(&mut recv) {
213
            break;
214
        }
215
        let band = (tick as usize) % h;
216
        let mut bytes = Vec::with_capacity(w * h * 4);
217
        for y in 0..h {
218
            let v = if y.abs_diff(band) < 8 { 235u8 } else { 28u8 };
219
            for _ in 0..w {
220
                bytes.extend_from_slice(&[v, v, v, 255]);
221
            }
222
        }
223
        let frame = VideoFrame {
224
            width: u32::try_from(w).unwrap_or(0),
225
            height: u32::try_from(h).unwrap_or(0),
226
            bytes: bytes.into(),
227
        };
228
        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
229
            WriteBackCallback::new(screencap_writeback),
230
            RefAny::new(frame),
231
        )));
232
        if !sent {
233
            break;
234
        }
235
        std::thread::sleep(std::time::Duration::from_millis(33));
236
        tick = tick.wrapping_add(12);
237
    }
238
1
}
239

            
240
/// Writeback (main thread): hand the frame to the shared GL presenter and
241
/// store the (stable) texture id.
242
15
extern "C" fn screencap_writeback(
243
15
    mut writeback_data: RefAny,
244
15
    mut frame_data: RefAny,
245
15
    mut info: CallbackInfo,
246
15
) -> Update {
247
15
    let (current, hook) = writeback_data.downcast_ref::<ScreenCaptureWidgetState>().map_or_else(|| (None, OptionOnVideoFrame::None), |s| (s.gl_texture_id, s.on_frame.clone()));
248
15
    let mut user_update = Update::DoNothing;
249
15
    let new_id = match frame_data.downcast_ref::<VideoFrame>() {
250
14
        Some(frame) => {
251
14
            let id = present_frame(&mut info, writeback_data.clone(), current, &frame);
252
14
            user_update = invoke_on_frame(&hook, &mut info, &frame);
253
14
            id
254
        }
255
1
        None => return Update::DoNothing,
256
    };
257
14
    if let Some(mut s) = writeback_data.downcast_mut::<ScreenCaptureWidgetState>() {
258
13
        s.gl_texture_id = new_id;
259
13
    }
260
14
    user_update
261
15
}
262

            
263
/// Carry live state forward across relayout.
264
6
extern "C" fn merge_screencap_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
265
    // Return the OLD allocation (the one live capture backends may hold a
266
    // clone of), adopting config forward — the merge_map_tile_cache rule.
267
    // Returning new_data re-points the DOM at a fresh allocation; today the
268
    // frame writeback survives that only because present_frame finds its
269
    // node by RefAny TYPE id, which also means two widgets of the same type
270
    // collide. Keeping the persistent allocation makes dataset identity
271
    // stable so that search can become an identity lookup.
272
6
    let merged_into_old = {
273
6
        let new_guard = new_data.downcast_ref::<ScreenCaptureWidgetState>();
274
6
        let old_guard = old_data.downcast_mut::<ScreenCaptureWidgetState>();
275
6
        if let (Some(new_g), Some(mut old_g)) = (new_guard, old_guard) {
276
3
            old_g.config = new_g.config;
277
3
            old_g.on_frame = new_g.on_frame.clone();
278
3
            true
279
        } else {
280
            // Foreign / mismatched payloads (one side is not this widget's
281
            // state): hand back the NEW payload untouched — there is no
282
            // persistent allocation to preserve, and returning a
283
            // wrong-typed old dataset would poison the node.
284
3
            false
285
        }
286
    };
287
6
    if merged_into_old {
288
3
        old_data
289
    } else {
290
3
        new_data
291
    }
292
6
}
293

            
294
// ============================================================================
295
// Generated adversarial tests
296
// ============================================================================
297

            
298
#[cfg(test)]
299
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
300
mod autotest_generated {
301
    use std::{
302
        collections::BTreeMap,
303
        panic::{catch_unwind, AssertUnwindSafe},
304
        sync::{
305
            mpsc::{channel, Receiver, Sender},
306
            Arc, Mutex, PoisonError,
307
        },
308
    };
309

            
310
    use azul_core::{
311
        dom::{DomId, DomNodeId, NodeType},
312
        geom::OptionLogicalPosition,
313
        gl::OptionGlContextPtr,
314
        hit_test::ScrollPosition,
315
        resources::{DecodedImage, RendererResources},
316
        screencap::ScreenCaptureSource,
317
        styled_dom::NodeHierarchyItemId,
318
        task::{
319
            OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
320
            ThreadRecvCallback, ThreadSendMsg,
321
        },
322
        window::{MonitorVec, RawWindowHandle},
323
    };
324
    use azul_css::system::SystemStyle;
325
    use rust_fontconfig::FcFontCache;
326

            
327
    use super::*;
328
    #[cfg(feature = "icu")]
329
    use crate::icu::IcuLocalizerHandle;
330
    use crate::{
331
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
332
        thread::{ThreadSendCallback, ThreadSenderDestructorCallback, ThreadSenderInner},
333
        widgets::capture_common::OnVideoFrameCallbackType,
334
        window::LayoutWindow,
335
        window_state::FullWindowState,
336
    };
337

            
338
    // ------------------------------------------------------------------
339
    // Config fixtures
340
    // ------------------------------------------------------------------
341

            
342
    const fn cfg(
343
        source: ScreenCaptureSource,
344
        fps: u32,
345
        output_format: RawImageFormat,
346
    ) -> ScreenCaptureConfig {
347
        ScreenCaptureConfig {
348
            source,
349
            fps,
350
            output_format,
351
        }
352
    }
353

            
354
    /// Representative + extreme configs: both payload boundaries of each
355
    /// carrying `ScreenCaptureSource` variant, `fps` at 0 / 1 / `u32::MAX`, and
356
    /// a format that is deliberately *not* the widget's placeholder format.
357
    const ALL_CONFIGS: [ScreenCaptureConfig; 8] = [
358
        cfg(ScreenCaptureSource::PrimaryDisplay, 0, RawImageFormat::BGRA8),
359
        cfg(
360
            ScreenCaptureSource::PrimaryDisplay,
361
            u32::MAX,
362
            RawImageFormat::RGBA8,
363
        ),
364
        cfg(ScreenCaptureSource::Display(0), 1, RawImageFormat::BGRA8),
365
        cfg(
366
            ScreenCaptureSource::Display(u32::MAX),
367
            60,
368
            RawImageFormat::R8,
369
        ),
370
        cfg(ScreenCaptureSource::Window(0), 0, RawImageFormat::BGRA8),
371
        cfg(
372
            ScreenCaptureSource::Window(u64::MAX),
373
            u32::MAX,
374
            RawImageFormat::R8,
375
        ),
376
        cfg(
377
            ScreenCaptureSource::Window(u32::MAX as u64),
378
            30,
379
            RawImageFormat::RGBA8,
380
        ),
381
        cfg(ScreenCaptureSource::Display(1), 240, RawImageFormat::BGRA8),
382
    ];
383

            
384
    const DEFAULT_CFG: ScreenCaptureConfig = ALL_CONFIGS[0];
385

            
386
    /// Compile-time proof that `create` really is a `const fn` (its `const`
387
    /// qualifier is part of the public API - a non-const `create` would make
388
    /// this fn fail to compile).
389
    const fn const_create(config: ScreenCaptureConfig) -> ScreenCaptureWidget {
390
        ScreenCaptureWidget::create(config)
391
    }
392

            
393
    // ------------------------------------------------------------------
394
    // State fixtures
395
    // ------------------------------------------------------------------
396

            
397
    /// A `ScreenCaptureWidgetState` payload with no `on_frame` hook.
398
    fn state(
399
        config: ScreenCaptureConfig,
400
        started: bool,
401
        gl_texture_id: Option<u32>,
402
    ) -> RefAny {
403
        RefAny::new(ScreenCaptureWidgetState {
404
            config,
405
            started,
406
            gl_texture_id,
407
            on_frame: OptionOnVideoFrame::None,
408
        })
409
    }
410

            
411
    /// `(config, started, gl_texture_id, has_hook)` of a `ScreenCaptureWidgetState`.
412
    fn read_state(data: &mut RefAny) -> (ScreenCaptureConfig, bool, Option<u32>, bool) {
413
        let s = data
414
            .downcast_ref::<ScreenCaptureWidgetState>()
415
            .expect("payload must still be a ScreenCaptureWidgetState");
416
        (
417
            s.config,
418
            s.started,
419
            s.gl_texture_id,
420
            matches!(s.on_frame, OptionOnVideoFrame::Some(_)),
421
        )
422
    }
423

            
424
    /// The placeholder image behind an `<img>` `Dom` root: `(w, h, format, tag)`.
425
    fn placeholder_of(dom: &Dom) -> (usize, usize, RawImageFormat, Vec<u8>) {
426
        let NodeType::Image(image) = dom.root.get_node_type() else {
427
            panic!("ScreenCaptureWidget::dom must build an image node");
428
        };
429
        match image.get_data() {
430
            DecodedImage::NullImage {
431
                width,
432
                height,
433
                format,
434
                tag,
435
            } => (*width, *height, *format, tag.clone()),
436
            _ => panic!("the placeholder must be a NullImage (no decode, no allocation)"),
437
        }
438
    }
439

            
440
    // ---- frame hook -------------------------------------------------------
441

            
442
    /// Records every frame a widget's `on_frame` hook is handed, and replies
443
    /// with a caller-chosen `Update`.
444
    struct FrameLog {
445
        seen: Vec<(u32, u32, usize)>,
446
        reply: Update,
447
    }
448

            
449
    extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
450
        let mut reply = Update::DoNothing;
451
        if let Some(mut log) = data.downcast_mut::<FrameLog>() {
452
            log.seen
453
                .push((frame.width, frame.height, frame.bytes.as_ref().len()));
454
            reply = log.reply;
455
        }
456
        reply
457
    }
458

            
459
    extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: VideoFrame) -> Update {
460
        // A distinct body so the linker cannot fold this onto `record_frame` and
461
        // make the fn-pointer identity assertions vacuous.
462
        core::hint::black_box(Update::DoNothing)
463
    }
464

            
465
    fn frame_log(reply: Update) -> RefAny {
466
        RefAny::new(FrameLog {
467
            seen: Vec::new(),
468
            reply,
469
        })
470
    }
471

            
472
    /// The frames recorded by a `FrameLog` payload.
473
    fn logged_frames(data: &mut RefAny) -> Vec<(u32, u32, usize)> {
474
        data.downcast_ref::<FrameLog>()
475
            .expect("payload must still be a FrameLog")
476
            .seen
477
            .clone()
478
    }
479

            
480
    /// A `ScreenCaptureWidgetState` whose `on_frame` hook writes into `log`.
481
    fn state_with_hook(config: ScreenCaptureConfig, log: &RefAny) -> RefAny {
482
        RefAny::new(ScreenCaptureWidgetState {
483
            config,
484
            started: true,
485
            gl_texture_id: None,
486
            on_frame: Some(OnVideoFrame {
487
                refany: log.clone(),
488
                callback: (record_frame as OnVideoFrameCallbackType).into(),
489
            })
490
            .into(),
491
        })
492
    }
493

            
494
    /// A tightly-packed RGBA frame (`width * height * 4` bytes).
495
    fn frame(width: u32, height: u32) -> VideoFrame {
496
        let px = (width as usize) * (height as usize);
497
        VideoFrame {
498
            width,
499
            height,
500
            bytes: vec![7u8; px * 4].into(),
501
        }
502
    }
503

            
504
    /// A frame whose declared dimensions need not match its byte count.
505
    fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
506
        VideoFrame {
507
            width,
508
            height,
509
            bytes: bytes.into(),
510
        }
511
    }
512

            
513
    // ---- CallbackInfo harness --------------------------------------------
514

            
515
    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no GL
516
    /// context -> the widget's CPU present path). Returns `f`'s value plus every
517
    /// `CallbackChange` the callback recorded.
518
    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
519
        let layout_window =
520
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
521
        let renderer_resources = RendererResources::default();
522
        let previous_window_state: Option<FullWindowState> = None;
523
        let current_window_state = FullWindowState::default();
524
        let gl_context = OptionGlContextPtr::None;
525
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
526
            BTreeMap::new();
527
        let window_handle = RawWindowHandle::Unsupported;
528
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
529

            
530
        let ref_data = CallbackInfoRefData {
531
            layout_window: &layout_window,
532
            renderer_resources: &renderer_resources,
533
            previous_window_state: &previous_window_state,
534
            current_window_state: &current_window_state,
535
            gl_context: &gl_context,
536
            current_scroll_manager: &scroll_states,
537
            current_window_handle: &window_handle,
538
            system_callbacks: &system_callbacks,
539
            system_style: Arc::new(SystemStyle::default()),
540
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
541
            #[cfg(feature = "icu")]
542
            icu_localizer: IcuLocalizerHandle::default(),
543
            ctx: OptionRefAny::None,
544
        };
545

            
546
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
547

            
548
        let info = CallbackInfo::new(
549
            &ref_data,
550
            &changes,
551
            DomNodeId {
552
                dom: DomId::ROOT_ID,
553
                node: NodeHierarchyItemId::NONE,
554
            },
555
            OptionLogicalPosition::None,
556
            OptionLogicalPosition::None,
557
        );
558

            
559
        let out = f(info);
560
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
561
        (out, recorded)
562
    }
563

            
564
    // ---- screencap_worker harness ----------------------------------------
565

            
566
    /// One frame `screencap_worker` pushed, summarised so the (multi-megabyte)
567
    /// pixel buffer never has to be cloned into the log.
568
    #[derive(Debug, Clone, PartialEq, Eq)]
569
    struct SentFrame {
570
        width: u32,
571
        height: u32,
572
        len: usize,
573
        /// The first byte of every scanline (that row's test-pattern value).
574
        row_values: Vec<u8>,
575
        /// Every pixel of every scanline is `[v, v, v, 255]` for that row's `v`.
576
        rows_uniform_opaque: bool,
577
    }
578

            
579
    /// Everything `screencap_worker` managed to send. Guarded by `WORKER_GATE` -
580
    /// the worker's send callback is a plain C fn pointer, so it has nowhere else
581
    /// to put its result.
582
    static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
583
    static WORKER_GATE: Mutex<()> = Mutex::new(());
584

            
585
    /// Records the frame, then reports the send as *failed* - i.e. "the main
586
    /// thread is gone", the only signal `screencap_worker` has to stop. A worker
587
    /// that ignored it would hang this test binary forever (and grow ~3.7 MB per
588
    /// 33 ms while doing so).
589
    extern "C" fn record_and_stop(_sender: *const core::ffi::c_void, msg: ThreadReceiveMsg) -> bool {
590
        if let ThreadReceiveMsg::WriteBack(mut wb) = msg {
591
            if let Some(f) = wb.refany.downcast_ref::<VideoFrame>() {
592
                let bytes = f.bytes.as_ref();
593
                let stride = (f.width as usize) * 4;
594
                let mut row_values = Vec::new();
595
                let mut rows_uniform_opaque = true;
596
                if stride > 0 {
597
                    for row in bytes.chunks_exact(stride) {
598
                        let v = row[0];
599
                        row_values.push(v);
600
                        if !row.chunks_exact(4).all(|px| px == &[v, v, v, 255][..]) {
601
                            rows_uniform_opaque = false;
602
                        }
603
                    }
604
                }
605
                WORKER_LOG
606
                    .lock()
607
                    .unwrap_or_else(PoisonError::into_inner)
608
                    .push(SentFrame {
609
                        width: f.width,
610
                        height: f.height,
611
                        len: bytes.len(),
612
                        row_values,
613
                        rows_uniform_opaque,
614
                    });
615
            }
616
        }
617
        false
618
    }
619

            
620
    extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
621
    extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
622
    extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
623
        OptionThreadSendMsg::None
624
    }
625

            
626
    /// A `ThreadSender` whose every `send` is recorded and then rejected.
627
    fn stopped_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
628
        let (tx, rx) = channel::<ThreadReceiveMsg>();
629
        let sender = ThreadSender::new(ThreadSenderInner {
630
            ptr: Box::new(tx),
631
            send_fn: ThreadSendCallback { cb: record_and_stop },
632
            destructor: ThreadSenderDestructorCallback {
633
                cb: sender_drop_noop,
634
            },
635
        });
636
        (rx, sender)
637
    }
638

            
639
    /// A `ThreadReceiver` that never delivers anything (the worker ignores it).
640
    fn silent_receiver() -> (Sender<ThreadSendMsg>, ThreadReceiver) {
641
        let (tx, rx) = channel::<ThreadSendMsg>();
642
        let receiver = ThreadReceiver::new(ThreadReceiverInner {
643
            ptr: Box::new(rx),
644
            recv_fn: ThreadRecvCallback { cb: recv_nothing },
645
            destructor: ThreadReceiverDestructorCallback {
646
                cb: receiver_drop_noop,
647
            },
648
        });
649
        (tx, receiver)
650
    }
651

            
652
    /// Runs `screencap_worker` with `init` against a sender that rejects the
653
    /// first frame, and returns everything the worker managed to send.
654
    ///
655
    /// `None` when a real platform screen backend is registered in this process
656
    /// (`capture_common`'s own tests register one into the same process-global
657
    /// `OnceLock`) - the worker is then not the test pattern these assertions
658
    /// describe. The check *after* the run is the load-bearing one: a `OnceLock`
659
    /// is monotone, so "still unset afterwards" proves it was unset throughout.
660
    fn run_worker(init: RefAny) -> Option<Vec<SentFrame>> {
661
        let _gate = WORKER_GATE.lock().unwrap_or_else(PoisonError::into_inner);
662
        if screen_backend().is_some() {
663
            return None;
664
        }
665
        WORKER_LOG
666
            .lock()
667
            .unwrap_or_else(PoisonError::into_inner)
668
            .clear();
669

            
670
        let (_rx, sender) = stopped_sender();
671
        let (_tx, receiver) = silent_receiver();
672
        screencap_worker(init, sender, receiver);
673

            
674
        if screen_backend().is_some() {
675
            return None; // registered by a parallel test mid-run
676
        }
677
        Some(
678
            WORKER_LOG
679
                .lock()
680
                .unwrap_or_else(PoisonError::into_inner)
681
                .clone(),
682
        )
683
    }
684

            
685
    // ------------------------------------------------------------------
686
    // ScreenCaptureWidget::create
687
    // ------------------------------------------------------------------
688

            
689
    #[test]
690
    fn create_stores_the_config_verbatim_and_leaves_the_hook_unset() {
691
        for config in ALL_CONFIGS {
692
            let widget = ScreenCaptureWidget::create(config);
693
            assert_eq!(
694
                widget.config, config,
695
                "create must not normalise or clamp the config"
696
            );
697
            assert!(
698
                matches!(widget.on_frame, OptionOnVideoFrame::None),
699
                "a fresh widget has no frame hook"
700
            );
701
        }
702
    }
703

            
704
    #[test]
705
    fn create_preserves_the_full_source_payload_width() {
706
        // A `as u32` anywhere in the widget would collapse a u64 window handle.
707
        let widget = ScreenCaptureWidget::create(cfg(
708
            ScreenCaptureSource::Window(u64::MAX),
709
            0,
710
            RawImageFormat::BGRA8,
711
        ));
712
        match widget.config.source {
713
            ScreenCaptureSource::Window(h) => assert_eq!(h, u64::MAX),
714
            other => panic!("expected Window(u64::MAX), got {other:?}"),
715
        }
716

            
717
        let widget = ScreenCaptureWidget::create(cfg(
718
            ScreenCaptureSource::Display(u32::MAX),
719
            u32::MAX,
720
            RawImageFormat::BGRA8,
721
        ));
722
        match widget.config.source {
723
            ScreenCaptureSource::Display(i) => assert_eq!(i, u32::MAX),
724
            other => panic!("expected Display(u32::MAX), got {other:?}"),
725
        }
726
        assert_eq!(widget.config.fps, u32::MAX, "fps must not be clamped");
727
    }
728

            
729
    #[test]
730
    fn create_is_usable_from_a_const_fn() {
731
        for config in ALL_CONFIGS {
732
            let widget = const_create(config);
733
            assert_eq!(widget.config, config);
734
            assert!(matches!(widget.on_frame, OptionOnVideoFrame::None));
735
        }
736
    }
737

            
738
    // ------------------------------------------------------------------
739
    // ScreenCaptureWidget::set_on_frame / with_on_frame
740
    // ------------------------------------------------------------------
741

            
742
    #[test]
743
    fn set_on_frame_installs_the_hook_without_touching_the_config() {
744
        for config in ALL_CONFIGS {
745
            let mut widget = ScreenCaptureWidget::create(config);
746
            widget.set_on_frame(
747
                frame_log(Update::DoNothing),
748
                record_frame as OnVideoFrameCallbackType,
749
            );
750

            
751
            assert_eq!(widget.config, config, "the hook must not alter the config");
752
            let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
753
                panic!("set_on_frame must install a hook");
754
            };
755
            assert_eq!(
756
                hook.callback.cb as usize,
757
                record_frame as OnVideoFrameCallbackType as usize,
758
                "the stored fn pointer must be exactly the one that was passed in"
759
            );
760
        }
761
    }
762

            
763
    #[test]
764
    fn set_on_frame_twice_keeps_only_the_last_hook() {
765
        let mut widget = ScreenCaptureWidget::create(DEFAULT_CFG);
766
        widget.set_on_frame(
767
            RefAny::new(0_usize),
768
            record_frame as OnVideoFrameCallbackType,
769
        );
770
        widget.set_on_frame(
771
            RefAny::new(1_usize),
772
            frame_do_nothing as OnVideoFrameCallbackType,
773
        );
774

            
775
        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
776
            panic!("hook must still be set");
777
        };
778
        assert_eq!(
779
            hook.callback.cb as usize,
780
            frame_do_nothing as OnVideoFrameCallbackType as usize,
781
            "the second set_on_frame must replace the first, not stack"
782
        );
783
        assert_eq!(
784
            hook.refany.clone().downcast_ref::<usize>().map(|v| *v),
785
            Some(1),
786
            "the replacement's payload must come with it"
787
        );
788
    }
789

            
790
    #[test]
791
    fn set_on_frame_shares_the_users_payload_rather_than_copying_it() {
792
        // The backreference DI pattern only works if the widget holds a handle to
793
        // the *same* allocation the caller kept.
794
        let mut log = frame_log(Update::DoNothing);
795
        let mut widget = ScreenCaptureWidget::create(DEFAULT_CFG);
796
        widget.set_on_frame(log.clone(), record_frame as OnVideoFrameCallbackType);
797

            
798
        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
799
            panic!("hook must be set");
800
        };
801
        let mut stored = hook.refany.clone();
802
        {
803
            let mut inner = stored
804
                .downcast_mut::<FrameLog>()
805
                .expect("the widget must hold a FrameLog");
806
            inner.seen.push((1, 2, 3));
807
        }
808
        assert_eq!(
809
            logged_frames(&mut log),
810
            vec![(1, 2, 3)],
811
            "the widget must share the caller's payload, not clone it"
812
        );
813
    }
814

            
815
    #[test]
816
    fn with_on_frame_is_exactly_create_plus_set_on_frame() {
817
        for config in ALL_CONFIGS {
818
            let built = ScreenCaptureWidget::create(config).with_on_frame(
819
                frame_log(Update::RefreshDom),
820
                record_frame as OnVideoFrameCallbackType,
821
            );
822
            let mut manual = ScreenCaptureWidget::create(config);
823
            manual.set_on_frame(
824
                frame_log(Update::RefreshDom),
825
                record_frame as OnVideoFrameCallbackType,
826
            );
827

            
828
            assert_eq!(built.config, config, "the builder must not touch the config");
829
            assert_eq!(built.config, manual.config);
830

            
831
            let (OptionOnVideoFrame::Some(a), OptionOnVideoFrame::Some(b)) =
832
                (&built.on_frame, &manual.on_frame)
833
            else {
834
                panic!("both forms must install a hook");
835
            };
836
            assert_eq!(a.callback.cb as usize, b.callback.cb as usize);
837
        }
838
    }
839

            
840
    // ------------------------------------------------------------------
841
    // ScreenCaptureWidget::dom
842
    // ------------------------------------------------------------------
843

            
844
    #[test]
845
    fn dom_placeholder_is_always_1280x720_bgra8_whatever_the_config_asks_for() {
846
        // The placeholder is a fixed-size stand-in: the *real* size is whatever
847
        // the backend reports at runtime. So neither the requested source nor the
848
        // requested output format may leak into it.
849
        for config in ALL_CONFIGS {
850
            let (w, h, format, tag) = placeholder_of(&ScreenCaptureWidget::create(config).dom());
851
            assert_eq!(
852
                (w, h),
853
                (1280, 720),
854
                "the placeholder size is fixed, not derived from {config:?}"
855
            );
856
            assert_eq!(
857
                format,
858
                RawImageFormat::BGRA8,
859
                "output_format is a *capture* request; the placeholder stays BGRA8"
860
            );
861
            assert_eq!(tag, b"azul-screencap-placeholder".to_vec());
862
        }
863
    }
864

            
865
    #[test]
866
    fn dom_placeholder_is_a_null_image_that_allocates_no_pixels() {
867
        // 1280 * 720 * 4 bytes would be ~3.7 MB per widget if the placeholder were
868
        // a real raw image; a NullImage is only a descriptor.
869
        let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
870
        let NodeType::Image(image) = dom.root.get_node_type() else {
871
            panic!("the widget must build an image node");
872
        };
873
        assert!(
874
            matches!(image.get_data(), DecodedImage::NullImage { .. }),
875
            "the placeholder must not decode or allocate"
876
        );
877
    }
878

            
879
    #[test]
880
    fn dom_wires_exactly_one_after_mount_callback_a_dataset_and_a_merge_callback() {
881
        let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
882

            
883
        assert_eq!(dom.children.as_ref().len(), 0, "the widget is a single node");
884

            
885
        let callbacks = dom.root.get_callbacks();
886
        assert_eq!(
887
            callbacks.as_ref().len(),
888
            1,
889
            "exactly one callback: the AfterMount capture-thread starter"
890
        );
891
        assert_eq!(
892
            callbacks.as_ref()[0].event,
893
            EventFilter::Component(ComponentEventFilter::AfterMount),
894
            "the thread must start on AfterMount, not on any input event"
895
        );
896
        assert_eq!(
897
            callbacks.as_ref()[0].callback.cb,
898
            screencap_on_after_mount as CallbackType as usize,
899
            "the wired callback must be screencap_on_after_mount"
900
        );
901

            
902
        let merge = dom
903
            .root
904
            .get_merge_callback()
905
            .expect("state must survive relayout");
906
        assert_eq!(
907
            merge.cb as usize,
908
            merge_screencap_state as DatasetMergeCallbackType as usize,
909
            "the merge callback must be merge_screencap_state"
910
        );
911
    }
912

            
913
    #[test]
914
    fn dom_seeds_the_dataset_with_the_config_and_a_not_yet_started_thread() {
915
        for config in ALL_CONFIGS {
916
            let dom = ScreenCaptureWidget::create(config).dom();
917
            let mut dataset = dom
918
                .root
919
                .get_dataset()
920
                .cloned()
921
                .expect("the node must carry its ScreenCaptureWidgetState");
922
            let (stored, started, texture, has_hook) = read_state(&mut dataset);
923

            
924
            assert_eq!(stored, config, "dom() must not rewrite the config");
925
            assert!(!started, "the capture thread only starts on AfterMount");
926
            assert_eq!(texture, None, "no texture exists before the first frame");
927
            assert!(!has_hook, "no hook was set on this widget");
928
        }
929
    }
930

            
931
    #[test]
932
    fn dom_moves_the_on_frame_hook_into_the_dataset() {
933
        let dom = ScreenCaptureWidget::create(DEFAULT_CFG)
934
            .with_on_frame(
935
                frame_log(Update::DoNothing),
936
                record_frame as OnVideoFrameCallbackType,
937
            )
938
            .dom();
939

            
940
        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
941
        let (_, _, _, has_hook) = read_state(&mut dataset);
942
        assert!(has_hook, "dom() must carry the user hook into the state");
943
    }
944

            
945
    #[test]
946
    fn dom_gives_the_after_mount_callback_the_very_same_state_the_node_carries() {
947
        // `dom()` hands the callback a *clone* of the dataset. If that clone did
948
        // not share the payload, AfterMount would flip `started` on a copy and the
949
        // capture thread would be started again on every mount.
950
        let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
951
        let mut node_ds = dom.root.get_dataset().cloned().expect("dataset");
952
        let mut cb_ds = dom.root.get_callbacks().as_ref()[0].refany.clone();
953

            
954
        {
955
            let mut s = cb_ds
956
                .downcast_mut::<ScreenCaptureWidgetState>()
957
                .expect("the callback's payload must be the widget state");
958
            s.started = true;
959
            s.gl_texture_id = Some(1234);
960
        }
961

            
962
        let (_, started, texture, _) = read_state(&mut node_ds);
963
        assert!(
964
            started,
965
            "the callback and the node must share one state, not two copies"
966
        );
967
        assert_eq!(texture, Some(1234));
968
    }
969

            
970
    #[test]
971
    fn two_widgets_built_from_one_config_get_independent_state() {
972
        let a = ScreenCaptureWidget::create(cfg(
973
            ScreenCaptureSource::Display(0),
974
            30,
975
            RawImageFormat::BGRA8,
976
        ))
977
        .dom();
978
        let b = ScreenCaptureWidget::create(cfg(
979
            ScreenCaptureSource::Window(7),
980
            60,
981
            RawImageFormat::RGBA8,
982
        ))
983
        .dom();
984

            
985
        let mut da = a.root.get_dataset().cloned().expect("dataset a");
986
        let mut db = b.root.get_dataset().cloned().expect("dataset b");
987
        {
988
            let mut s = da
989
                .downcast_mut::<ScreenCaptureWidgetState>()
990
                .expect("state a");
991
            s.started = true;
992
        }
993

            
994
        let (config_a, started_a, _, _) = read_state(&mut da);
995
        let (config_b, started_b, _, _) = read_state(&mut db);
996
        assert!(started_a);
997
        assert!(
998
            !started_b,
999
            "two widgets must not share one global capture state"
        );
        assert_eq!(config_a.source, ScreenCaptureSource::Display(0));
        assert_eq!(config_b.source, ScreenCaptureSource::Window(7));
    }
    // ------------------------------------------------------------------
    // screencap_on_after_mount
    //
    // NOTE: the *first* mount (started == false) is deliberately not exercised.
    // It calls `Thread::create`, which spawns a real OS thread running
    // `screencap_worker`; nothing in a unit test drains that thread's channel, so
    // the worker would loop forever pushing ~3.7 MB frames while the `Thread`
    // destructor waits to join it. Only the guard paths below can be driven
    // safely (this mirrors the camera widget's test module).
    // ------------------------------------------------------------------
    #[test]
    fn after_mount_ignores_a_dataset_that_is_not_a_screencap_state() {
        for foreign in [RefAny::new(0_u32), RefAny::new(DEFAULT_CFG)] {
            // The second case is the plausible mistake: handing the *config* POD
            // instead of the widget state.
            let (update, changes) =
                with_callback_info(|info| screencap_on_after_mount(foreign.clone(), info));
            assert_eq!(update, Update::DoNothing);
            assert!(
                changes.is_empty(),
                "a foreign dataset must not start a capture thread: {changes:?}"
            );
        }
    }
    #[test]
    fn after_mount_is_a_no_op_once_the_thread_has_started() {
        let log = frame_log(Update::RefreshDom);
        let mut data = state_with_hook(DEFAULT_CFG, &log);
        {
            let mut s = data
                .downcast_mut::<ScreenCaptureWidgetState>()
                .expect("state");
            s.gl_texture_id = Some(3);
        }
        // Repeated mounts (relayout re-runs AfterMount) must stay inert.
        for _ in 0..3 {
            let (update, changes) =
                with_callback_info(|info| screencap_on_after_mount(data.clone(), info));
            assert_eq!(update, Update::DoNothing);
            assert!(
                changes.is_empty(),
                "AfterMount must start the capture thread at most once: {changes:?}"
            );
        }
        let (config, started, texture, has_hook) = read_state(&mut data);
        assert_eq!(config, DEFAULT_CFG, "a re-mount must not rewrite the config");
        assert!(started);
        assert_eq!(texture, Some(3), "a re-mount must not drop the texture");
        assert!(has_hook, "a re-mount must not drop the user hook");
    }
    // ------------------------------------------------------------------
    // screencap_worker
    // ------------------------------------------------------------------
    #[test]
    fn worker_stops_as_soon_as_the_main_thread_stops_receiving() {
        let Some(sent) = run_worker(RefAny::new(())) else {
            return; // a platform screen backend is registered: not the test pattern
        };
        assert_eq!(
            sent.len(),
            1,
            "the worker must stop after the first rejected send, not spin"
        );
        assert_eq!(
            (sent[0].width, sent[0].height),
            (DEFAULT_W, DEFAULT_H),
            "the test pattern is emitted at the widget's default capture size"
        );
        assert_eq!(
            sent[0].len,
            (DEFAULT_W as usize) * (DEFAULT_H as usize) * 4,
            "the frame must be tightly-packed RGBA8: w * h * 4 bytes"
        );
    }
    #[test]
    fn worker_emits_the_documented_band_pattern_on_its_first_frame() {
        let Some(sent) = run_worker(RefAny::new(())) else {
            return;
        };
        let f = &sent[0];
        assert!(
            f.rows_uniform_opaque,
            "every pixel must be an opaque grey [v, v, v, 255]"
        );
        assert_eq!(
            f.row_values.len(),
            DEFAULT_H as usize,
            "one value per scanline"
        );
        // tick 0 => band == 0, so rows 0..8 are the bright band (|y - 0| < 8).
        assert!(
            f.row_values[..8].iter().all(|&v| v == 235),
            "rows 0..8 are the bright band, got {:?}",
            &f.row_values[..8]
        );
        assert!(
            f.row_values[8..].iter().all(|&v| v == 28),
            "every row below the band is dark grey"
        );
    }
    #[test]
    fn worker_ignores_its_init_payload_entirely() {
        // ADVERSARIAL: the test-pattern worker takes NO input - not the widget's
        // config, not its fps, not its source. A caller cannot influence the
        // frames by handing it a different init, and a garbage init must not
        // panic.
        let Some(unit) = run_worker(RefAny::new(())) else {
            return;
        };
        let Some(text) = run_worker(RefAny::new("not an init struct")) else {
            return;
        };
        let Some(widget_state) = run_worker(state(
            cfg(
                ScreenCaptureSource::Window(u64::MAX),
                u32::MAX,
                RawImageFormat::R8,
            ),
            true,
            Some(u32::MAX),
        )) else {
            return;
        };
        assert_eq!(unit, text, "a foreign init must not change the frames");
        assert_eq!(
            unit, widget_state,
            "even a full widget state (fps = u32::MAX, R8) must not change the \
             test pattern - it is hard-coded"
        );
    }
    // ------------------------------------------------------------------
    // screencap_writeback
    // ------------------------------------------------------------------
    #[test]
    fn writeback_invokes_the_hook_with_the_frame_and_returns_its_update() {
        for reply in [
            Update::DoNothing,
            Update::RefreshDom,
            Update::RefreshDomAllWindows,
        ] {
            let mut log = frame_log(reply);
            let mut data = state_with_hook(DEFAULT_CFG, &log);
            let frame_data = RefAny::new(frame(2, 2));
            let (update, _) = with_callback_info(|info| {
                screencap_writeback(data.clone(), frame_data.clone(), info)
            });
            assert_eq!(update, reply, "the user hook's Update must be returned as-is");
            assert_eq!(logged_frames(&mut log), vec![(2, 2, 16)]);
            let (_, _, texture, _) = read_state(&mut data);
            assert_eq!(
                texture, None,
                "without a GL context no texture id is ever installed"
            );
        }
    }
    #[test]
    fn writeback_ignores_frame_data_of_the_wrong_type() {
        let mut log = frame_log(Update::RefreshDom);
        let mut data = state_with_hook(DEFAULT_CFG, &log);
        let (update, changes) =
            with_callback_info(|info| screencap_writeback(data.clone(), RefAny::new(0_u32), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "no frame -> no image change");
        assert!(
            logged_frames(&mut log).is_empty(),
            "the user hook must not fire without a frame"
        );
    }
    #[test]
    fn writeback_survives_a_writeback_dataset_that_is_not_a_screencap_state() {
        let (update, changes) = with_callback_info(|info| {
            screencap_writeback(RefAny::new(0_u32), RefAny::new(frame(1, 1)), info)
        });
        assert_eq!(
            update,
            Update::DoNothing,
            "a foreign dataset means no hook and no texture - but no panic either"
        );
        assert!(
            changes.is_empty(),
            "no node owns that dataset, so nothing may be installed: {changes:?}"
        );
    }
    #[test]
    fn writeback_keeps_a_preexisting_texture_id_on_the_cpu_path() {
        for current in [Some(0_u32), Some(42), Some(u32::MAX)] {
            let mut data = state(DEFAULT_CFG, true, current);
            let frame_data = RefAny::new(frame(2, 2));
            let (update, _) = with_callback_info(|info| {
                screencap_writeback(data.clone(), frame_data.clone(), info)
            });
            assert_eq!(update, Update::DoNothing, "no hook -> no user update");
            let (_, _, texture, _) = read_state(&mut data);
            assert_eq!(
                texture, current,
                "the stable texture id must survive the writeback unchanged"
            );
        }
    }
    #[test]
    fn writeback_rejects_a_frame_whose_bytes_do_not_match_its_dimensions() {
        // A malformed/hostile backend frame: the image upload must fail cleanly
        // instead of indexing out of bounds or allocating ~17 GB.
        for (w, h, bytes) in [
            (u32::MAX, 1_u32, Vec::new()),
            (4, 4, vec![0_u8; 63]),
            (4, 4, vec![0_u8; 65]),
            (2, 2, Vec::new()),
        ] {
            let mut data = state(DEFAULT_CFG, true, None);
            let bogus = RefAny::new(frame_raw(w, h, bytes.clone()));
            let (update, changes) =
                with_callback_info(|info| screencap_writeback(data.clone(), bogus.clone(), info));
            assert_eq!(update, Update::DoNothing);
            assert!(
                changes.is_empty(),
                "a {w}x{h} frame with {} bytes must not touch the DOM: {changes:?}",
                bytes.len()
            );
            let (_, _, texture, _) = read_state(&mut data);
            assert_eq!(texture, None, "a rejected frame must not invent a texture id");
        }
    }
    #[test]
    fn writeback_hands_even_a_rejected_frame_to_the_user_hook() {
        // FOOTGUN worth pinning: `present_frame` and `invoke_on_frame` are
        // independent. A frame the image pipeline rejects still reaches user code,
        // so `on_frame` is NOT a "this frame was valid" signal.
        let mut log = frame_log(Update::RefreshDom);
        let mut data = state_with_hook(DEFAULT_CFG, &log);
        let bogus = RefAny::new(frame_raw(u32::MAX, 1, Vec::new()));
        let (update, changes) =
            with_callback_info(|info| screencap_writeback(data.clone(), bogus.clone(), info));
        assert_eq!(update, Update::RefreshDom);
        assert!(changes.is_empty(), "the frame itself was rejected");
        assert_eq!(
            logged_frames(&mut log),
            vec![(u32::MAX, 1, 0)],
            "the hook sees the raw frame, dimensions and all, unvalidated"
        );
    }
    #[test]
    fn writeback_accepts_a_zero_sized_frame_without_panicking() {
        // 0 * 0 * 4 == 0 == len(bytes), so a 0x0 frame passes the length check and
        // is installed as a degenerate image. Pin that it stays panic-free and
        // leaves the texture id alone.
        let mut data = state(DEFAULT_CFG, true, Some(2));
        let empty = RefAny::new(frame_raw(0, 0, Vec::new()));
        let (update, _) =
            with_callback_info(|info| screencap_writeback(data.clone(), empty.clone(), info));
        assert_eq!(update, Update::DoNothing);
        let (_, _, texture, _) = read_state(&mut data);
        assert_eq!(texture, Some(2));
    }
    #[test]
    fn writeback_survives_dimensions_whose_byte_count_overflows_usize() {
        // ADVERSARIAL: a backend reporting 2^31 x 2^31 makes the CPU present path
        // compute `width * height * 4` in usize -> 2^64, which overflows. In a
        // debug build that is an arithmetic-overflow panic; in release it wraps to
        // 0 and the empty buffer is *accepted* as a valid 2^31 x 2^31 image.
        // Neither is a graceful rejection (see the autotest report) - what must
        // hold in both modes is that the widget's stored texture id is never
        // corrupted and the process is still usable afterwards.
        let mut data = state(DEFAULT_CFG, true, Some(11));
        let huge = RefAny::new(frame_raw(1_u32 << 31, 1_u32 << 31, Vec::new()));
        let (result, _) = with_callback_info(|info| {
            catch_unwind(AssertUnwindSafe(|| {
                screencap_writeback(data.clone(), huge.clone(), info)
            }))
        });
        match result {
            Ok(update) => {
                assert_eq!(update, Update::DoNothing);
                let (_, _, texture, _) = read_state(&mut data);
                assert_eq!(texture, Some(11), "the texture id must not be corrupted");
            }
            Err(_) => eprintln!(
                "NOTE: screencap_writeback panicked (usize overflow of width*height*4) for a \
                 2^31 x 2^31 frame - a malformed capture backend can take the process down"
            ),
        }
    }
    // ------------------------------------------------------------------
    // merge_screencap_state
    // ------------------------------------------------------------------
    #[test]
    fn merge_takes_the_thread_state_from_old_and_everything_else_from_new() {
        let fresh = cfg(
            ScreenCaptureSource::Window(u64::MAX),
            60,
            RawImageFormat::RGBA8,
        );
        let log = frame_log(Update::DoNothing);
        let new_data = state_with_hook(fresh, &log);
        let old_data = state(
            cfg(ScreenCaptureSource::Display(3), 1, RawImageFormat::R8),
            true,
            Some(9),
        );
        let mut merged = merge_screencap_state(new_data, old_data);
        let (config, started, texture, has_hook) = read_state(&mut merged);
        assert_eq!(config, fresh, "the fresh build's config wins");
        assert!(has_hook, "the fresh build's hook wins");
        assert!(started, "'thread already running' must carry forward");
        assert_eq!(texture, Some(9), "the stable texture id must carry forward");
    }
    #[test]
    fn merge_lets_the_old_thread_state_overwrite_a_fresh_builds_claim() {
        // The old state is authoritative for `started` / `gl_texture_id` in BOTH
        // directions: a fresh build that (wrongly) claims to be running is reset,
        // so the thread is started exactly once per real mount.
        let new_data = RefAny::new(ScreenCaptureWidgetState {
            config: DEFAULT_CFG,
            started: true,
            gl_texture_id: Some(77),
            on_frame: OptionOnVideoFrame::None,
        });
        let old_data = state(DEFAULT_CFG, false, None);
        let mut merged = merge_screencap_state(new_data, old_data);
        let (_, started, texture, _) = read_state(&mut merged);
        assert!(!started, "the old state wins for `started`, in both directions");
        assert_eq!(texture, None, "and for the texture id too");
    }
    #[test]
    fn merge_returns_the_persistent_old_payload_not_a_copy() {
        // PIN FLIPPED (2026-07-31, deliberately): merge used to return the
        // NEW allocation, which orphaned the allocation live capture
        // backends hold a clone of (the frame writeback then wrote into a
        // dataset nobody rendered — the frozen-picture family). The rule is
        // now the map widget's: adopt config forward, return the OLD
        // (persistent) allocation.
        let old_data = state(DEFAULT_CFG, true, Some(5));
        let mut kept = old_data.clone();
        let mut merged = merge_screencap_state(state(DEFAULT_CFG, false, None), old_data);
        {
            let mut s = merged
                .downcast_mut::<ScreenCaptureWidgetState>()
                .expect("merged state");
            s.gl_texture_id = Some(1);
        }
        let (_, started, texture, _) = read_state(&mut kept);
        assert!(
            started,
            "the persistent allocation keeps its worker-facing fields"
        );
        assert_eq!(
            texture,
            Some(1),
            "merge must hand back the OLD allocation — the one live capture \
             backends hold a clone of"
        );
    }
    #[test]
    fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
        let new_data = state(DEFAULT_CFG, false, None);
        let mut merged = merge_screencap_state(new_data, RefAny::new(0_u32));
        let (config, started, texture, _) = read_state(&mut merged);
        assert_eq!(config, DEFAULT_CFG);
        assert!(!started, "nothing to carry forward from a foreign payload");
        assert_eq!(texture, None);
    }
    #[test]
    fn merge_returns_a_foreign_new_dataset_untouched() {
        let old_data = state(DEFAULT_CFG, true, Some(1));
        let mut merged = merge_screencap_state(RefAny::new(77_u32), old_data);
        assert_eq!(
            merged.downcast_ref::<u32>().map(|v| *v),
            Some(77),
            "merge must hand back exactly the payload it was given"
        );
    }
    #[test]
    fn merge_of_a_dataset_with_itself_does_not_panic() {
        // The same RefAny on both sides: the mutable + shared borrow overlap, so
        // the merge is skipped rather than aliasing. Either way the state must
        // survive intact.
        let mut data = state(DEFAULT_CFG, true, Some(5));
        let mut merged = merge_screencap_state(data.clone(), data.clone());
        let (config, started, texture, _) = read_state(&mut merged);
        assert_eq!(config, DEFAULT_CFG);
        assert!(started);
        assert_eq!(texture, Some(5));
        assert_eq!(read_state(&mut data), (DEFAULT_CFG, true, Some(5), false));
    }
    /// REGRESSION (B3): a capture worker must ACKNOWLEDGE `TerminateThread`.
    ///
    /// Reported from azul-meet on macOS after using camera + screenshare — the
    /// framework printed, twice (once per capture worker):
    ///
    /// ```text
    /// [azul][thread] a background thread did not acknowledge TerminateThread
    /// within 2000ms and was DETACHED rather than joined.
    /// ```
    ///
    /// Root cause: the worker took its receiver as `_recv` and never polled it,
    /// so the terminate message was never observed. The only exit was
    /// `sender.send()` returning false, which does NOT happen at shutdown
    /// because the main thread still owns the receiving end while it waits out
    /// the grace period. Fixed by `capture_common::terminate_requested`.
    ///
    /// The budget here is the framework's own:
    /// `THREAD_TERMINATE_GRACE_STEPS (200) * 10ms = 2000ms`.
    #[test]
    fn screencap_worker_acknowledges_terminate_within_the_grace_budget() {
        use crate::thread::{Thread, ThreadCallback};
        let t = Thread::create(
            RefAny::new(0_usize),
            RefAny::new(0_usize),
            ThreadCallback::new(screencap_worker),
        );
        assert!(
            t.send_message(ThreadSendMsg::TerminateThread),
            "the worker holds its receiver alive, so the send must succeed"
        );
        let finished = || {
            t.ptr
                .lock()
                .expect("thread mutex must not be poisoned")
                .is_finished()
        };
        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2_000);
        while !finished() && std::time::Instant::now() < deadline {
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        assert!(
            finished(),
            "screencap_worker did not acknowledge TerminateThread within 2000ms — at shutdown it \
             would be DETACHED rather than joined"
        );
    }
}