Skip to main content

tauri_plugin_window_state/
lib.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Save window positions and sizes and restore them when the app is reopened.
6
7#![doc(
8    html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
9    html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
10)]
11#![cfg(not(any(target_os = "android", target_os = "ios")))]
12
13use bitflags::bitflags;
14use serde::{Deserialize, Serialize};
15use tauri::{
16    AppHandle, Manager, Monitor, PhysicalPosition, PhysicalSize, RunEvent, Runtime, WebviewWindow,
17    Window, WindowEvent,
18    plugin::{Builder as PluginBuilder, TauriPlugin},
19};
20
21use std::{
22    collections::{HashMap, HashSet},
23    fs::create_dir_all,
24    io::BufReader,
25    sync::{Arc, Mutex},
26};
27
28mod cmd;
29
30type LabelMapperFn = dyn Fn(&str) -> &str + Send + Sync;
31type FilterCallbackFn = dyn Fn(&str) -> bool + Send + Sync;
32
33/// Default filename used to store window state.
34///
35/// If using a custom filename, you should probably use [`AppHandleExt::filename`] instead.
36pub const DEFAULT_FILENAME: &str = ".window-state.json";
37
38/// The error type returned by this plugin's fallible functions.
39#[derive(Debug, thiserror::Error)]
40pub enum Error {
41    /// An I/O error occurred while reading or writing the window state file.
42    #[error(transparent)]
43    Io(#[from] std::io::Error),
44    /// An error occurred while calling into Tauri, e.g. while resolving the app config directory
45    /// or manipulating a window.
46    #[error(transparent)]
47    Tauri(#[from] tauri::Error),
48    /// An error occurred while serializing or deserializing the window state file.
49    #[error(transparent)]
50    SerdeJson(#[from] serde_json::Error),
51}
52
53/// Alias for a [`Result`](std::result::Result) with the error type [`Error`].
54pub type Result<T> = std::result::Result<T, Error>;
55
56bitflags! {
57    /// Flags controlling which parts of a window's state are saved and restored.
58    ///
59    /// Flags can be combined with the bitwise OR operator (`|`), e.g. `StateFlags::SIZE | StateFlags::POSITION`.
60    #[derive(Clone, Copy, Debug)]
61    pub struct StateFlags: u32 {
62        /// Save and restore the window size.
63        const SIZE        = 1 << 0;
64        /// Save and restore the window position.
65        const POSITION    = 1 << 1;
66        /// Save and restore whether the window is maximized.
67        const MAXIMIZED   = 1 << 2;
68        /// Save and restore whether the window is visible.
69        const VISIBLE     = 1 << 3;
70        /// Save and restore whether the window has decorations.
71        const DECORATIONS = 1 << 4;
72        /// Save and restore whether the window is fullscreen.
73        const FULLSCREEN  = 1 << 5;
74    }
75}
76
77impl Default for StateFlags {
78    /// Default to [`all`](Self::all)
79    fn default() -> Self {
80        Self::all()
81    }
82}
83
84struct PluginState {
85    pub(crate) state_flags: StateFlags,
86    filename: String,
87    map_label: Option<Box<LabelMapperFn>>,
88}
89
90#[derive(Debug, Deserialize, Serialize, PartialEq)]
91struct WindowState {
92    width: u32,
93    height: u32,
94    x: i32,
95    y: i32,
96    // prev_x and prev_y are used to store position
97    // before maximization happened, because maximization
98    // will set x and y to the top-left corner of the monitor
99    prev_x: i32,
100    prev_y: i32,
101    maximized: bool,
102    visible: bool,
103    decorated: bool,
104    fullscreen: bool,
105}
106
107impl Default for WindowState {
108    fn default() -> Self {
109        Self {
110            width: Default::default(),
111            height: Default::default(),
112            x: Default::default(),
113            y: Default::default(),
114            prev_x: Default::default(),
115            prev_y: Default::default(),
116            maximized: Default::default(),
117            visible: true,
118            decorated: true,
119            fullscreen: Default::default(),
120        }
121    }
122}
123
124struct WindowStateCache(Arc<Mutex<HashMap<String, WindowState>>>);
125/// Used to prevent deadlocks from resize and position event listeners setting the cached state on restoring states
126struct RestoringWindowState(Mutex<()>);
127
128/// Extension trait for [`AppHandle`] exposing window state APIs.
129pub trait AppHandleExt {
130    /// Saves all open windows state to disk
131    fn save_window_state(&self, flags: StateFlags) -> Result<()>;
132    /// Get the name of the file used to store window state.
133    fn filename(&self) -> String;
134}
135
136impl<R: Runtime> AppHandleExt for tauri::AppHandle<R> {
137    fn save_window_state(&self, flags: StateFlags) -> Result<()> {
138        let app_dir = self.path().app_config_dir()?;
139        let plugin_state = self.state::<PluginState>();
140        let state_path = app_dir.join(&plugin_state.filename);
141        let windows = self.webview_windows();
142        let cache = self.state::<WindowStateCache>();
143        let mut state = cache.0.lock().unwrap();
144
145        for (label, s) in state.iter_mut() {
146            let window = if let Some(map) = &plugin_state.map_label {
147                windows
148                    .iter()
149                    .find_map(|(l, window)| (map(l) == label).then_some(window))
150            } else {
151                windows.get(label)
152            };
153
154            if let Some(window) = window {
155                window.update_state(s, flags)?;
156            }
157        }
158
159        create_dir_all(app_dir)?;
160        std::fs::write(state_path, serde_json::to_vec_pretty(&*state)?)?;
161
162        Ok(())
163    }
164
165    fn filename(&self) -> String {
166        self.state::<PluginState>().filename.clone()
167    }
168}
169
170/// Extension trait for [`Window`] and [`WebviewWindow`] exposing window state APIs.
171pub trait WindowExt {
172    /// Restores this window state from disk
173    fn restore_state(&self, flags: StateFlags) -> tauri::Result<()>;
174}
175
176impl<R: Runtime> WindowExt for WebviewWindow<R> {
177    fn restore_state(&self, flags: StateFlags) -> tauri::Result<()> {
178        self.as_ref().window().restore_state(flags)
179    }
180}
181
182impl<R: Runtime> WindowExt for Window<R> {
183    fn restore_state(&self, flags: StateFlags) -> tauri::Result<()> {
184        let plugin_state = self.app_handle().state::<PluginState>();
185        let label = plugin_state
186            .map_label
187            .as_ref()
188            .map(|map| map(self.label()))
189            .unwrap_or_else(|| self.label());
190
191        let restoring_window_state = self.state::<RestoringWindowState>();
192        let _restoring_window_lock = restoring_window_state.0.lock().unwrap();
193        let cache = self.state::<WindowStateCache>();
194        let mut c = cache.0.lock().unwrap();
195
196        let mut should_show = true;
197
198        if let Some(state) = c
199            .get(label)
200            .filter(|state| state != &&WindowState::default())
201        {
202            if flags.contains(StateFlags::DECORATIONS) {
203                self.set_decorations(state.decorated)?;
204            }
205
206            if flags.contains(StateFlags::POSITION) {
207                let position = (state.x, state.y).into();
208                let size = (state.width, state.height).into();
209                // restore position to saved value if saved monitor exists
210                // otherwise, let the OS decide where to place the window
211                for m in self.available_monitors()? {
212                    if m.intersects(position, size) {
213                        self.set_position(PhysicalPosition {
214                            x: if state.maximized {
215                                state.prev_x
216                            } else {
217                                state.x
218                            },
219                            y: if state.maximized {
220                                state.prev_y
221                            } else {
222                                state.y
223                            },
224                        })?;
225                    }
226                }
227            }
228
229            if flags.contains(StateFlags::SIZE) {
230                self.set_size(PhysicalSize {
231                    width: state.width,
232                    height: state.height,
233                })?;
234            }
235
236            if flags.contains(StateFlags::MAXIMIZED) && state.maximized {
237                self.maximize()?;
238            }
239
240            if flags.contains(StateFlags::FULLSCREEN) {
241                self.set_fullscreen(state.fullscreen)?;
242            }
243
244            should_show = state.visible;
245        } else {
246            let mut metadata = WindowState::default();
247
248            if flags.contains(StateFlags::SIZE) {
249                let size = self.inner_size()?;
250                metadata.width = size.width;
251                metadata.height = size.height;
252            }
253
254            if flags.contains(StateFlags::POSITION) {
255                let pos = self.outer_position()?;
256                metadata.x = pos.x;
257                metadata.y = pos.y;
258            }
259
260            if flags.contains(StateFlags::MAXIMIZED) {
261                metadata.maximized = self.is_maximized()?;
262            }
263
264            if flags.contains(StateFlags::VISIBLE) {
265                metadata.visible = self.is_visible()?;
266            }
267
268            if flags.contains(StateFlags::DECORATIONS) {
269                metadata.decorated = self.is_decorated()?;
270            }
271
272            if flags.contains(StateFlags::FULLSCREEN) {
273                metadata.fullscreen = self.is_fullscreen()?;
274            }
275
276            c.insert(label.into(), metadata);
277        }
278
279        if flags.contains(StateFlags::VISIBLE) && should_show {
280            self.show()?;
281            self.set_focus()?;
282        }
283
284        Ok(())
285    }
286}
287
288trait WindowExtInternal {
289    fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()>;
290}
291
292impl<R: Runtime> WindowExtInternal for WebviewWindow<R> {
293    fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()> {
294        self.as_ref().window().update_state(state, flags)
295    }
296}
297
298impl<R: Runtime> WindowExtInternal for Window<R> {
299    fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()> {
300        let is_maximized = flags
301            .intersects(StateFlags::MAXIMIZED | StateFlags::POSITION | StateFlags::SIZE)
302            && self.is_maximized()?;
303        let is_minimized =
304            flags.intersects(StateFlags::POSITION | StateFlags::SIZE) && self.is_minimized()?;
305
306        if flags.contains(StateFlags::MAXIMIZED) {
307            state.maximized = is_maximized;
308        }
309
310        if flags.contains(StateFlags::FULLSCREEN) {
311            state.fullscreen = self.is_fullscreen()?;
312        }
313
314        if flags.contains(StateFlags::DECORATIONS) {
315            state.decorated = self.is_decorated()?;
316        }
317
318        if flags.contains(StateFlags::VISIBLE) {
319            state.visible = self.is_visible()?;
320        }
321
322        if flags.contains(StateFlags::SIZE) && !is_maximized && !is_minimized {
323            let size = self.inner_size()?;
324            // It doesn't make sense to save a window with 0 height or width
325            if size.width > 0 && size.height > 0 {
326                state.width = size.width;
327                state.height = size.height;
328            }
329        }
330
331        if flags.contains(StateFlags::POSITION) && !is_maximized && !is_minimized {
332            let position = self.outer_position()?;
333            state.x = position.x;
334            state.y = position.y;
335        }
336
337        Ok(())
338    }
339}
340
341/// Builder for the window-state [plugin](TauriPlugin).
342#[derive(Default)]
343pub struct Builder {
344    denylist: HashSet<String>,
345    filter_callback: Option<Box<FilterCallbackFn>>,
346    skip_initial_state: HashSet<String>,
347    state_flags: StateFlags,
348    map_label: Option<Box<LabelMapperFn>>,
349    filename: Option<String>,
350}
351
352impl Builder {
353    /// Creates a new [`Builder`] with the default configuration:
354    /// all [`StateFlags`] enabled, no denylist, no filter, and the [`DEFAULT_FILENAME`].
355    pub fn new() -> Self {
356        Self::default()
357    }
358
359    /// Sets the state flags to control what state gets restored and saved.
360    pub fn with_state_flags(mut self, flags: StateFlags) -> Self {
361        self.state_flags = flags;
362        self
363    }
364
365    /// Sets a custom filename to use when saving and restoring window states from disk.
366    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
367        self.filename.replace(filename.into());
368        self
369    }
370
371    /// Sets a list of windows that shouldn't be tracked and managed by this plugin
372    /// For example, splash screen windows.
373    pub fn with_denylist(mut self, denylist: &[&str]) -> Self {
374        self.denylist = denylist.iter().map(|l| l.to_string()).collect();
375        self
376    }
377
378    /// Sets a filter callback to exclude specific windows from being tracked.
379    /// Return `true` to save the state, or `false` to skip and not save it.
380    pub fn with_filter<F>(mut self, filter_callback: F) -> Self
381    where
382        F: Fn(&str) -> bool + Send + Sync + 'static,
383    {
384        self.filter_callback = Some(Box::new(filter_callback));
385        self
386    }
387
388    /// Adds the given window label to a list of windows to skip initial state restore.
389    pub fn skip_initial_state(mut self, label: &str) -> Self {
390        self.skip_initial_state.insert(label.into());
391        self
392    }
393
394    /// Transforms the window label when saving the window state.
395    ///
396    /// This can be used to group different windows to use the same state.
397    pub fn map_label<F>(mut self, map_fn: F) -> Self
398    where
399        F: Fn(&str) -> &str + Sync + Send + 'static,
400    {
401        self.map_label = Some(Box::new(map_fn));
402        self
403    }
404
405    /// Builds the [`TauriPlugin`].
406    ///
407    /// The plugin loads the previously saved window state on setup, restores each window's
408    /// state when it becomes ready (unless denylisted, filtered out, or opted out with
409    /// [`Builder::skip_initial_state`]), tracks move/resize events to keep the state up to date,
410    /// and saves the state of all windows to disk when the app exits.
411    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
412        let state_flags = self.state_flags;
413        let filename = self.filename.unwrap_or_else(|| DEFAULT_FILENAME.into());
414        let map_label = self.map_label;
415
416        PluginBuilder::new("window-state")
417            .invoke_handler(tauri::generate_handler![
418                cmd::save_window_state,
419                cmd::restore_state,
420                cmd::filename
421            ])
422            .setup(move |app, _api| {
423                let cache = load_saved_window_states(app, &filename).unwrap_or_default();
424                app.manage(WindowStateCache(Arc::new(Mutex::new(cache))));
425                app.manage(RestoringWindowState(Mutex::new(())));
426                app.manage(PluginState {
427                    state_flags,
428                    filename,
429                    map_label,
430                });
431                Ok(())
432            })
433            .on_window_ready(move |window| {
434                let plugin_state = window.app_handle().state::<PluginState>();
435                let label = plugin_state
436                    .map_label
437                    .as_ref()
438                    .map(|map| map(window.label()))
439                    .unwrap_or_else(|| window.label());
440
441                // Check deny list names
442                if self.denylist.contains(label) {
443                    return;
444                }
445
446                // Check deny list callback
447                if let Some(filter_callback) = &self.filter_callback {
448                    // Don't save the state if the callback returns false
449                    if !filter_callback(label) {
450                        return;
451                    }
452                }
453
454                if !self.skip_initial_state.contains(label) {
455                    let _ = window.restore_state(state_flags);
456                }
457
458                let cache = window.state::<WindowStateCache>();
459                let cache = cache.0.clone();
460                let label = label.to_string();
461                let window_clone = window.clone();
462
463                // insert a default state if this window should be tracked and
464                // the disk cache doesn't have a state for it
465                {
466                    cache
467                        .lock()
468                        .unwrap()
469                        .entry(label.clone())
470                        .or_insert_with(WindowState::default);
471                }
472
473                window.on_window_event(move |e| match e {
474                    WindowEvent::CloseRequested { .. } => {
475                        let mut c = cache.lock().unwrap();
476                        if let Some(state) = c.get_mut(&label) {
477                            let _ = window_clone.update_state(state, state_flags);
478                        }
479                    }
480
481                    WindowEvent::Moved(position)
482                        if state_flags.contains(StateFlags::POSITION)
483                            && window_clone
484                                .state::<RestoringWindowState>()
485                                .0
486                                .try_lock()
487                                .is_ok()
488                            && !window_clone.is_minimized().unwrap_or_default() =>
489                    {
490                        let mut c = cache.lock().unwrap();
491                        if let Some(state) = c.get_mut(&label) {
492                            state.prev_x = state.x;
493                            state.prev_y = state.y;
494
495                            state.x = position.x;
496                            state.y = position.y;
497                        }
498                    }
499                    WindowEvent::Resized(size)
500                        if state_flags.contains(StateFlags::SIZE)
501                            && window_clone
502                                .state::<RestoringWindowState>()
503                                .0
504                                .try_lock()
505                                .is_ok() =>
506                    {
507                        // TODO: Remove once https://github.com/tauri-apps/tauri/issues/5812 is resolved.
508                        let is_maximized = if cfg!(target_os = "macos")
509                            && (!window_clone.is_decorated().unwrap_or_default()
510                                || !window_clone.is_resizable().unwrap_or_default())
511                        {
512                            false
513                        } else {
514                            window_clone.is_maximized().unwrap_or_default()
515                        };
516
517                        if !window_clone.is_minimized().unwrap_or_default() && !is_maximized {
518                            let mut c = cache.lock().unwrap();
519                            if let Some(state) = c.get_mut(&label) {
520                                state.width = size.width;
521                                state.height = size.height;
522                            }
523                        }
524                    }
525                    _ => {}
526                });
527            })
528            .on_event(move |app, event| {
529                if let RunEvent::Exit = event {
530                    let _ = app.save_window_state(state_flags);
531                }
532            })
533            .build()
534    }
535}
536
537fn load_saved_window_states<R: Runtime>(
538    app: &AppHandle<R>,
539    filename: &String,
540) -> Result<HashMap<String, WindowState>> {
541    let app_dir = app.path().app_config_dir()?;
542    let state_path = app_dir.join(filename);
543    let file = std::fs::File::open(state_path)?;
544    let reader = BufReader::new(file);
545    let states = serde_json::from_reader(reader)?;
546    Ok(states)
547}
548
549trait MonitorExt {
550    fn intersects(&self, position: PhysicalPosition<i32>, size: PhysicalSize<u32>) -> bool;
551}
552
553impl MonitorExt for Monitor {
554    fn intersects(&self, position: PhysicalPosition<i32>, size: PhysicalSize<u32>) -> bool {
555        let PhysicalPosition { x, y } = *self.position();
556        let PhysicalSize { width, height } = *self.size();
557
558        let left = x;
559        let right = x + width as i32;
560        let top = y;
561        let bottom = y + height as i32;
562
563        [
564            (position.x, position.y),
565            (position.x + size.width as i32, position.y),
566            (position.x, position.y + size.height as i32),
567            (
568                position.x + size.width as i32,
569                position.y + size.height as i32,
570            ),
571        ]
572        .into_iter()
573        .any(|(x, y)| x >= left && x < right && y >= top && y < bottom)
574    }
575}