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