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 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
33pub 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 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: 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>>>);
110struct RestoringWindowState(Mutex<()>);
112
113pub trait AppHandleExt {
114 fn save_window_state(&self, flags: StateFlags) -> Result<()>;
116 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 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 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 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 pub fn with_state_flags(mut self, flags: StateFlags) -> Self {
341 self.state_flags = flags;
342 self
343 }
344
345 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
347 self.filename.replace(filename.into());
348 self
349 }
350
351 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 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 pub fn skip_initial_state(mut self, label: &str) -> Self {
370 self.skip_initial_state.insert(label.into());
371 self
372 }
373
374 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 if self.denylist.contains(label) {
417 return;
418 }
419
420 if let Some(filter_callback) = &self.filter_callback {
422 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 {
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 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}