Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

150 changes: 150 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ pub struct AppConfig {
pub last_active_connection_id: Option<String>,
/// Ids of all connections that were open when the app was last closed.
pub last_open_connection_ids: Option<Vec<String>>,

// ----- Window State -----
/// Window width in pixels when the app was last closed.
pub window_width: Option<u32>,
/// Window height in pixels when the app was last closed.
pub window_height: Option<u32>,
/// Window X position when the app was last closed.
pub window_x: Option<i32>,
/// Window Y position when the app was last closed.
pub window_y: Option<i32>,
/// Whether the window was maximized when the app was last closed.
pub window_maximized: Option<bool>,
}

static CONFIG_CACHE: Lazy<RwLock<AppConfig>> = Lazy::new(|| RwLock::new(AppConfig::default()));
Expand Down Expand Up @@ -458,6 +470,21 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> {
if config.last_open_connection_ids.is_some() {
existing_config.last_open_connection_ids = config.last_open_connection_ids;
}
if config.window_width.is_some() {
existing_config.window_width = config.window_width;
}
if config.window_height.is_some() {
existing_config.window_height = config.window_height;
}
if config.window_x.is_some() {
existing_config.window_x = config.window_x;
}
if config.window_y.is_some() {
existing_config.window_y = config.window_y;
}
if config.window_maximized.is_some() {
existing_config.window_maximized = config.window_maximized;
}

let content = serde_json::to_string_pretty(&existing_config).map_err(|e| e.to_string())?;
fs::write(config_path, content).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -849,6 +876,63 @@ pub fn save_config_json(app: AppHandle, json: String) -> Result<(), String> {
}
}

/// Returns true when running under a Wayland compositor.
pub fn is_wayland() -> bool {
// Wrap in a closure: passing `std::env::var` as a bare fn item fails
// the higher-ranked lifetime bound on the injected lookup.
is_wayland_with_env(|key| std::env::var(key))
}

/// Wayland detection with an injectable env lookup, so tests do not depend
/// on the host session (`WAYLAND_DISPLAY` may or may not be set on dev
/// machines and CI runners).
fn is_wayland_with_env<F>(env_lookup: F) -> bool
where
F: FnOnce(&str) -> Result<String, std::env::VarError>,
{
env_lookup("WAYLAND_DISPLAY").is_ok()
}

/// Save the main window's size and position to config.
///
/// When `maximized` is true, only the maximized flag is persisted — size/position
/// are intentionally skipped because a maximized window's dimensions are the
/// screen size, not the pre-maximize geometry. Restoring from those dimensions
/// would open the window fullscreen on the next launch.
///
/// On Wayland, position is always ignored (the compositor decides placement) and
/// size is skipped because `inner_size()` includes CSD margins, causing the
/// window to inflate by ~50×100 px each session.
pub fn save_window_state(app: &AppHandle, width: u32, height: u32, x: i32, y: i32, maximized: bool) -> Result<(), String> {
if let Some(config_dir) = get_config_dir(app) {
if !config_dir.exists() {
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
}
let config_path = config_dir.join("config.json");
let mut config = load_config_internal(app);
// Only persist size/position when the window is not maximized.
if !maximized {
config.window_maximized = Some(false);
// On Wayland skip position (compositor-controlled) and size
// (GTK includes CSD margins in inner_size, inflating the window).
if !is_wayland() {
config.window_width = Some(width);
config.window_height = Some(height);
config.window_x = Some(x);
config.window_y = Some(y);
}
} else {
config.window_maximized = Some(true);
}
let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
fs::write(config_path, content).map_err(|e| e.to_string())?;
cache_config(&config);
Ok(())
} else {
Err("Could not resolve config directory".to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1104,4 +1188,70 @@ mod tests {
// path is exercised indirectly via parse failures + missing file).
let _ = load_config_from_disk();
}

#[test]
fn window_state_fields_default_to_none() {
let config = AppConfig::default();
assert!(config.window_width.is_none());
assert!(config.window_height.is_none());
assert!(config.window_x.is_none());
assert!(config.window_y.is_none());
assert!(config.window_maximized.is_none());
}

#[test]
fn window_state_fields_serialize_with_camel_case() {
let mut config = AppConfig::default();
config.window_width = Some(1920);
config.window_height = Some(1080);
config.window_x = Some(100);
config.window_y = Some(200);
config.window_maximized = Some(true);

let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("windowWidth"));
assert!(json.contains("windowHeight"));
assert!(json.contains("windowX"));
assert!(json.contains("windowY"));
assert!(json.contains("windowMaximized"));
// snake_case must not appear in JSON
assert!(!json.contains("window_width"));
assert!(!json.contains("window_maximized"));
}

#[test]
fn window_state_fields_round_trip() {
let json = r#"{
"windowWidth": 1920,
"windowHeight": 1080,
"windowX": 100,
"windowY": 200,
"windowMaximized": true
}"#;

let config: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.window_width, Some(1920));
assert_eq!(config.window_height, Some(1080));
assert_eq!(config.window_x, Some(100));
assert_eq!(config.window_y, Some(200));
assert_eq!(config.window_maximized, Some(true));

// Re-serialize and parse again
let serialized = serde_json::to_string(&config).unwrap();
let reparsed: AppConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(reparsed.window_width, Some(1920));
assert_eq!(reparsed.window_height, Some(1080));
assert_eq!(reparsed.window_x, Some(100));
assert_eq!(reparsed.window_y, Some(200));
assert_eq!(reparsed.window_maximized, Some(true));
}

#[test]
fn is_wayland_detects_wayland_display_from_env_lookup() {
// Deterministic regardless of the host session: the env lookup is
// injected, so the test never assumes WAYLAND_DISPLAY is (or isn't)
// set on the machine running it.
assert!(is_wayland_with_env(|_| Ok("wayland-0".to_string())));
assert!(!is_wayland_with_env(|_| Err(std::env::VarError::NotPresent)));
}
}
47 changes: 41 additions & 6 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,16 +325,51 @@ pub fn run() {
// queries instead of waiting for the full approval timeout.
heartbeat::spawn();

// Maximize the window on startup if the user enabled it.
if crate::config::load_config_internal(&app.handle())
.start_maximized
.unwrap_or(false)
{
if let Some(window) = app.get_webview_window("main") {
// Restore window size and position if saved
let config = crate::config::load_config_internal(&app.handle());
if let Some(window) = app.get_webview_window("main") {
// Restore position and size first
if let (Some(x), Some(y), Some(width), Some(height)) = (
config.window_x,
config.window_y,
config.window_width,
config.window_height,
) {
if let Err(e) = window.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y })) {
log::warn!("Failed to restore window position: {e}");
}
if let Err(e) = window.set_size(tauri::Size::Physical(tauri::PhysicalSize { width, height })) {
log::warn!("Failed to restore window size: {e}");
}
}

// Then maximize if needed
if config.window_maximized.unwrap_or(false) || config.start_maximized.unwrap_or(false) {
if let Err(e) = window.maximize() {
log::warn!("Failed to maximize window on startup: {e}");
}
}

// Save window state on close
let close_app = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let window = close_app.get_webview_window("main");
if let Some(window) = window {
let maximized = window.is_maximized().unwrap_or(false);
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
let _ = crate::config::save_window_state(
&close_app,
size.width,
size.height,
pos.x,
pos.y,
maximized,
);
}
}
}
});
}

// Open devtools automatically in debug mode
Expand Down