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    /// Both payloads in one clipboard write.
441    ///
442    /// `set_html` takes the plain alternative alongside the markup, so this is
443    /// a single operation rather than two that could race: writing text and
444    /// then HTML would leave a window in which a paste sees only the first.
445    ///
446    /// Gated like its two siblings. Without `clipboard` there is no
447    /// `with_clipboard`, and the trait's default copies the plain text.
448    #[cfg(all(
449        feature = "clipboard",
450        any(
451            target_os = "windows",
452            target_os = "macos",
453            target_os = "linux",
454            target_os = "dragonfly",
455            target_os = "freebsd",
456            target_os = "netbsd",
457            target_os = "openbsd"
458        )
459    ))]
460    fn set_clipboard_html(
461        &self,
462        html: String,
463        text: String,
464    ) -> Result<(), blitz_traits::shell::ClipboardError> {
465        with_clipboard(|cb| cb.set_html(html, Some(text)))
466    }
467
468    #[cfg(all(
469        feature = "file-dialog",
470        any(
471            target_os = "windows",
472            target_os = "macos",
473            target_os = "linux",
474            target_os = "dragonfly",
475            target_os = "freebsd",
476            target_os = "netbsd",
477            target_os = "openbsd"
478        )
479    ))]
480    fn open_file_dialog(
481        &self,
482        multiple: bool,
483        filter: Option<FileDialogFilter>,
484    ) -> Vec<std::path::PathBuf> {
485        let mut dialog = rfd::FileDialog::new();
486        if let Some(FileDialogFilter { name, extensions }) = filter {
487            dialog = dialog.add_filter(&name, &extensions);
488        }
489        let files = if multiple {
490            dialog.pick_files()
491        } else {
492            dialog.pick_file().map(|file| vec![file])
493        };
494        files.unwrap_or_default()
495    }
496}
497
498/// What the clipboard has to guarantee, expressed as the three ways it broke.
499///
500/// Copy and paste in the embedding app were intermittent: the same keystroke
501/// worked or did nothing depending on what else held the pasteboard. The cause
502/// was `arboard::Clipboard::new().unwrap()` on every call, which opened a fresh
503/// connection per keystroke, panicked when it lost the race, and on X11 tore
504/// down the selection-owner thread as soon as the call returned — taking the
505/// copied text with it.
506///
507/// A headless test machine usually has no pasteboard at all, so asserting that
508/// a round trip returns the text would only assert that CI has a display. What
509/// is worth pinning is the part that was actually wrong and holds either way:
510/// the connection is opened at most once, and no call can panic.
511#[cfg(all(
512    test,
513    feature = "clipboard",
514    any(
515        target_os = "windows",
516        target_os = "macos",
517        target_os = "linux",
518        target_os = "dragonfly",
519        target_os = "freebsd",
520        target_os = "netbsd",
521        target_os = "openbsd"
522    )
523))]
524mod clipboard_tests {
525    use super::with_clipboard;
526
527    /// The regression that made copy panic rather than fail.
528    ///
529    /// Every clipboard call site in `blitz-dom` discards the result
530    /// (`let _ = shell_provider.set_clipboard_text(..)`), so an unavailable
531    /// clipboard has to surface as `Err`. When it was `.unwrap()`, a machine
532    /// without a pasteboard did not fail the copy, it took the process down.
533    ///
534    /// The unavailable case is constructed rather than waited for. A developer
535    /// machine has a working pasteboard, so a test that merely calls the happy
536    /// path passes just as well against the `.unwrap()` this replaced, and
537    /// proves nothing. Reproducing the shape — the open failed, so there is no
538    /// clipboard to run against — is what pins the behaviour on every machine.
539    #[test]
540    fn an_unavailable_clipboard_is_an_error_and_never_a_panic() {
541        // The `None` arm of the cached cell: exactly what `get_or_init` stores
542        // when `Clipboard::new()` fails, without needing it to fail here.
543        let unavailable: Option<std::sync::Mutex<arboard::Clipboard>> = None;
544        let outcome = unavailable
545            .as_ref()
546            .ok_or(blitz_traits::shell::ClipboardError)
547            .map(|_| unreachable!("there is no clipboard to run against"));
548
549        assert!(
550            outcome.is_err(),
551            "an unopenable clipboard must be reported as an error, not unwrapped",
552        );
553
554        // And the live path must not unwind either, whatever this machine has.
555        let _ = with_clipboard(|cb| cb.set_text("agencyzero".to_owned()));
556        let _ = with_clipboard(|cb| cb.get_text());
557    }
558
559    /// The regression that made copy and paste intermittent.
560    ///
561    /// The connection must be built once and reused, not rebuilt per
562    /// keystroke. `OnceLock::get` stays `None` until the first initialisation,
563    /// and every later call has to observe that same cell.
564    #[test]
565    fn the_connection_is_opened_at_most_once_and_then_reused() {
566        for _ in 0..8 {
567            let _ = with_clipboard(|cb| cb.get_text());
568        }
569
570        // Initialised exactly once by the loop above, whether the open
571        // succeeded (`Some`) or failed (`None`). Either way it is now cached,
572        // so no ninth call can open a second connection.
573        assert!(
574            super::CLIPBOARD.get().is_some(),
575            "the shared clipboard should be initialised after first use",
576        );
577    }
578
579    /// A panic while the lock is held must not disable the clipboard.
580    ///
581    /// The clipboard guards no invariant a panicking caller could have broken,
582    /// so recovering the guard is correct. Propagating the poison instead would
583    /// mean one unlucky copy disabled every copy for the life of the process.
584    ///
585    /// Asserted on a local mutex rather than the shared one. The recovery is
586    /// `unwrap_or_else(|poisoned| poisoned.into_inner())`, and a test that only
587    /// checked "a later call did not crash" would pass against a plain
588    /// `.unwrap()` too on any run where nothing poisoned the lock first. Here
589    /// the lock is definitely poisoned, so the recovery is the only reason the
590    /// value is reachable.
591    #[test]
592    fn a_poisoned_lock_does_not_disable_every_later_copy() {
593        let lock = std::sync::Mutex::new(String::from("still reachable"));
594
595        let poisoned = std::panic::catch_unwind(|| {
596            let _guard = lock.lock().unwrap();
597            panic!("poison the guard while it is held");
598        });
599        assert!(poisoned.is_err(), "the closure above must have panicked");
600        assert!(lock.is_poisoned(), "the lock must now be poisoned");
601
602        // The recovery `with_clipboard` performs. Without it this is an `Err`
603        // and the clipboard would stay dead for the life of the process.
604        let recovered = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
605        assert_eq!(*recovered, "still reachable");
606        drop(recovered);
607
608        /*
609         * And the real path stays callable after a panic passes through it.
610         *
611         * Whether the closure runs at all depends on the machine: a headless CI
612         * runner has no display for `arboard` to open, so `with_clipboard`
613         * returns `ClipboardError` before reaching it and nothing panics. The
614         * assertion this used to make, that `catch_unwind` caught something,
615         * therefore held on a developer desktop and failed on Linux CI.
616         *
617         * What has to be true on every machine is the part that matters: a
618         * panic passing through `with_clipboard` must not leave the clipboard
619         * unusable. So the panic is allowed to be absent, and the call after it
620         * is what is actually being tested, by not unwinding.
621         */
622        let _ = std::panic::catch_unwind(|| {
623            with_clipboard(|_| -> Result<(), arboard::Error> { panic!("poison the shared guard") })
624        });
625        let _ = with_clipboard(|cb| cb.get_text());
626    }
627}