tauri_plugin_window_state/
lib.rs1#![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
33pub const DEFAULT_FILENAME: &str = ".window-state.json";
37
38#[derive(Debug, thiserror::Error)]
40pub enum Error {
41 #[error(transparent)]
43 Io(#[from] std::io::Error),
44 #[error(transparent)]
47 Tauri(#[from] tauri::Error),
48 #[error(transparent)]
50 SerdeJson(#[from] serde_json::Error),
51}
52
53pub type Result<T> = std::result::Result<T, Error>;
55
56bitflags! {
57 #[derive(Clone, Copy, Debug)]
61 pub struct StateFlags: u32 {
62 const SIZE = 1 << 0;
64 const POSITION = 1 << 1;
66 const MAXIMIZED = 1 << 2;
68 const VISIBLE = 1 << 3;
70 const DECORATIONS = 1 << 4;
72 const FULLSCREEN = 1 << 5;
74 }
75}
76
77impl Default for StateFlags {
78 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: 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>>>);
125struct RestoringWindowState(Mutex<()>);
127
128pub trait AppHandleExt {
130 fn save_window_state(&self, flags: StateFlags) -> Result<()>;
132 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
170pub trait WindowExt {
172 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 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 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#[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 pub fn new() -> Self {
356 Self::default()
357 }
358
359 pub fn with_state_flags(mut self, flags: StateFlags) -> Self {
361 self.state_flags = flags;
362 self
363 }
364
365 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
367 self.filename.replace(filename.into());
368 self
369 }
370
371 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 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 pub fn skip_initial_state(mut self, label: &str) -> Self {
390 self.skip_initial_state.insert(label.into());
391 self
392 }
393
394 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 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 if self.denylist.contains(label) {
443 return;
444 }
445
446 if let Some(filter_callback) = &self.filter_callback {
448 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 {
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 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}