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(
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#[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 #[test]
512 fn an_unavailable_clipboard_is_an_error_and_never_a_panic() {
513 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 let _ = with_clipboard(|cb| cb.set_text("agencyzero".to_owned()));
528 let _ = with_clipboard(|cb| cb.get_text());
529 }
530
531 #[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 assert!(
546 super::CLIPBOARD.get().is_some(),
547 "the shared clipboard should be initialised after first use",
548 );
549 }
550
551 #[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 let recovered = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
577 assert_eq!(*recovered, "still reachable");
578 drop(recovered);
579
580 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}