Skip to main content

blitz_shell/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Event loop, windowing and system integration.
4//!
5//! ## Feature flags
6//!  - `default`: Enables the features listed below.
7//!  - `accessibility`: Enables [`accesskit`] accessibility support.
8//!  - `hot-reload`: Enables hot-reloading of Dioxus RSX.
9//!  - `tracing`: Enables tracing support.
10
11mod application;
12mod convert_events;
13mod event;
14pub mod frame_stats;
15mod net;
16mod window;
17
18#[cfg(feature = "accessibility")]
19mod accessibility;
20
21pub use crate::application::BlitzApplication;
22pub use crate::event::{BlitzShellEvent, BlitzShellProxy};
23pub use crate::frame_stats::{
24    FrameStatsSnapshot, FrameTimings, TimingStats, clear_frame_stats, latest_frame_stats,
25};
26
27/// Permit or forbid deep profiling for this process.
28///
29/// This is the owner's toggle and it starts no collection: sampling runs only
30/// while a consumer holds a guard from [`begin_deep_profiling`]. Withdrawing
31/// permission stops a live capture and releases what it collected, because a
32/// capability that is off should retain nothing.
33///
34/// The concrete collectors live in the shell and script crates, so this is the
35/// lowest shared layer that can coordinate them without making `blitz-traits`
36/// depend on its consumers. Reapplying the current state is a no-op, so a
37/// settings refresh cannot split an active capture.
38#[cfg(feature = "debug-control")]
39pub fn set_deep_profiling_permitted(permitted: bool) {
40    if blitz_traits::profiling::deep_profiling_permitted() == permitted {
41        return;
42    }
43
44    blitz_traits::profiling::set_deep_profiling_permitted(permitted);
45    if !permitted {
46        // Forbidden means dormant, and dormant means holding nothing.
47        clear_capture_stores();
48    }
49}
50
51/// Ask for samples, for as long as the returned guard is held.
52///
53/// `None` when the profile does not permit sampling. The first consumer starts
54/// an empty capture window, so no section can enter it carrying a sample from
55/// the last one, and the last guard to drop releases the sample storage rather
56/// than parking it for a consumer that may never return.
57///
58/// The guard is returned rather than exposing `start()`/`stop()` so an early
59/// return or a panic cannot leave the collectors running for the life of the
60/// process.
61#[cfg(feature = "debug-control")]
62#[must_use = "sampling stops as soon as the guard is dropped"]
63pub fn begin_deep_profiling() -> Option<DeepProfilingSession> {
64    let inner = blitz_traits::profiling::begin_deep_profiling()?;
65    if blitz_traits::profiling::deep_profiling_consumers() == 1 {
66        clear_capture_stores();
67    }
68    Some(DeepProfilingSession { inner: Some(inner) })
69}
70
71/// Release both sample stores.
72///
73/// Each `clear` reassigns its log to the default rather than truncating it, so
74/// the backing allocations are dropped rather than retained at their high-water
75/// mark.
76#[cfg(feature = "debug-control")]
77fn clear_capture_stores() {
78    clear_frame_stats();
79    blitz_script::script_stats::clear();
80}
81
82/// Holds a deep-profiling capture open across the shell and script collectors.
83///
84/// Wraps the `blitz-traits` guard so that dropping the *last* one also frees
85/// the samples. The inner guard alone only stops collection, and a stopped
86/// capture that still owns its buffers is the retention this change removes.
87#[cfg(feature = "debug-control")]
88#[derive(Debug)]
89pub struct DeepProfilingSession {
90    inner: Option<blitz_traits::profiling::DeepProfilingGuard>,
91}
92
93#[cfg(feature = "debug-control")]
94impl Drop for DeepProfilingSession {
95    fn drop(&mut self) {
96        // Drop the inner guard first: the count has to reach zero before the
97        // stores are cleared, or a section still in flight could append to the
98        // buffer between the clear and the stop.
99        drop(self.inner.take());
100        if blitz_traits::profiling::deep_profiling_consumers() == 0 {
101            clear_capture_stores();
102        }
103    }
104}
105
106/// One lock for every test that moves the process-wide profiling state.
107///
108/// Permission, the consumer count and both sample stores are global, and they
109/// are exercised from two modules: the lifecycle tests below and the recording
110/// test in `frame_stats`. Without a single lock shared by both, the suite
111/// passes or fails on thread scheduling, which is worse than no test at all.
112#[cfg(test)]
113pub(crate) static PROFILING_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
114
115#[cfg(test)]
116pub(crate) fn exclusive_profiling_state() -> std::sync::MutexGuard<'static, ()> {
117    PROFILING_TEST_LOCK
118        .lock()
119        .unwrap_or_else(|poisoned| poisoned.into_inner())
120}
121
122#[cfg(all(test, feature = "debug-control"))]
123mod profiling_lifecycle_tests {
124    use std::time::Duration;
125
126    use crate::exclusive_profiling_state as exclusive;
127
128    fn record_one_sample_of_each() {
129        crate::frame_stats::record_frame(
130            web_time::Instant::now(),
131            Duration::from_millis(2),
132            Duration::from_millis(3),
133            Duration::from_millis(4),
134        );
135        blitz_script::script_stats::record_poll(Duration::from_millis(5), true);
136    }
137
138    #[test]
139    fn a_new_deep_capture_window_drops_all_previous_collector_samples() {
140        let _serial = exclusive();
141        crate::set_deep_profiling_permitted(true);
142        let first = crate::begin_deep_profiling().expect("permitted");
143        record_one_sample_of_each();
144        assert!(crate::latest_frame_stats().is_some());
145        assert!(blitz_script::script_stats::latest_script_stats().is_some());
146
147        drop(first);
148        let _second = crate::begin_deep_profiling().expect("permitted");
149
150        assert!(crate::latest_frame_stats().is_none());
151        assert!(blitz_script::script_stats::latest_script_stats().is_none());
152        crate::set_deep_profiling_permitted(false);
153    }
154
155    /// The memory half of the design: the last consumer leaving must give the
156    /// samples back, not park them for a reader that may never return.
157    #[test]
158    fn the_last_consumer_leaving_releases_the_samples() {
159        let _serial = exclusive();
160        crate::set_deep_profiling_permitted(true);
161        let session = crate::begin_deep_profiling().expect("permitted");
162        record_one_sample_of_each();
163        assert!(crate::latest_frame_stats().is_some());
164
165        drop(session);
166
167        assert!(
168            crate::latest_frame_stats().is_none(),
169            "dropping the last consumer must release the frame samples",
170        );
171        assert!(
172            blitz_script::script_stats::latest_script_stats().is_none(),
173            "dropping the last consumer must release the script samples",
174        );
175        crate::set_deep_profiling_permitted(false);
176    }
177
178    /// Permission on its own starts no *intrusive* collection, which is the
179    /// whole change: the toggle used to begin sampling at boot for a reader
180    /// that was not there.
181    ///
182    /// Asserted through `deep_profiling_enabled`, not through a reader. The two
183    /// are different questions and conflating them is a trap: the frame ring is
184    /// filled by `record_frame` unconditionally, because it is four durations
185    /// pushed into a bounded buffer and the `[blitz-frame]` log file reads it
186    /// with no consumer to attach. What a consumer gates is the intrusive
187    /// collectors that cost something per section.
188    #[test]
189    fn permission_without_a_consumer_starts_no_intrusive_collection() {
190        let _serial = exclusive();
191        crate::set_deep_profiling_permitted(true);
192
193        assert!(
194            blitz_traits::profiling::deep_profiling_permitted(),
195            "the owner's switch is on",
196        );
197        assert!(
198            !blitz_traits::profiling::deep_profiling_enabled(),
199            "but no consumer is attached, so the intrusive collectors stay off",
200        );
201        assert_eq!(blitz_traits::profiling::deep_profiling_consumers(), 0);
202        crate::set_deep_profiling_permitted(false);
203    }
204}
205pub use crate::window::{View, WindowConfig};
206
207#[cfg(feature = "data-uri")]
208pub use crate::net::DataUriNetProvider;
209
210#[cfg(all(
211    feature = "file-dialog",
212    any(
213        target_os = "windows",
214        target_os = "macos",
215        target_os = "linux",
216        target_os = "dragonfly",
217        target_os = "freebsd",
218        target_os = "netbsd",
219        target_os = "openbsd"
220    )
221))]
222use blitz_traits::shell::FileDialogFilter;
223use blitz_traits::shell::ShellProvider;
224use std::sync::Arc;
225use winit::cursor::{Cursor, CursorIcon};
226use winit::dpi::{LogicalPosition, LogicalSize};
227pub use winit::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
228pub use winit::window::Window;
229use winit::window::{ImeCapabilities, ImeEnableRequest, ImeRequest, ImeRequestData};
230
231#[derive(Default)]
232pub struct Config {
233    pub stylesheets: Vec<String>,
234    pub base_url: Option<String>,
235}
236
237/// Build an event loop for the application
238pub fn create_default_event_loop() -> EventLoop {
239    let mut ev_builder = EventLoop::builder();
240    #[cfg(target_os = "android")]
241    {
242        use winit::platform::android::EventLoopBuilderExtAndroid;
243        ev_builder.with_android_app(current_android_app());
244    }
245
246    let event_loop = ev_builder.build().unwrap();
247    event_loop.set_control_flow(ControlFlow::Wait);
248
249    event_loop
250}
251
252#[cfg(target_os = "android")]
253static ANDROID_APP: std::sync::OnceLock<android_activity::AndroidApp> = std::sync::OnceLock::new();
254
255#[cfg(target_os = "android")]
256#[cfg_attr(docsrs, doc(cfg(target_os = "android")))]
257/// Set the current [`AndroidApp`](android_activity::AndroidApp).
258pub fn set_android_app(app: android_activity::AndroidApp) {
259    ANDROID_APP.set(app).unwrap()
260}
261
262#[cfg(target_os = "android")]
263#[cfg_attr(docsrs, doc(cfg(target_os = "android")))]
264/// Get the current [`AndroidApp`](android_activity::AndroidApp).
265/// This will panic if the android activity has not been setup with [`set_android_app`].
266pub fn current_android_app() -> android_activity::AndroidApp {
267    ANDROID_APP.get().unwrap().clone()
268}
269
270/// The process-wide clipboard connection, opened at most once.
271///
272/// `arboard::Clipboard::new()` is not a cheap accessor. On macOS it takes a
273/// handle on the shared `NSPasteboard`, and on X11 it spawns a thread to serve
274/// selection requests for as long as the value lives. Building one per
275/// keystroke — which is what the copy and paste paths used to do — is wrong on
276/// both platforms and wrong in two separate ways:
277///
278///  - **It fails intermittently.** Opening the pasteboard races every other
279///    process that wants it, so the same keystroke succeeds or fails depending
280///    on what else is running. It was constructed with `.unwrap()`, so a lost
281///    race was not a failed copy but a panic in the shell provider.
282///  - **On macOS the copy did not outlive the call.** Text written through a
283///    `Clipboard` that is dropped at the end of the function can go with it,
284///    which is why a copy could appear to do nothing at all.
285///
286/// One shared instance fixes both: the connection is opened once, reused, and
287/// lives as long as the process. `OnceLock` makes the initialisation itself
288/// race-free, and the `Mutex` inside serialises access because `arboard`
289/// requires `&mut self`. A failure to open is recorded as `None` and reported
290/// to the caller as `ClipboardError` rather than taking the process down.
291#[cfg(all(
292    feature = "clipboard",
293    any(
294        target_os = "windows",
295        target_os = "macos",
296        target_os = "linux",
297        target_os = "dragonfly",
298        target_os = "freebsd",
299        target_os = "netbsd",
300        target_os = "openbsd"
301    )
302))]
303static CLIPBOARD: std::sync::OnceLock<Option<std::sync::Mutex<arboard::Clipboard>>> =
304    std::sync::OnceLock::new();
305
306/// Run `op` against the shared clipboard, or return `ClipboardError`.
307///
308/// Every failure that used to be a panic or a silent drop arrives here as an
309/// `Err`. A poisoned lock is recovered from rather than propagated: the
310/// clipboard holds no invariant that a panicking caller could have corrupted,
311/// and refusing every subsequent copy for the life of the process is a worse
312/// outcome than continuing.
313#[cfg(all(
314    feature = "clipboard",
315    any(
316        target_os = "windows",
317        target_os = "macos",
318        target_os = "linux",
319        target_os = "dragonfly",
320        target_os = "freebsd",
321        target_os = "netbsd",
322        target_os = "openbsd"
323    )
324))]
325fn with_clipboard<T>(
326    op: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
327) -> Result<T, blitz_traits::shell::ClipboardError> {
328    let cell = CLIPBOARD
329        .get_or_init(|| match arboard::Clipboard::new() {
330            Ok(clipboard) => Some(std::sync::Mutex::new(clipboard)),
331            Err(_) => None,
332        })
333        .as_ref()
334        .ok_or(blitz_traits::shell::ClipboardError)?;
335
336    let mut clipboard = cell.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
337    op(&mut clipboard).map_err(|_| blitz_traits::shell::ClipboardError)
338}
339
340pub struct BlitzShellProvider {
341    window: Arc<dyn Window>,
342    proxy: BlitzShellProxy,
343}
344impl BlitzShellProvider {
345    pub fn new(window: Arc<dyn Window>, proxy: BlitzShellProxy) -> Self {
346        Self { window, proxy }
347    }
348}
349
350impl ShellProvider for BlitzShellProvider {
351    fn request_redraw(&self) {
352        self.window.request_redraw();
353    }
354    fn set_cursor(&self, icon: Option<CursorIcon>) {
355        match icon {
356            Some(icon) => {
357                self.window.set_cursor_visible(true);
358                self.window.set_cursor(Cursor::Icon(icon));
359            }
360            None => {
361                self.window.set_cursor(Cursor::Icon(CursorIcon::Default));
362                self.window.set_cursor_visible(false)
363            }
364        }
365    }
366    fn set_window_title(&self, title: String) {
367        self.window.set_title(&title);
368    }
369    fn set_ime_enabled(&self, is_enabled: bool) {
370        if is_enabled {
371            let _ = self.window.request_ime_update(ImeRequest::Enable(
372                ImeEnableRequest::new(ImeCapabilities::new(), ImeRequestData::default()).unwrap(),
373            ));
374        } else {
375            let _ = self.window.request_ime_update(ImeRequest::Disable);
376        }
377    }
378    fn set_ime_cursor_area(&self, x: f32, y: f32, width: f32, height: f32) {
379        let _ = self.window.request_ime_update(ImeRequest::Update(
380            ImeRequestData::default().with_cursor_area(
381                LogicalPosition::new(x, y).into(),
382                LogicalSize::new(width, height).into(),
383            ),
384        ));
385    }
386
387    fn request_window_close(&self) {
388        self.proxy.send_event(BlitzShellEvent::CloseWindow {
389            window_id: self.window.id(),
390        });
391    }
392    fn set_window_minimized(&self, minimized: bool) {
393        self.window.set_minimized(minimized);
394    }
395    fn set_window_maximized(&self, maximized: bool) {
396        self.window.set_maximized(maximized);
397    }
398    fn is_window_maximized(&self) -> bool {
399        self.window.is_maximized()
400    }
401    fn set_window_decorations(&self, decorations: bool) {
402        self.window.set_decorations(decorations);
403    }
404    fn drag_window(&self) {
405        let _ = self.window.drag_window();
406    }
407
408    #[cfg(all(
409        feature = "clipboard",
410        any(
411            target_os = "windows",
412            target_os = "macos",
413            target_os = "linux",
414            target_os = "dragonfly",
415            target_os = "freebsd",
416            target_os = "netbsd",
417            target_os = "openbsd"
418        )
419    ))]
420    fn get_clipboard_text(&self) -> Result<String, blitz_traits::shell::ClipboardError> {
421        with_clipboard(|cb| cb.get_text())
422    }
423
424    #[cfg(all(
425        feature = "clipboard",
426        any(
427            target_os = "windows",
428            target_os = "macos",
429            target_os = "linux",
430            target_os = "dragonfly",
431            target_os = "freebsd",
432            target_os = "netbsd",
433            target_os = "openbsd"
434        )
435    ))]
436    fn set_clipboard_text(&self, text: String) -> Result<(), blitz_traits::shell::ClipboardError> {
437        with_clipboard(|cb| cb.set_text(text))
438    }
439
440    #[cfg(all(
441        feature = "file-dialog",
442        any(
443            target_os = "windows",
444            target_os = "macos",
445            target_os = "linux",
446            target_os = "dragonfly",
447            target_os = "freebsd",
448            target_os = "netbsd",
449            target_os = "openbsd"
450        )
451    ))]
452    fn open_file_dialog(
453        &self,
454        multiple: bool,
455        filter: Option<FileDialogFilter>,
456    ) -> Vec<std::path::PathBuf> {
457        let mut dialog = rfd::FileDialog::new();
458        if let Some(FileDialogFilter { name, extensions }) = filter {
459            dialog = dialog.add_filter(&name, &extensions);
460        }
461        let files = if multiple {
462            dialog.pick_files()
463        } else {
464            dialog.pick_file().map(|file| vec![file])
465        };
466        files.unwrap_or_default()
467    }
468}
469
470/// What the clipboard has to guarantee, expressed as the three ways it broke.
471///
472/// Copy and paste in the embedding app were intermittent: the same keystroke
473/// worked or did nothing depending on what else held the pasteboard. The cause
474/// was `arboard::Clipboard::new().unwrap()` on every call, which opened a fresh
475/// connection per keystroke, panicked when it lost the race, and on X11 tore
476/// down the selection-owner thread as soon as the call returned — taking the
477/// copied text with it.
478///
479/// A headless test machine usually has no pasteboard at all, so asserting that
480/// a round trip returns the text would only assert that CI has a display. What
481/// is worth pinning is the part that was actually wrong and holds either way:
482/// the connection is opened at most once, and no call can panic.
483#[cfg(all(
484    test,
485    feature = "clipboard",
486    any(
487        target_os = "windows",
488        target_os = "macos",
489        target_os = "linux",
490        target_os = "dragonfly",
491        target_os = "freebsd",
492        target_os = "netbsd",
493        target_os = "openbsd"
494    )
495))]
496mod clipboard_tests {
497    use super::with_clipboard;
498
499    /// The regression that made copy panic rather than fail.
500    ///
501    /// Every clipboard call site in `blitz-dom` discards the result
502    /// (`let _ = shell_provider.set_clipboard_text(..)`), so an unavailable
503    /// clipboard has to surface as `Err`. When it was `.unwrap()`, a machine
504    /// without a pasteboard did not fail the copy, it took the process down.
505    ///
506    /// The unavailable case is constructed rather than waited for. A developer
507    /// machine has a working pasteboard, so a test that merely calls the happy
508    /// path passes just as well against the `.unwrap()` this replaced, and
509    /// proves nothing. Reproducing the shape — the open failed, so there is no
510    /// clipboard to run against — is what pins the behaviour on every machine.
511    #[test]
512    fn an_unavailable_clipboard_is_an_error_and_never_a_panic() {
513        // The `None` arm of the cached cell: exactly what `get_or_init` stores
514        // when `Clipboard::new()` fails, without needing it to fail here.
515        let unavailable: Option<std::sync::Mutex<arboard::Clipboard>> = None;
516        let outcome = unavailable
517            .as_ref()
518            .ok_or(blitz_traits::shell::ClipboardError)
519            .map(|_| unreachable!("there is no clipboard to run against"));
520
521        assert!(
522            outcome.is_err(),
523            "an unopenable clipboard must be reported as an error, not unwrapped",
524        );
525
526        // And the live path must not unwind either, whatever this machine has.
527        let _ = with_clipboard(|cb| cb.set_text("agencyzero".to_owned()));
528        let _ = with_clipboard(|cb| cb.get_text());
529    }
530
531    /// The regression that made copy and paste intermittent.
532    ///
533    /// The connection must be built once and reused, not rebuilt per
534    /// keystroke. `OnceLock::get` stays `None` until the first initialisation,
535    /// and every later call has to observe that same cell.
536    #[test]
537    fn the_connection_is_opened_at_most_once_and_then_reused() {
538        for _ in 0..8 {
539            let _ = with_clipboard(|cb| cb.get_text());
540        }
541
542        // Initialised exactly once by the loop above, whether the open
543        // succeeded (`Some`) or failed (`None`). Either way it is now cached,
544        // so no ninth call can open a second connection.
545        assert!(
546            super::CLIPBOARD.get().is_some(),
547            "the shared clipboard should be initialised after first use",
548        );
549    }
550
551    /// A panic while the lock is held must not disable the clipboard.
552    ///
553    /// The clipboard guards no invariant a panicking caller could have broken,
554    /// so recovering the guard is correct. Propagating the poison instead would
555    /// mean one unlucky copy disabled every copy for the life of the process.
556    ///
557    /// Asserted on a local mutex rather than the shared one. The recovery is
558    /// `unwrap_or_else(|poisoned| poisoned.into_inner())`, and a test that only
559    /// checked "a later call did not crash" would pass against a plain
560    /// `.unwrap()` too on any run where nothing poisoned the lock first. Here
561    /// the lock is definitely poisoned, so the recovery is the only reason the
562    /// value is reachable.
563    #[test]
564    fn a_poisoned_lock_does_not_disable_every_later_copy() {
565        let lock = std::sync::Mutex::new(String::from("still reachable"));
566
567        let poisoned = std::panic::catch_unwind(|| {
568            let _guard = lock.lock().unwrap();
569            panic!("poison the guard while it is held");
570        });
571        assert!(poisoned.is_err(), "the closure above must have panicked");
572        assert!(lock.is_poisoned(), "the lock must now be poisoned");
573
574        // The recovery `with_clipboard` performs. Without it this is an `Err`
575        // and the clipboard would stay dead for the life of the process.
576        let recovered = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
577        assert_eq!(*recovered, "still reachable");
578        drop(recovered);
579
580        /*
581         * And the real path stays callable after a panic passes through it.
582         *
583         * Whether the closure runs at all depends on the machine: a headless CI
584         * runner has no display for `arboard` to open, so `with_clipboard`
585         * returns `ClipboardError` before reaching it and nothing panics. The
586         * assertion this used to make, that `catch_unwind` caught something,
587         * therefore held on a developer desktop and failed on Linux CI.
588         *
589         * What has to be true on every machine is the part that matters: a
590         * panic passing through `with_clipboard` must not leave the clipboard
591         * unusable. So the panic is allowed to be absent, and the call after it
592         * is what is actually being tested, by not unwinding.
593         */
594        let _ = std::panic::catch_unwind(|| {
595            with_clipboard(|_| -> Result<(), arboard::Error> { panic!("poison the shared guard") })
596        });
597        let _ = with_clipboard(|cb| cb.get_text());
598    }
599}