Skip to main content

dioxus_desktop/
app.rs

1use crate::{
2    config::{Config, WindowCloseBehaviour},
3    edits::EditWebsocket,
4    event_handlers::WindowEventHandlers,
5    ipc::{IpcMessage, UserWindowEvent},
6    query::QueryResult,
7    shortcut::ShortcutRegistry,
8    webview::{PendingWebview, WebviewInstance},
9};
10use dioxus_core::VirtualDom;
11use std::{
12    cell::{Cell, RefCell},
13    collections::HashMap,
14    rc::Rc,
15    time::Duration,
16};
17use tao::{
18    dpi::PhysicalSize,
19    event::Event,
20    event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy, EventLoopWindowTarget},
21    window::WindowId,
22};
23
24/// The single top-level object that manages all the running windows, assets, shortcuts, etc
25pub(crate) struct App {
26    // move the props into a cell so we can pop it out later to create the first window
27    // iOS panics if we create a window before the event loop is started, so we toss them into a cell
28    pub(crate) unmounted_dom: Cell<Option<VirtualDom>>,
29    pub(crate) cfg: Cell<Option<Config>>,
30
31    // Stuff we need mutable access to
32    pub(crate) control_flow: ControlFlow,
33    pub(crate) is_visible_before_start: bool,
34    pub(crate) exit_on_last_window_close: bool,
35    pub(crate) disable_dma_buf_on_wayland: bool,
36    pub(crate) webviews: HashMap<WindowId, WebviewInstance>,
37    pub(crate) float_all: bool,
38    pub(crate) show_devtools: bool,
39    pub(crate) tray_icon_show_window_on_click: bool,
40
41    /// This single blob of state is shared between all the windows so they have access to the runtime state
42    ///
43    /// This includes stuff like the event handlers, shortcuts, etc as well as ways to modify *other* windows
44    pub(crate) shared: Rc<SharedContext>,
45}
46
47/// A bundle of state shared between all the windows, providing a way for us to communicate with running webview.
48pub(crate) struct SharedContext {
49    pub(crate) event_handlers: WindowEventHandlers,
50    pub(crate) pending_webviews: RefCell<Vec<PendingWebview>>,
51    pub(crate) shortcut_manager: ShortcutRegistry,
52    pub(crate) proxy: EventLoopProxy<UserWindowEvent>,
53    pub(crate) target: EventLoopWindowTarget<UserWindowEvent>,
54    pub(crate) websocket: EditWebsocket,
55}
56
57impl App {
58    pub fn new(mut cfg: Config, virtual_dom: VirtualDom) -> (EventLoop<UserWindowEvent>, Self) {
59        let event_loop = cfg
60            .event_loop
61            .take()
62            .unwrap_or_else(|| EventLoopBuilder::<UserWindowEvent>::with_user_event().build());
63
64        let tray_icon_show_window_on_click = cfg.tray_icon_show_window_on_click;
65
66        let app = Self {
67            exit_on_last_window_close: cfg.exit_on_last_window_close,
68            disable_dma_buf_on_wayland: cfg.disable_dma_buf_on_wayland,
69            is_visible_before_start: true,
70            webviews: HashMap::new(),
71            control_flow: ControlFlow::Wait,
72            unmounted_dom: Cell::new(Some(virtual_dom)),
73            float_all: false,
74            show_devtools: false,
75            tray_icon_show_window_on_click,
76            cfg: Cell::new(Some(cfg)),
77            shared: Rc::new(SharedContext {
78                event_handlers: WindowEventHandlers::default(),
79                pending_webviews: Default::default(),
80                shortcut_manager: ShortcutRegistry::new(),
81                proxy: event_loop.create_proxy(),
82                target: event_loop.clone(),
83                websocket: EditWebsocket::start(),
84            }),
85        };
86
87        // Set the event converter
88        dioxus_html::set_event_converter(Box::new(crate::events::SerializedHtmlEventConverter));
89
90        // Wire up the global hotkey handler
91        #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
92        app.set_global_hotkey_handler();
93
94        // Wire up the menubar receiver - this way any component can key into the menubar actions
95        #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
96        app.set_menubar_receiver();
97
98        // Wire up the tray icon receiver - this way any component can key into the menubar actions
99        #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
100        app.set_tray_icon_receiver();
101
102        // Allow hotreloading to work - but only in debug mode
103        #[cfg(all(feature = "devtools", debug_assertions))]
104        app.connect_hotreload();
105
106        #[cfg(debug_assertions)]
107        #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
108        app.connect_preserve_window_state_handler();
109
110        // Make sure to disable DMA buffer rendering on Linux Wayland sessions
111        app.disable_dma_buf();
112
113        (event_loop, app)
114    }
115
116    pub fn tick(&mut self, window_event: &Event<'_, UserWindowEvent>) {
117        self.control_flow = ControlFlow::Wait;
118        self.shared
119            .event_handlers
120            .apply_event(window_event, &self.shared.target);
121    }
122
123    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
124    pub fn handle_global_hotkey(&self, event: global_hotkey::GlobalHotKeyEvent) {
125        self.shared.shortcut_manager.call_handlers(event);
126    }
127
128    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
129    pub fn handle_menu_event(&mut self, event: muda::MenuEvent) {
130        match event.id().0.as_str() {
131            "dioxus-float-top" => {
132                for webview in self.webviews.values() {
133                    webview
134                        .desktop_context
135                        .window
136                        .set_always_on_top(self.float_all);
137                }
138                self.float_all = !self.float_all;
139            }
140            "dioxus-toggle-dev-tools" => {
141                self.show_devtools = !self.show_devtools;
142                for webview in self.webviews.values() {
143                    let wv = &webview.desktop_context.webview;
144                    if self.show_devtools {
145                        wv.open_devtools();
146                    } else {
147                        wv.close_devtools();
148                    }
149                }
150            }
151            _ => (),
152        }
153    }
154    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
155    pub fn handle_tray_menu_event(&mut self, event: tray_icon::menu::MenuEvent) {
156        _ = event;
157    }
158
159    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
160    pub fn handle_tray_icon_event(&mut self, event: tray_icon::TrayIconEvent) {
161        if let tray_icon::TrayIconEvent::Click {
162            id: _,
163            position: _,
164            rect: _,
165            button,
166            button_state: _,
167        } = event
168        {
169            if button == tray_icon::MouseButton::Left && self.tray_icon_show_window_on_click {
170                for webview in self.webviews.values() {
171                    webview.desktop_context.window.set_visible(true);
172                    webview.desktop_context.window.set_focus();
173                }
174            }
175        }
176    }
177
178    #[cfg(all(feature = "devtools", debug_assertions))]
179    pub fn connect_hotreload(&self) {
180        let proxy = self.shared.proxy.clone();
181        dioxus_devtools::connect(move |msg| {
182            _ = proxy.send_event(UserWindowEvent::HotReloadEvent(msg));
183        })
184    }
185
186    pub fn handle_new_window(&mut self) {
187        for pending_webview in self.shared.pending_webviews.borrow_mut().drain(..) {
188            let window = pending_webview.create_window(&self.shared);
189            let id = window.desktop_context.window.id();
190            self.webviews.insert(id, window);
191            _ = self.shared.proxy.send_event(UserWindowEvent::Poll(id));
192        }
193    }
194
195    pub fn handle_close_requested(&mut self, id: WindowId) {
196        let Some(window) = self.webviews.get(&id) else {
197            // If the window is not found, we can just return
198            return;
199        };
200
201        match window.desktop_context.close_behaviour.get() {
202            // If the window is just set to hide when closed, we can just hide it
203            WindowCloseBehaviour::WindowHides => {
204                window.desktop_context.window.set_visible(false);
205            }
206
207            // If the window is set to close, we can remove it from the list of webviews
208            // If the app is set to exit when the last window closes, we should also exit the app
209            WindowCloseBehaviour::WindowCloses => {
210                #[cfg(debug_assertions)]
211                self.persist_window_state();
212
213                self.webviews.remove(&id);
214
215                if self.exit_on_last_window_close && self.webviews.is_empty() {
216                    self.control_flow = ControlFlow::Exit
217                }
218            }
219        };
220    }
221
222    pub fn window_destroyed(&mut self, id: WindowId) {
223        self.webviews.remove(&id);
224
225        if self.exit_on_last_window_close && self.webviews.is_empty() {
226            self.control_flow = ControlFlow::Exit
227        }
228    }
229
230    pub fn resize_window(&self, id: WindowId, size: PhysicalSize<u32>) {
231        // TODO: the app layer should avoid directly manipulating the webview webview instance internals.
232        // Window creation and modification is the responsibility of the webview instance so it makes sense to
233        // encapsulate that there.
234        if let Some(webview) = self.webviews.get(&id) {
235            use wry::Rect;
236
237            _ = webview.desktop_context.webview.set_bounds(Rect {
238                position: wry::dpi::Position::Logical(wry::dpi::LogicalPosition::new(0.0, 0.0)),
239                size: wry::dpi::Size::Physical(wry::dpi::PhysicalSize::new(
240                    size.width,
241                    size.height,
242                )),
243            });
244        }
245    }
246
247    pub fn handle_start_cause_init(&mut self) {
248        let virtual_dom = self
249            .unmounted_dom
250            .take()
251            .expect("Virtualdom should be set before initialization");
252        #[allow(unused_mut)]
253        let mut cfg = self
254            .cfg
255            .take()
256            .expect("Config should be set before initialization");
257
258        self.is_visible_before_start = cfg.window.window.visible;
259        #[cfg(not(target_os = "linux"))]
260        {
261            cfg.window = cfg.window.with_visible(false);
262        }
263        let explicit_window_size = cfg.window.window.inner_size;
264        let explicit_window_position = cfg.window.window.position;
265
266        let webview = WebviewInstance::new(cfg, virtual_dom, self.shared.clone());
267
268        // And then attempt to resume from state
269        self.resume_from_state(&webview, explicit_window_size, explicit_window_position);
270
271        let id = webview.desktop_context.window.id();
272        self.webviews.insert(id, webview);
273    }
274
275    pub fn handle_browser_open(&mut self, msg: IpcMessage) {
276        if let Some(temp) = msg.params().as_object() {
277            if temp.contains_key("href") {
278                if let Some(href) = temp.get("href").and_then(|v| v.as_str()) {
279                    if let Err(err) = webbrowser::open(href) {
280                        tracing::error!("Failed to open URL: {}", err);
281                    }
282                }
283            }
284        }
285    }
286
287    /// The webview is finally loaded
288    ///
289    /// Let's rebuild it and then start polling it
290    pub fn handle_initialize_msg(&mut self, id: WindowId) {
291        let view = self.webviews.get_mut(&id).unwrap();
292
293        view.edits
294            .wry_queue
295            .with_mutation_state_mut(|f| view.dom.rebuild(f));
296
297        view.edits.wry_queue.send_edits();
298
299        #[cfg(not(target_os = "linux"))]
300        {
301            view.desktop_context
302                .window
303                .set_visible(self.is_visible_before_start);
304        }
305
306        _ = self.shared.proxy.send_event(UserWindowEvent::Poll(id));
307    }
308
309    pub fn handle_query_msg(&mut self, msg: IpcMessage, id: WindowId) {
310        let Ok(result) = serde_json::from_value::<QueryResult>(msg.params()) else {
311            return;
312        };
313
314        let Some(view) = self.webviews.get(&id) else {
315            return;
316        };
317
318        view.desktop_context.query.send(result);
319    }
320
321    #[cfg(all(feature = "devtools", debug_assertions))]
322    pub fn handle_hot_reload_msg(&mut self, msg: dioxus_devtools::DevserverMsg) {
323        use std::time::Duration;
324
325        use dioxus_devtools::DevserverMsg;
326
327        // Amount of time that toats should be displayed.
328        const TOAST_TIMEOUT: Duration = Duration::from_secs(2);
329        const TOAST_TIMEOUT_LONG: Duration = Duration::from_secs(3600); // Duration::MAX is too long for JS.
330
331        match msg {
332            DevserverMsg::HotReload(hr_msg) => {
333                for webview in self.webviews.values_mut() {
334                    {
335                        // This is a place where wry says it's threadsafe but it's actually not.
336                        // If we're patching the app, we want to make sure it's not going to progress in the interim.
337                        #[cfg(target_os = "android")]
338                        let _lock = crate::android_sync_lock::android_runtime_lock();
339                        dioxus_devtools::apply_changes(&webview.dom, &hr_msg);
340                    }
341
342                    webview.poll_vdom();
343                }
344
345                if !hr_msg.assets.is_empty() {
346                    for webview in self.webviews.values_mut() {
347                        webview.kick_stylsheets();
348                    }
349                }
350
351                if hr_msg.jump_table.is_some()
352                    && hr_msg.for_build_id == Some(dioxus_cli_config::build_id())
353                {
354                    self.send_toast_to_all(
355                        "Hot-patch success!",
356                        &format!("App successfully patched in {} ms", hr_msg.ms_elapsed),
357                        "success",
358                        TOAST_TIMEOUT,
359                        false,
360                    );
361                }
362            }
363            DevserverMsg::FullReloadCommand => {
364                self.send_toast_to_all(
365                    "Successfully rebuilt.",
366                    "Your app was rebuilt successfully and without error.",
367                    "success",
368                    TOAST_TIMEOUT,
369                    true,
370                );
371            }
372            DevserverMsg::FullReloadStart => self.send_toast_to_all(
373                "Your app is being rebuilt.",
374                "A non-hot-reloadable change occurred and we must rebuild.",
375                "info",
376                TOAST_TIMEOUT_LONG,
377                false,
378            ),
379            DevserverMsg::FullReloadFailed => self.send_toast_to_all(
380                "Oops! The build failed.",
381                "We tried to rebuild your app, but something went wrong.",
382                "error",
383                TOAST_TIMEOUT_LONG,
384                false,
385            ),
386            DevserverMsg::HotPatchStart => self.send_toast_to_all(
387                "Hot-patching app...",
388                "Hot-patching modified Rust code.",
389                "info",
390                TOAST_TIMEOUT_LONG,
391                false,
392            ),
393            DevserverMsg::Shutdown => {
394                self.control_flow = ControlFlow::Exit;
395            }
396            _ => {}
397        }
398    }
399
400    #[cfg(all(feature = "devtools", debug_assertions))]
401    fn send_toast_to_all(
402        &self,
403        header_text: &str,
404        message: &str,
405        level: &str,
406        duration: Duration,
407        after_reload: bool,
408    ) {
409        for webview in self.webviews.values() {
410            webview.show_toast(header_text, message, level, duration, after_reload);
411        }
412    }
413
414    /// Poll the virtualdom until it's pending
415    ///
416    /// The waker we give it is connected to the event loop, so it will wake up the event loop when it's ready to be polled again
417    ///
418    /// All IO is done on the tokio runtime we started earlier
419    pub fn poll_vdom(&mut self, id: WindowId) {
420        let Some(view) = self.webviews.get_mut(&id) else {
421            return;
422        };
423
424        view.poll_vdom();
425    }
426
427    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
428    fn set_global_hotkey_handler(&self) {
429        let receiver = self.shared.proxy.clone();
430
431        // The event loop becomes the hotkey receiver
432        // This means we don't need to poll the receiver on every tick - we just get the events as they come in
433        // This is a bit more efficient than the previous implementation, but if someone else sets a handler, the
434        // receiver will become inert.
435        global_hotkey::GlobalHotKeyEvent::set_event_handler(Some(move |t| {
436            // todo: should we unset the event handler when the app shuts down?
437            _ = receiver.send_event(UserWindowEvent::GlobalHotKeyEvent(t));
438        }));
439    }
440
441    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
442    fn set_menubar_receiver(&self) {
443        let receiver = self.shared.proxy.clone();
444
445        // The event loop becomes the menu receiver
446        // This means we don't need to poll the receiver on every tick - we just get the events as they come in
447        // This is a bit more efficient than the previous implementation, but if someone else sets a handler, the
448        // receiver will become inert.
449        muda::MenuEvent::set_event_handler(Some(move |t| {
450            // todo: should we unset the event handler when the app shuts down?
451            _ = receiver.send_event(UserWindowEvent::MudaMenuEvent(t));
452        }));
453    }
454
455    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
456    fn set_tray_icon_receiver(&self) {
457        let receiver = self.shared.proxy.clone();
458
459        // The event loop becomes the menu receiver
460        // This means we don't need to poll the receiver on every tick - we just get the events as they come in
461        // This is a bit more efficient than the previous implementation, but if someone else sets a handler, the
462        // receiver will become inert.
463        tray_icon::TrayIconEvent::set_event_handler(Some(move |t| {
464            // todo: should we unset the event handler when the app shuts down?
465            _ = receiver.send_event(UserWindowEvent::TrayIconEvent(t));
466        }));
467
468        // for whatever reason they had to make it separate
469        let receiver = self.shared.proxy.clone();
470        tray_icon::menu::MenuEvent::set_event_handler(Some(move |t| {
471            // todo: should we unset the event handler when the app shuts down?
472            _ = receiver.send_event(UserWindowEvent::TrayMenuEvent(t));
473        }));
474    }
475
476    /// Do our best to preserve state about the window when the event loop is destroyed
477    ///
478    /// This will attempt to save the window position, size, and monitor into the environment before
479    /// closing. This way, when the app is restarted, it can attempt to restore the window to the same
480    /// position and size it was in before, making a better DX.
481    pub(crate) fn handle_loop_destroyed(&self) {
482        #[cfg(debug_assertions)]
483        self.persist_window_state();
484    }
485
486    #[cfg(debug_assertions)]
487    fn persist_window_state(&self) {
488        if let Some(webview) = self.webviews.values().next() {
489            let window = &webview.desktop_context.window;
490
491            let Some(monitor) = window.current_monitor() else {
492                return;
493            };
494
495            let Ok(position) = window.outer_position() else {
496                return;
497            };
498            let (x, y) = if cfg!(target_os = "macos") {
499                let position = position.to_logical::<i32>(window.scale_factor());
500                (position.x, position.y)
501            } else {
502                (position.x, position.y)
503            };
504
505            let (width, height) = if cfg!(target_os = "macos") {
506                let size = window.outer_size();
507                let size = size.to_logical::<u32>(window.scale_factor());
508                // This is to work around a bug in how tao handles inner_size on macOS
509                // We *want* to use inner_size, but that's currently broken, so we use outer_size instead and then an adjustment
510                //
511                // https://github.com/tauri-apps/tao/issues/889
512                let adjustment = if window.is_decorated() { 28 } else { 0 };
513                (size.width, size.height.saturating_sub(adjustment))
514            } else {
515                let size = window.inner_size();
516                (size.width, size.height)
517            };
518
519            let Some(monitor_name) = monitor.name() else {
520                return;
521            };
522
523            let state = PreservedWindowState {
524                x,
525                y,
526                width: width.max(200),
527                height: height.max(200),
528                monitor: monitor_name.to_string(),
529            };
530
531            // Yes... I know... we're loading a file that might not be ours... but it's a debug feature
532            if let Ok(state) = serde_json::to_string(&state) {
533                _ = std::fs::write(restore_file(), state);
534            }
535        }
536    }
537
538    // Write this to the target dir so we can pick back up
539    fn resume_from_state(
540        &mut self,
541        webview: &WebviewInstance,
542        explicit_inner_size: Option<tao::dpi::Size>,
543        explicit_window_position: Option<tao::dpi::Position>,
544    ) {
545        // We only want to do this on desktop
546        if cfg!(target_os = "android") || cfg!(target_os = "ios") {
547            return;
548        }
549
550        // We only want to do this in debug mode
551        if !cfg!(debug_assertions) {
552            return;
553        }
554
555        if let Ok(state) = std::fs::read_to_string(restore_file()) {
556            if let Ok(state) = serde_json::from_str::<PreservedWindowState>(&state) {
557                let window = &webview.desktop_context.window;
558                let position = (state.x, state.y);
559                let size = (state.width, state.height);
560
561                // Only set the outer position if it wasn't explicitly set
562                if explicit_window_position.is_none() {
563                    if cfg!(target_os = "macos") {
564                        window.set_outer_position(tao::dpi::LogicalPosition::new(
565                            position.0, position.1,
566                        ));
567                    } else {
568                        window.set_outer_position(tao::dpi::PhysicalPosition::new(
569                            position.0, position.1,
570                        ));
571                    }
572                }
573
574                // Only set the inner size if it wasn't explicitly set
575                if explicit_inner_size.is_none() {
576                    if cfg!(target_os = "macos") {
577                        window.set_inner_size(tao::dpi::LogicalSize::new(size.0, size.1));
578                    } else {
579                        window.set_inner_size(tao::dpi::PhysicalSize::new(size.0, size.1));
580                    }
581                }
582            }
583        }
584    }
585
586    /// Wire up a receiver to sigkill that lets us preserve the window state
587    /// Whenever sigkill is sent, we shut down the app and save the window state
588    #[cfg(debug_assertions)]
589    fn connect_preserve_window_state_handler(&self) {
590        // TODO: make this work on windows
591        #[cfg(unix)]
592        {
593            // Wire up the trap
594            let target = self.shared.proxy.clone();
595            std::thread::spawn(move || {
596                use signal_hook::consts::{SIGINT, SIGTERM};
597                let sigkill = signal_hook::iterator::Signals::new([SIGTERM, SIGINT]);
598                if let Ok(mut sigkill) = sigkill {
599                    for _ in sigkill.forever() {
600                        if target.send_event(UserWindowEvent::Shutdown).is_err() {
601                            std::process::exit(0);
602                        }
603
604                        // give it a moment for the event to be processed
605                        std::thread::sleep(std::time::Duration::from_millis(100));
606                    }
607                }
608            });
609        }
610    }
611
612    /// Disable DMA buffer rendering on Linux Wayland sessions to avoid bugs with WebKitGTK
613    fn disable_dma_buf(&self) {
614        if cfg!(target_os = "linux") && self.disable_dma_buf_on_wayland {
615            static INIT: std::sync::Once = std::sync::Once::new();
616            INIT.call_once(|| {
617                if std::path::Path::new("/dev/dri").exists()
618                    && std::env::var("XDG_SESSION_TYPE").unwrap_or_default() == "wayland"
619                {
620                    // Gnome Webkit is currently buggy under Wayland and KDE, so we will run it with XWayland mode.
621                    // See: https://github.com/DioxusLabs/dioxus/issues/3667
622                    unsafe {
623                        // Disable explicit sync for NVIDIA drivers on Linux when using Way
624                        std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
625                    }
626                }
627                unsafe {
628                    std::env::set_var("GDK_BACKEND", "x11");
629                }
630            });
631        }
632    }
633}
634
635#[derive(Debug, serde::Serialize, serde::Deserialize)]
636struct PreservedWindowState {
637    x: i32,
638    y: i32,
639    width: u32,
640    height: u32,
641    monitor: String,
642}
643
644/// Return the location of a tempfile with our window state in it such that we can restore it later
645fn restore_file() -> std::path::PathBuf {
646    let dir = dioxus_cli_config::session_cache_dir().unwrap_or_else(std::env::temp_dir);
647    dir.join("window-state.json")
648}