Skip to main content

webui_desktop/
frame.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use std::path::PathBuf;
5use std::sync::Arc;
6
7#[cfg(feature = "application-ipc")]
8use crate::ipc::{IpcBridge, IpcHost, IpcOptions, IpcWindow, IpcWindowOwner};
9use crate::{
10    DesktopError, DesktopEvent, DesktopRuntime, DesktopShellConfig, EventRegistrationError,
11    EventRegistry, EventResponse, EventSubscription, Result, TitlebarStyle, WindowEffect,
12    WindowHandle, WindowOptions,
13};
14
15/// Cross-platform desktop frame owned by a native shell backend.
16///
17/// A frame owns its event registrations and command channel until the backend
18/// returns. Use the optional native runner or a custom [`DesktopFrameBackend`];
19/// clone individual handles, not the owning frame.
20pub struct DesktopFrame {
21    #[cfg(feature = "native")]
22    pub(crate) executor: Arc<crate::execution::ApplicationExecutor>,
23    /// Stable application identity from the bundle or Rust host.
24    pub(crate) app_id: Option<String>,
25    /// Runtime-neutral WebUI request dispatcher.
26    pub(crate) runtime: Arc<DesktopRuntime>,
27    /// Cross-platform window options.
28    pub(crate) window: WindowOptions,
29    /// Cross-platform native shell options from the desktop manifest.
30    pub(crate) shell: DesktopShellConfig,
31    /// UI-thread lifecycle event registry.
32    pub(crate) events: EventRegistry,
33    /// Sendable command queue for native window control.
34    pub(crate) window_handle: WindowHandle,
35    #[cfg(feature = "application-ipc")]
36    ipc_owner: IpcWindowOwner,
37}
38
39impl DesktopFrame {
40    /// Borrow the immutable application identity.
41    #[must_use]
42    pub fn app_id(&self) -> Option<&str> {
43        self.app_id.as_deref()
44    }
45
46    /// Borrow the request dispatcher owned by this frame.
47    #[must_use]
48    pub fn runtime(&self) -> &Arc<DesktopRuntime> {
49        &self.runtime
50    }
51
52    /// Borrow the immutable window configuration.
53    #[must_use]
54    pub fn window(&self) -> &WindowOptions {
55        &self.window
56    }
57
58    /// Borrow the immutable native shell configuration.
59    #[must_use]
60    pub fn shell(&self) -> &DesktopShellConfig {
61        &self.shell
62    }
63
64    /// Borrow the frame's event registry for a custom backend.
65    #[must_use]
66    pub fn events(&self) -> &EventRegistry {
67        &self.events
68    }
69
70    /// Borrow the sendable window command handle.
71    #[must_use]
72    pub fn window_handle(&self) -> &WindowHandle {
73        &self.window_handle
74    }
75
76    /// Create a desktop frame with default shell options and deny-all IPC permissions.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the IPC schema is invalid or window styling differs
81    /// from the already-rendered runtime.
82    pub fn new(runtime: Arc<DesktopRuntime>, window: WindowOptions) -> Result<Self> {
83        #[cfg(feature = "application-ipc")]
84        {
85            Self::with_ipc_options(runtime, window, IpcOptions::default())
86        }
87        #[cfg(not(feature = "application-ipc"))]
88        {
89            runtime.validate_window(&window)?;
90            Ok(Self::from_parts(runtime, window))
91        }
92    }
93
94    /// Create a frame with explicit application IPC permissions and resource limits.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the IPC schema or limits are invalid, or window
99    /// styling differs from the already-rendered runtime.
100    #[cfg(feature = "application-ipc")]
101    pub fn with_ipc_options(
102        runtime: Arc<DesktopRuntime>,
103        window: WindowOptions,
104        options: IpcOptions,
105    ) -> Result<Self> {
106        runtime.validate_window(&window)?;
107        let origin = if cfg!(target_os = "windows") {
108            "https://app.webui.localhost"
109        } else {
110            "webui://app"
111        }
112        .to_string();
113        let host = if runtime.is_development() {
114            IpcHost::Source { origin }
115        } else {
116            IpcHost::Packaged { origin }
117        };
118        let ipc_owner = IpcWindowOwner::new(runtime.ipc_registry(), options, host)?;
119        Ok(Self::from_parts(runtime, window, ipc_owner))
120    }
121
122    fn from_parts(
123        runtime: Arc<DesktopRuntime>,
124        window: WindowOptions,
125        #[cfg(feature = "application-ipc")] ipc_owner: IpcWindowOwner,
126    ) -> Self {
127        let window_handle = WindowHandle::with_background(runtime.live_background());
128        Self {
129            #[cfg(feature = "native")]
130            executor: Arc::default(),
131            app_id: None,
132            runtime,
133            window,
134            shell: DesktopShellConfig::default(),
135            events: EventRegistry::default(),
136            window_handle,
137            #[cfg(feature = "application-ipc")]
138            ipc_owner,
139        }
140    }
141
142    /// Return a weak handle to this window's document-scoped application IPC.
143    #[must_use]
144    #[cfg(feature = "application-ipc")]
145    pub fn ipc(&self) -> IpcWindow {
146        self.ipc_owner.window()
147    }
148
149    /// Return the native transport facade for a custom backend.
150    ///
151    /// Adapters must supply trusted main-frame navigation and origin information
152    /// and retain the owning frame until callbacks and the native loop finish.
153    #[must_use]
154    #[cfg(feature = "application-ipc")]
155    pub fn ipc_bridge(&self) -> IpcBridge {
156        self.ipc_owner.bridge()
157    }
158
159    /// Register a non-blocking callback for the frame's lifetime.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error when registration is closed or its bounded capacity is full.
164    pub fn on_event<F>(&self, handler: F) -> std::result::Result<(), EventRegistrationError>
165    where
166        F: Fn(&DesktopEvent) -> EventResponse + Send + Sync + 'static,
167    {
168        self.events.on_event(handler)
169    }
170
171    /// Register a callback until the returned subscription is dropped.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error when registration is closed or its bounded capacity is full.
176    pub fn subscribe<F>(
177        &self,
178        handler: F,
179    ) -> std::result::Result<EventSubscription, EventRegistrationError>
180    where
181        F: Fn(&DesktopEvent) -> EventResponse + Send + Sync + 'static,
182    {
183        self.events.subscribe(handler)
184    }
185
186    /// Set the application's stable identity for native services.
187    #[must_use]
188    pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
189        self.app_id = Some(app_id.into());
190        self
191    }
192
193    /// Attach shell options to the frame.
194    #[must_use]
195    pub fn with_shell(mut self, shell: DesktopShellConfig) -> Self {
196        self.shell = shell;
197        self
198    }
199}
200
201impl Drop for DesktopFrame {
202    fn drop(&mut self) {
203        #[cfg(feature = "native")]
204        self.executor.close();
205        // Callback captures may own window handles. Stop commands before
206        // releasing those captures so their destructors cannot enqueue work.
207        #[cfg(feature = "application-ipc")]
208        self.ipc_owner.close();
209        self.window_handle.close();
210        self.events.close();
211    }
212}
213
214/// Cross-platform shell capabilities supported by the active backend.
215#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
216pub struct DesktopFrameCapabilities {
217    /// Backend supports authenticated, document-scoped application IPC.
218    pub application_ipc: bool,
219    /// Backend can install an application menu.
220    pub app_menu: bool,
221    /// Backend supports non-native titlebar styles.
222    pub titlebar_styles: bool,
223    /// Platform window effects this backend actually applies.
224    ///
225    /// A single boolean would claim support for every [`WindowEffect`] variant,
226    /// which is never true: no backend implements all of them. Listing the
227    /// implemented variants lets `validate_frame_capabilities` reject an
228    /// unsupported effect loudly instead of letting it silently no-op.
229    pub supported_effects: &'static [WindowEffect],
230    /// Backend supports tray icons.
231    pub tray: bool,
232    /// Backend supports queued native window controls.
233    pub window_controls: bool,
234    /// Backend emits lifecycle events.
235    pub events: bool,
236}
237
238impl DesktopFrameCapabilities {
239    /// Return whether the backend applies `effect` natively.
240    ///
241    /// [`WindowEffect::None`] is always supported because it requests nothing.
242    #[must_use]
243    pub fn supports_effect(&self, effect: WindowEffect) -> bool {
244        matches!(effect, WindowEffect::None) || self.supported_effects.contains(&effect)
245    }
246}
247
248/// Native desktop frame backend contract.
249///
250/// Platform modules implement this trait so app code can use one API regardless
251/// of target OS. Backend-specific code should stay inside the platform module.
252pub trait DesktopFrameBackend {
253    /// Return the shell capabilities supported by this backend.
254    #[must_use]
255    fn capabilities(&self) -> DesktopFrameCapabilities {
256        DesktopFrameCapabilities::default()
257    }
258
259    /// Run the desktop frame until the native app exits.
260    ///
261    /// Call this through [`run_frame_with`] so capability validation happens
262    /// before ownership is transferred to the backend.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the native shell cannot initialize or exits with a
267    /// platform-specific failure.
268    fn run_frame(&self, frame: DesktopFrame) -> Result<()>;
269}
270
271/// Backend that dispatches to the current target OS implementation.
272#[cfg(feature = "native")]
273#[derive(Clone, Copy, Debug, Default)]
274pub struct PlatformFrameBackend;
275
276#[cfg(feature = "native")]
277impl PlatformFrameBackend {
278    /// Create the current-platform backend dispatcher.
279    #[must_use]
280    pub const fn new() -> Self {
281        Self
282    }
283}
284
285#[cfg(feature = "native")]
286impl DesktopFrameBackend for PlatformFrameBackend {
287    fn capabilities(&self) -> DesktopFrameCapabilities {
288        platform_capabilities()
289    }
290
291    fn run_frame(&self, frame: DesktopFrame) -> Result<()> {
292        validate_frame(&frame, self.capabilities())?;
293        platform_run_frame(frame)
294    }
295}
296
297/// Run a prebuilt desktop runtime in the current platform backend.
298///
299/// # Errors
300///
301/// Returns an error if the current platform shell cannot initialize.
302#[cfg(feature = "native")]
303pub fn run_runtime(runtime: Arc<DesktopRuntime>, window: WindowOptions) -> Result<()> {
304    run_frame(DesktopFrame::new(runtime, window)?)
305}
306
307/// Run a prebuilt desktop frame in the current platform backend.
308///
309/// # Errors
310///
311/// Returns an error if the current platform shell cannot initialize.
312#[cfg(feature = "native")]
313pub fn run_frame(frame: DesktopFrame) -> Result<()> {
314    run_frame_with(frame, &PlatformFrameBackend::new())
315}
316
317/// Validate and run a frame with a caller-supplied backend.
318///
319/// This entry point is available without the `native` feature, allowing custom
320/// hosts and headless tests to use the same capability and ownership contract.
321/// The backend must retain the frame until its event loop and callbacks finish.
322///
323/// # Errors
324///
325/// Returns an error before invoking the backend for unsupported configuration,
326/// or propagates the backend's startup or runtime failure.
327pub fn run_frame_with<B: DesktopFrameBackend + ?Sized>(
328    frame: DesktopFrame,
329    backend: &B,
330) -> Result<()> {
331    validate_frame(&frame, backend.capabilities())?;
332    backend.run_frame(frame)
333}
334
335fn validate_frame(frame: &DesktopFrame, capabilities: DesktopFrameCapabilities) -> Result<()> {
336    frame.runtime.validate_window(&frame.window)?;
337    validate_frame_capabilities(&frame.window, &frame.shell, capabilities)?;
338    #[cfg(feature = "application-ipc")]
339    if frame.runtime.ipc_registry().is_enabled() && !capabilities.application_ipc {
340        return Err(DesktopError::UnsupportedRuntime {
341            message: "the selected backend does not support application IPC".to_string(),
342            help: "use a native WebUI backend or implement the frame-scoped IPC adapter contract"
343                .to_string(),
344        });
345    }
346    Ok(())
347}
348
349/// Validate requested window and shell features before the native shell starts.
350///
351/// # Errors
352///
353/// Returns actionable diagnostics when a requested feature is not supported.
354pub fn validate_frame_capabilities(
355    window: &WindowOptions,
356    shell: &DesktopShellConfig,
357    capabilities: DesktopFrameCapabilities,
358) -> Result<()> {
359    let unsupported =
360        if !matches!(window.titlebar, TitlebarStyle::Native) && !capabilities.titlebar_styles {
361            Some((
362                "titlebar style",
363                "Use the native titlebar or a backend that advertises titlebar_styles",
364            ))
365        } else if !capabilities.supports_effect(window.effect) {
366            Some((
367                "window effect",
368                "Use WindowEffect::None or an effect this backend advertises in supported_effects",
369            ))
370        } else if shell.tray.is_some() && !capabilities.tray {
371            Some((
372                "tray icon",
373                "Remove shell.tray or select a backend that advertises tray",
374            ))
375        } else if !shell.menus.is_empty() && !capabilities.app_menu {
376            Some((
377                "application menu",
378                "Remove shell.menus or select a backend that advertises app_menu",
379            ))
380        } else {
381            None
382        };
383    unsupported.map_or(Ok(()), |(message, help)| {
384        Err(DesktopError::UnsupportedRuntime {
385            message: message.to_string(),
386            help: help.to_string(),
387        })
388    })
389}
390
391/// Return the packaged bundle resource directory when the executable is running
392/// from a desktop package layout.
393#[must_use]
394pub fn find_packaged_resources_dir() -> Option<PathBuf> {
395    let resources = platform_packaged_resources_dir()?;
396    resources
397        .join("manifest.webui-desktop.json")
398        .is_file()
399        .then_some(resources)
400}
401
402#[cfg(all(feature = "native", target_os = "macos"))]
403fn platform_run_frame(frame: DesktopFrame) -> Result<()> {
404    crate::macos::run_frame(frame).map_err(|source| DesktopError::Backend {
405        source: source.into(),
406    })
407}
408
409#[cfg(feature = "local-server")]
410pub(crate) fn platform_run_local_server_frame(frame: crate::LocalServerFrame) -> Result<()> {
411    #[cfg(target_os = "macos")]
412    {
413        crate::macos::run_local_server_frame(frame).map_err(local_backend_error)
414    }
415    #[cfg(target_os = "windows")]
416    {
417        crate::windows::run_local_server_frame(frame).map_err(local_backend_error)
418    }
419    #[cfg(target_os = "linux")]
420    {
421        crate::linux::run_local_server_frame(frame).map_err(local_backend_error)
422    }
423    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
424    {
425        let _ = frame;
426        Err(DesktopError::UnsupportedRuntime {
427            message: "local-server native frames are unavailable on this target".to_string(),
428            help: "Use macOS, Windows or Linux with a native webview runtime".to_string(),
429        })
430    }
431}
432
433#[cfg(all(
434    feature = "local-server",
435    any(target_os = "macos", target_os = "windows", target_os = "linux")
436))]
437fn local_backend_error(source: anyhow::Error) -> DesktopError {
438    match source.downcast::<DesktopError>() {
439        Ok(desktop) => desktop,
440        Err(source) => DesktopError::Backend {
441            source: source.into(),
442        },
443    }
444}
445
446#[cfg(feature = "local-server")]
447pub(crate) fn local_platform_capabilities() -> DesktopFrameCapabilities {
448    #[cfg(any(target_os = "macos", target_os = "windows"))]
449    {
450        platform_capabilities()
451    }
452    #[cfg(target_os = "linux")]
453    {
454        DesktopFrameCapabilities {
455            application_ipc: false,
456            titlebar_styles: false,
457            ..platform_capabilities()
458        }
459    }
460    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
461    {
462        DesktopFrameCapabilities::default()
463    }
464}
465
466#[cfg(all(feature = "native", target_os = "linux"))]
467fn platform_run_frame(frame: DesktopFrame) -> Result<()> {
468    crate::linux::run_frame(frame).map_err(|source| DesktopError::Backend {
469        source: source.into(),
470    })
471}
472
473#[cfg(all(feature = "native", target_os = "windows"))]
474fn platform_run_frame(frame: DesktopFrame) -> Result<()> {
475    crate::windows::run_frame(frame).map_err(|source| DesktopError::Backend {
476        source: source.into(),
477    })
478}
479
480#[cfg(all(
481    feature = "native",
482    not(any(target_os = "macos", target_os = "linux", target_os = "windows"))
483))]
484fn platform_run_frame(_frame: DesktopFrame) -> Result<()> {
485    Err(DesktopError::UnsupportedRuntime {
486        message: "no native backend for this platform".to_string(),
487        help: "Use a supported desktop target or supply a DesktopFrameBackend".to_string(),
488    })
489}
490
491/// Capabilities the macOS AppKit/WKWebView backend implements.
492///
493/// All four effects are implemented: `macos::effects::resolve_effect` maps the
494/// three blur variants onto `NSVisualEffectView` vibrancy and `Tabbed` onto the
495/// native tabbed titlebar treatment.
496#[cfg(all(feature = "native", target_os = "macos"))]
497fn platform_capabilities() -> DesktopFrameCapabilities {
498    DesktopFrameCapabilities {
499        application_ipc: cfg!(feature = "application-ipc"),
500        app_menu: true,
501        titlebar_styles: true,
502        supported_effects: &[
503            WindowEffect::Vibrancy,
504            WindowEffect::Acrylic,
505            WindowEffect::Mica,
506            WindowEffect::Tabbed,
507        ],
508        tray: true,
509        window_controls: true,
510        events: true,
511    }
512}
513
514/// Capabilities the Windows Win32/WebView2 backend implements.
515///
516/// Tray support is deferred. `Acrylic` and `Mica` map onto the DWM system
517/// backdrop types, and `Vibrancy` resolves to acrylic as its closest Windows
518/// equivalent. `Tabbed` is a macOS titlebar treatment with no Windows analogue,
519/// so it is rejected rather than silently ignored.
520#[cfg(all(feature = "native", target_os = "windows"))]
521fn platform_capabilities() -> DesktopFrameCapabilities {
522    DesktopFrameCapabilities {
523        application_ipc: cfg!(feature = "application-ipc"),
524        app_menu: false,
525        titlebar_styles: true,
526        supported_effects: &[
527            WindowEffect::Vibrancy,
528            WindowEffect::Acrylic,
529            WindowEffect::Mica,
530        ],
531        tray: false,
532        window_controls: true,
533        events: true,
534    }
535}
536
537/// Capabilities the Linux GTK4/WebKitGTK backend implements.
538///
539/// `supported_effects` is empty because GTK4 exposes no portable blur or
540/// vibrancy, and tray is false because GTK4 removed `GtkStatusIcon`.
541#[cfg(all(feature = "native", target_os = "linux"))]
542fn platform_capabilities() -> DesktopFrameCapabilities {
543    DesktopFrameCapabilities {
544        application_ipc: cfg!(feature = "application-ipc"),
545        app_menu: false,
546        titlebar_styles: true,
547        supported_effects: &[],
548        tray: false,
549        window_controls: true,
550        events: true,
551    }
552}
553
554#[cfg(all(
555    feature = "native",
556    not(any(target_os = "macos", target_os = "linux", target_os = "windows"))
557))]
558fn platform_capabilities() -> DesktopFrameCapabilities {
559    DesktopFrameCapabilities::default()
560}
561
562#[cfg(target_os = "macos")]
563fn platform_packaged_resources_dir() -> Option<PathBuf> {
564    let exe = std::env::current_exe().ok()?;
565    let contents = exe.parent().and_then(std::path::Path::parent)?;
566    Some(contents.join("Resources").join("webui"))
567}
568
569#[cfg(any(target_os = "windows", target_os = "linux"))]
570fn platform_packaged_resources_dir() -> Option<PathBuf> {
571    let exe = std::env::current_exe().ok()?;
572    Some(exe.parent()?.join("resources").join("webui"))
573}
574
575#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
576fn platform_packaged_resources_dir() -> Option<PathBuf> {
577    None
578}
579
580#[cfg(test)]
581#[allow(clippy::disallowed_methods)]
582pub(crate) mod test_support {
583    use std::collections::HashMap;
584    use std::path::PathBuf;
585    use std::sync::Arc;
586    use tempfile::TempDir;
587    use webui_protocol::{FragmentList, WebUIFragment, WebUIProtocol};
588
589    use crate::{
590        BundleIntegrity, DesktopBundleConfig, DesktopBundleManifest, DesktopRuntime,
591        DesktopShellConfig, WindowOptions,
592    };
593
594    pub(crate) fn bundle() -> (TempDir, DesktopBundleManifest) {
595        let dir = TempDir::new().unwrap();
596        std::fs::create_dir(dir.path().join("assets")).unwrap();
597        // Frame tests must exercise the same bundle path without the source feature.
598        WebUIProtocol::new(HashMap::from([(
599            "index.html".to_string(),
600            FragmentList {
601                fragments: vec![WebUIFragment::raw("<main>Hello</main>")],
602                ..Default::default()
603            },
604        )]))
605        .to_protobuf_file(dir.path().join("protocol.bin"))
606        .unwrap();
607        let manifest = DesktopBundleManifest {
608            manifest_version: DesktopBundleManifest::VERSION,
609            app_id: "com.example.webui.frame-test".to_string(),
610            app_name: "Frame Test".to_string(),
611            version: "0.0.0".to_string(),
612            publisher: "Microsoft".to_string(),
613            entry: "index.html".to_string(),
614            plugin: None,
615            protocol_path: PathBuf::from("protocol.bin"),
616            state_path: None,
617            assets_dir: PathBuf::from("assets"),
618            ipc_schema: None,
619            window: WindowOptions::default(),
620            shell: DesktopShellConfig::default(),
621            package_targets: Vec::new(),
622            integrity: BundleIntegrity::default(),
623        };
624        std::fs::write(
625            dir.path().join("manifest.webui-desktop.json"),
626            serde_json::to_vec(&manifest).unwrap(),
627        )
628        .unwrap();
629        (dir, manifest)
630    }
631
632    pub(crate) fn runtime() -> (TempDir, Arc<DesktopRuntime>) {
633        let (dir, manifest) = bundle();
634        let runtime = DesktopRuntime::from_bundle_config_and_manifest(
635            DesktopBundleConfig::new(dir.path().to_path_buf()),
636            manifest,
637        )
638        .unwrap();
639        (dir, Arc::new(runtime))
640    }
641}
642
643#[cfg(test)]
644#[allow(clippy::disallowed_methods)]
645mod tests {
646    use super::*;
647    use std::sync::atomic::{AtomicUsize, Ordering};
648    use std::sync::Mutex;
649
650    use crate::{WindowCommandError, MAX_EVENT_HANDLERS};
651
652    #[test]
653    fn frame_new_uses_default_shell() {
654        let window = WindowOptions {
655            title: "Frame Test".to_string(),
656            ..WindowOptions::default()
657        };
658
659        let (_bundle, runtime) = test_support::runtime();
660        let frame = DesktopFrame::new(runtime, window).unwrap();
661
662        assert_eq!(frame.window().title, "Frame Test");
663        assert!(frame.shell().icon_path.is_none());
664        assert!(frame.shell().menus.is_empty());
665        assert!(frame.shell().tray.is_none());
666        assert!(frame.app_id().is_none());
667        assert!(!frame.runtime().startup_html().is_empty());
668        let _ = frame.events();
669        let _ = frame.window_handle();
670    }
671
672    #[test]
673    fn frame_rejects_window_css_that_disagrees_with_rendered_document() {
674        let (_bundle, runtime) = test_support::runtime();
675        let window = WindowOptions {
676            titlebar: TitlebarStyle::HiddenInset,
677            ..WindowOptions::default()
678        };
679        assert!(DesktopFrame::new(runtime, window).is_err());
680    }
681
682    #[cfg(feature = "native")]
683    #[test]
684    fn direct_platform_trait_entry_cannot_bypass_capability_validation() {
685        let (_bundle, runtime) = test_support::runtime();
686        let mut frame = DesktopFrame::new(runtime, WindowOptions::default()).unwrap();
687        // Internal mutation simulates an invalid frame; external hosts only have
688        // immutable accessors. Validation must happen before opening any window.
689        frame.window.titlebar = TitlebarStyle::HiddenInset;
690        assert!(PlatformFrameBackend::new().run_frame(frame).is_err());
691    }
692
693    #[test]
694    fn frame_with_shell_replaces_shell() {
695        let shell = DesktopShellConfig {
696            icon_path: Some(PathBuf::from("assets/icon.png")),
697            tray: Some(crate::TrayConfig {
698                icon_path: PathBuf::from("assets/tray.png"),
699                tooltip: None,
700            }),
701            ..DesktopShellConfig::default()
702        };
703
704        let (_bundle, runtime) = test_support::runtime();
705        let frame = DesktopFrame::new(runtime, WindowOptions::default())
706            .unwrap()
707            .with_shell(shell);
708
709        assert_eq!(
710            frame.shell.icon_path.as_deref(),
711            Some(std::path::Path::new("assets/icon.png"))
712        );
713        assert!(frame.shell.tray.is_some());
714    }
715
716    #[cfg(feature = "native")]
717    #[test]
718    fn platform_backend_advertises_only_implemented_capabilities() {
719        let capabilities = PlatformFrameBackend::new().capabilities();
720
721        // Every supported backend implements lifecycle events, non-native
722        // titlebars, and host-driven window controls; a backend that cannot
723        // must report false so `validate_frame_capabilities` rejects the
724        // request instead of letting the feature silently no-op.
725        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
726        {
727            assert_eq!(
728                capabilities.application_ipc,
729                cfg!(feature = "application-ipc")
730            );
731            assert!(capabilities.events);
732            assert!(capabilities.titlebar_styles);
733            assert!(capabilities.window_controls);
734        }
735        // `None` requests nothing, so every backend must accept it.
736        #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
737        assert!(capabilities.supports_effect(WindowEffect::None));
738        // GTK4 exposes no portable blur or vibrancy and removed GtkStatusIcon.
739        #[cfg(target_os = "linux")]
740        {
741            assert!(capabilities.supported_effects.is_empty());
742            assert!(!capabilities.supports_effect(WindowEffect::Mica));
743            assert!(!capabilities.tray);
744        }
745        // Only macOS implements a native application menu and a tray item.
746        #[cfg(target_os = "macos")]
747        {
748            assert!(capabilities.app_menu);
749            assert!(capabilities.tray);
750            // `resolve_effect` covers every non-`None` variant.
751            assert!(capabilities.supports_effect(WindowEffect::Vibrancy));
752            assert!(capabilities.supports_effect(WindowEffect::Acrylic));
753            assert!(capabilities.supports_effect(WindowEffect::Mica));
754            assert!(capabilities.supports_effect(WindowEffect::Tabbed));
755        }
756        #[cfg(target_os = "windows")]
757        {
758            assert!(!capabilities.app_menu);
759            assert!(!capabilities.tray);
760            // DWM backdrops cover the blur variants; `Tabbed` has no analogue.
761            assert!(capabilities.supports_effect(WindowEffect::Vibrancy));
762            assert!(capabilities.supports_effect(WindowEffect::Acrylic));
763            assert!(capabilities.supports_effect(WindowEffect::Mica));
764            assert!(!capabilities.supports_effect(WindowEffect::Tabbed));
765        }
766        // An unsupported platform must advertise nothing.
767        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
768        assert_eq!(capabilities, DesktopFrameCapabilities::default());
769    }
770
771    struct RecordingBackend {
772        calls: AtomicUsize,
773        fail: bool,
774    }
775
776    impl DesktopFrameBackend for RecordingBackend {
777        fn run_frame(&self, frame: DesktopFrame) -> Result<()> {
778            assert_eq!(frame.app_id.as_deref(), Some("com.example.custom"));
779            assert!(frame.runtime.startup_html().contains("<main>Hello</main>"));
780            self.calls.fetch_add(1, Ordering::SeqCst);
781            if self.fail {
782                Err(DesktopError::Backend {
783                    source: std::io::Error::other("fixture backend failure").into(),
784                })
785            } else {
786                Ok(())
787            }
788        }
789    }
790
791    #[test]
792    fn custom_backend_runs_without_native_features_and_closes_the_frame() {
793        let (_bundle, runtime) = test_support::runtime();
794        let frame = DesktopFrame::new(runtime, WindowOptions::default())
795            .unwrap()
796            .with_app_id("com.example.custom");
797        let handle = frame.window_handle.clone();
798        let backend = RecordingBackend {
799            calls: AtomicUsize::new(0),
800            fail: false,
801        };
802
803        run_frame_with(frame, &backend).unwrap();
804
805        assert_eq!(backend.calls.load(Ordering::SeqCst), 1);
806        assert_eq!(handle.request_close(), Err(WindowCommandError::Closed));
807    }
808
809    #[test]
810    fn capability_rejection_skips_backend_and_closes_the_frame() {
811        let (_bundle, runtime) = test_support::runtime();
812        let frame = DesktopFrame::new(
813            runtime,
814            WindowOptions {
815                effect: WindowEffect::Mica,
816                ..WindowOptions::default()
817            },
818        )
819        .unwrap();
820        let handle = frame.window_handle.clone();
821        let backend = RecordingBackend {
822            calls: AtomicUsize::new(0),
823            fail: false,
824        };
825
826        let error = run_frame_with(frame, &backend).unwrap_err();
827
828        assert!(matches!(error, DesktopError::UnsupportedRuntime { .. }));
829        assert!(error.hint().is_some());
830        assert_eq!(backend.calls.load(Ordering::SeqCst), 0);
831        assert_eq!(handle.focus(), Err(WindowCommandError::Closed));
832    }
833
834    #[test]
835    fn backend_failure_is_propagated_and_closes_the_frame() {
836        let (_bundle, runtime) = test_support::runtime();
837        let frame = DesktopFrame::new(runtime, WindowOptions::default())
838            .unwrap()
839            .with_app_id("com.example.custom");
840        let handle = frame.window_handle.clone();
841        let backend = RecordingBackend {
842            calls: AtomicUsize::new(0),
843            fail: true,
844        };
845
846        assert!(matches!(
847            run_frame_with(frame, &backend),
848            Err(DesktopError::Backend { .. })
849        ));
850        assert_eq!(backend.calls.load(Ordering::SeqCst), 1);
851        assert_eq!(handle.request_close(), Err(WindowCommandError::Closed));
852    }
853
854    #[test]
855    fn frame_supports_persistent_and_scoped_event_handlers() {
856        let (_bundle, runtime) = test_support::runtime();
857        let frame = DesktopFrame::new(runtime, WindowOptions::default()).unwrap();
858        let persistent = Arc::new(AtomicUsize::new(0));
859        let scoped = Arc::new(AtomicUsize::new(0));
860        let seen = Arc::clone(&persistent);
861        frame
862            .on_event(move |_| {
863                seen.fetch_add(1, Ordering::SeqCst);
864                EventResponse::Continue
865            })
866            .unwrap();
867        let seen = Arc::clone(&scoped);
868        let subscription = frame
869            .subscribe(move |_| {
870                seen.fetch_add(1, Ordering::SeqCst);
871                EventResponse::Continue
872            })
873            .unwrap();
874
875        let _ = frame.events.dispatch(&DesktopEvent::Ready);
876        drop(subscription);
877        let _ = frame.events.dispatch(&DesktopEvent::Ready);
878
879        assert_eq!(persistent.load(Ordering::SeqCst), 2);
880        assert_eq!(scoped.load(Ordering::SeqCst), 1);
881    }
882
883    #[test]
884    fn frame_propagates_registration_capacity_errors() {
885        let (_bundle, runtime) = test_support::runtime();
886        let frame = DesktopFrame::new(runtime, WindowOptions::default()).unwrap();
887        for _ in 0..MAX_EVENT_HANDLERS {
888            frame.on_event(|_| EventResponse::Continue).unwrap();
889        }
890
891        assert_eq!(
892            frame.on_event(|_| EventResponse::Continue),
893            Err(EventRegistrationError::Capacity)
894        );
895        assert!(matches!(
896            frame.subscribe(|_| EventResponse::Continue),
897            Err(EventRegistrationError::Capacity)
898        ));
899    }
900
901    #[test]
902    fn dropping_frame_releases_cyclic_callbacks_and_closes_retained_handles() {
903        let (_bundle, runtime) = test_support::runtime();
904        let frame = DesktopFrame::new(runtime, WindowOptions::default()).unwrap();
905        let registry = frame.events.clone();
906        let handle = frame.window_handle.clone();
907        let capture = Arc::new(());
908        let observed = Arc::downgrade(&capture);
909        let captured_registry = registry.clone();
910        frame
911            .on_event(move |_| {
912                let _ = (&captured_registry, &capture);
913                EventResponse::Continue
914            })
915            .unwrap();
916        handle.focus().unwrap();
917
918        drop(frame);
919
920        assert!(observed.upgrade().is_none());
921        assert!(handle.drain_commands().is_empty());
922        assert_eq!(handle.request_close(), Err(WindowCommandError::Closed));
923        assert_eq!(
924            registry.on_event(|_| EventResponse::Continue),
925            Err(EventRegistrationError::Closed)
926        );
927        assert!(matches!(
928            registry.subscribe(|_| EventResponse::Continue),
929            Err(EventRegistrationError::Closed)
930        ));
931    }
932
933    #[test]
934    fn frame_closes_commands_before_releasing_callback_captures() {
935        struct SendOnDrop {
936            handle: WindowHandle,
937            result: Arc<Mutex<Option<std::result::Result<(), WindowCommandError>>>>,
938        }
939        impl Drop for SendOnDrop {
940            fn drop(&mut self) {
941                *self.result.lock().unwrap() = Some(self.handle.request_close());
942            }
943        }
944
945        let (_bundle, runtime) = test_support::runtime();
946        let frame = DesktopFrame::new(runtime, WindowOptions::default()).unwrap();
947        let result = Arc::new(Mutex::new(None));
948        let capture = SendOnDrop {
949            handle: frame.window_handle.clone(),
950            result: Arc::clone(&result),
951        };
952        frame
953            .on_event(move |_| {
954                let _ = &capture;
955                EventResponse::Continue
956            })
957            .unwrap();
958
959        drop(frame);
960
961        assert_eq!(
962            *result.lock().unwrap(),
963            Some(Err(WindowCommandError::Closed))
964        );
965    }
966}