1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3mod 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#[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 clear_capture_stores();
48 }
49}
50
51#[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#[cfg(feature = "debug-control")]
77fn clear_capture_stores() {
78 clear_frame_stats();
79 blitz_script::script_stats::clear();
80}
81
82#[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(self.inner.take());
100 if blitz_traits::profiling::deep_profiling_consumers() == 0 {
101 clear_capture_stores();
102 }
103 }
104}
105
106#[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 #[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 #[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
237pub 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")))]
257pub 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")))]
264pub fn current_android_app() -> android_activity::AndroidApp {
267 ANDROID_APP.get().unwrap().clone()
268}
269
270#[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#[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(
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#[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 #[test]
540 fn an_unavailable_clipboard_is_an_error_and_never_a_panic() {
541 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 let _ = with_clipboard(|cb| cb.set_text("agencyzero".to_owned()));
556 let _ = with_clipboard(|cb| cb.get_text());
557 }
558
559 #[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 assert!(
574 super::CLIPBOARD.get().is_some(),
575 "the shared clipboard should be initialised after first use",
576 );
577 }
578
579 #[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 let recovered = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
605 assert_eq!(*recovered, "still reachable");
606 drop(recovered);
607
608 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}