dioxus_desktop/
app.rs

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