git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit0c8b93141673f9691a738b6e3ea8356bd9cc93b8
parentf5f50c68d4
authorLucas Galante <[email protected]>
date2026-06-23 14:50
Implement window layout and session serialization, restoration, and auto-spawning

 src/server/config.rs         |  10 +++
 src/server/run_server.rs     |  14 +++++
 src/server/tiling.rs         |   2 +-
 src/server/window.rs         |  16 +++++
 src/server/window_manager.rs | 145 ++++++++++++++++++++++++++++++++++++++++++-
 5 files changed, 185 insertions(+), 2 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 4212857..3565fed 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -599,6 +599,16 @@ pub fn default_config_path() -> Option<String> {
     }
 }
 
+pub fn default_state_path() -> Option<String> {
+    if let Ok(xdg_state_home) = std::env::var("XDG_STATE_HOME") {
+        Some(format!("{}/cce/state.json", xdg_state_home))
+    } else if let Ok(home) = std::env::var("HOME") {
+        Some(format!("{}/.local/state/cce/state.json", home))
+    } else {
+        None
+    }
+}
+
 pub fn extract_program_name(cmd: &str) -> String {
     let trimmed = cmd.trim();
     if trimmed.is_empty() {
diff --git a/src/server/run_server.rs b/src/server/run_server.rs
index e2f9cff..e2a7992 100644
--- a/src/server/run_server.rs
+++ b/src/server/run_server.rs
@@ -154,6 +154,12 @@ pub fn run_server() {
         log::warn!("no config file found, using defaults");
     }
 
+    if let Some(state_path) = crate::config::default_state_path() {
+        unsafe {
+            server.wm.load_state(&state_path);
+        }
+    }
+
     process::setup();
 
     let socket_ptr = unsafe {
@@ -190,6 +196,10 @@ pub fn run_server() {
         }
     }
 
+    unsafe {
+        server.wm.spawn_restored_windows();
+    }
+
     struct ServerGuard {
         init_pid: Option<nix::unistd::Pid>,
         wm: *mut crate::window_manager::WindowManager,
@@ -271,6 +281,10 @@ pub fn run_server() {
     }
 
     log::info!("shutting down server");
+    unsafe {
+        server.wm.save_state();
+    }
+    server.wm.shutting_down = true;
     std::mem::drop(_guard);
     server.deinit();
 }
diff --git a/src/server/tiling.rs b/src/server/tiling.rs
index 828b24a..59d927a 100644
--- a/src/server/tiling.rs
+++ b/src/server/tiling.rs
@@ -1,6 +1,6 @@
 // Tiling formulas ported from cce-client
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
 pub enum TilingMode {
     Floating,
     Cascade,
diff --git a/src/server/window.rs b/src/server/window.rs
index c67a79e..fff9dce 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -620,6 +620,22 @@ impl Window {
         assert_eq!(self.state, WindowState::Initialized);
         self.state = WindowState::Mapped;
 
+        let app_id_str = self.get_app_id_string().unwrap_or_default();
+        let title_str = self.get_title_string().unwrap_or_default();
+        if app_id_str != "cce-status-interface" {
+            if let Some(saved) = (*self.server).wm.match_and_remove_restore_state(&app_id_str, &title_str) {
+                log::info!("Restoring saved state for window: app_id={}, title={}. Position: ({}, {}), Size: {}x{}", app_id_str, title_str, saved.virtual_x, saved.virtual_y, saved.width, saved.height);
+                self.tiling_mode = saved.tiling_mode;
+                self.tags = saved.tags;
+                self.minimized = saved.minimized;
+                self.virtual_x = saved.virtual_x;
+                self.virtual_y = saved.virtual_y;
+                self.scale = saved.scale;
+                self.box_geom.width = saved.width as i32;
+                self.box_geom.height = saved.height as i32;
+            }
+        }
+
         let surface = self.root_surface();
         if !surface.is_null() {
             let commit_listener = &mut self.commit as *mut ffi::wl_listener as *mut WlListener;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index a5d242a..3b71b16 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -41,6 +41,30 @@ pub struct WindowManagerRenderingRequested {
     pub order_hash: u64,
 }
 
+#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
+pub struct SavedWindowState {
+    pub app_id: String,
+    pub title: String,
+    pub tiling_mode: crate::tiling::TilingMode,
+    pub tags: u32,
+    pub minimized: bool,
+    pub virtual_x: f64,
+    pub virtual_y: f64,
+    pub scale: f64,
+    pub width: u32,
+    pub height: u32,
+    pub cmdline: String,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
+pub struct SavedState {
+    pub desk_pan_x: f64,
+    pub desk_pan_y: f64,
+    pub desk_zoom: f64,
+    pub global_layout: crate::tiling::TilingMode,
+    pub windows: Vec<SavedWindowState>,
+}
+
 pub struct WindowManager {
     pub server: *mut Server,
     pub global: *mut ffi::wl_global,
@@ -75,6 +99,8 @@ pub struct WindowManager {
     pub expose_hovered_window: *mut Window,
     pub expose_initial_focus: *mut Window,
     pub last_status_update: std::cell::RefCell<Option<crate::status_server::StatusUpdate>>,
+    pub restore_queue: Vec<SavedWindowState>,
+    pub shutting_down: bool,
 }
 
 impl WindowManager {
@@ -119,6 +145,8 @@ impl WindowManager {
         self.desk_pan_y = 0.0;
         self.desk_zoom = 1.0;
         self.global_layout = crate::tiling::TilingMode::Cascade;
+        self.restore_queue = Vec::new();
+        self.shutting_down = false;
         self.layout = crate::config::Layout::default();
         self.output_scale = 1.0;
         self.mode_rules = Vec::new();
@@ -173,6 +201,120 @@ impl WindowManager {
         Ok(())
     }
 
+    pub unsafe fn load_state(&mut self, path: &str) {
+        log::info!("Loading state from {}", path);
+        if let Ok(content) = std::fs::read_to_string(path) {
+            if let Ok(state) = serde_json::from_str::<SavedState>(&content) {
+                self.desk_pan_x = state.desk_pan_x;
+                self.desk_pan_y = state.desk_pan_y;
+                self.desk_zoom = state.desk_zoom;
+                self.global_layout = state.global_layout;
+                self.restore_queue = state.windows;
+                log::info!("State loaded successfully. {} windows in restore queue.", self.restore_queue.len());
+            } else {
+                log::error!("Failed to parse state JSON from {}", path);
+            }
+        } else {
+            log::info!("State file not found or unreadable at {}, starting with empty state.", path);
+        }
+    }
+
+    pub unsafe fn save_state(&self) {
+        if self.shutting_down {
+            return;
+        }
+        let Some(path_str) = crate::config::default_state_path() else {
+            log::error!("Could not resolve state file path");
+            return;
+        };
+        log::debug!("Saving state to {}", path_str);
+        
+        let mut saved_wins = Vec::new();
+        for &w in self.windows.iter() {
+            if w.is_null() || (*w).closed || matches!((*w).state, crate::window::WindowState::Closing | crate::window::WindowState::Init) {
+                continue;
+            }
+            if (*w).is_status_bar() {
+                continue;
+            }
+            
+            let app_id = (*w).get_app_id_string().unwrap_or_default();
+            let title = (*w).get_title_string().unwrap_or_default();
+            
+            let pid = (*w).unreliable_pid();
+            let mut cmdline = if pid > 0 {
+                std::fs::read_to_string(format!("/proc/{}/cmdline", pid))
+                    .unwrap_or_default()
+                    .replace('\0', " ")
+                    .trim()
+                    .to_string()
+            } else {
+                String::new()
+            };
+            if cmdline.is_empty() {
+                cmdline = app_id.clone();
+            }
+
+            saved_wins.push(SavedWindowState {
+                app_id,
+                title,
+                tiling_mode: (*w).tiling_mode,
+                tags: (*w).tags,
+                minimized: (*w).minimized,
+                virtual_x: (*w).virtual_x,
+                virtual_y: (*w).virtual_y,
+                scale: (*w).scale,
+                width: (*w).box_geom.width as u32,
+                height: (*w).box_geom.height as u32,
+                cmdline,
+            });
+        }
+        
+        let state = SavedState {
+            desk_pan_x: self.desk_pan_x,
+            desk_pan_y: self.desk_pan_y,
+            desk_zoom: self.desk_zoom,
+            global_layout: self.global_layout,
+            windows: saved_wins,
+        };
+        
+        if let Ok(json_str) = serde_json::to_string_pretty(&state) {
+            let path = std::path::Path::new(&path_str);
+            if let Some(parent) = path.parent() {
+                let _ = std::fs::create_dir_all(parent);
+            }
+            if let Err(e) = std::fs::write(path, json_str) {
+                log::error!("Failed to write state file: {}", e);
+            }
+        }
+    }
+
+    pub unsafe fn match_and_remove_restore_state(&mut self, app_id: &str, title: &str) -> Option<SavedWindowState> {
+        if app_id.is_empty() {
+            return None;
+        }
+        // First pass: Exact match (app_id AND title)
+        if let Some(pos) = self.restore_queue.iter().position(|w| w.app_id == app_id && w.title == title) {
+            return Some(self.restore_queue.remove(pos));
+        }
+        // Second pass: app_id only match
+        if let Some(pos) = self.restore_queue.iter().position(|w| w.app_id == app_id) {
+            return Some(self.restore_queue.remove(pos));
+        }
+        None
+    }
+
+    pub unsafe fn spawn_restored_windows(&mut self) {
+        log::info!("Spawning restored windows. Total: {}", self.restore_queue.len());
+        let restored = self.restore_queue.clone();
+        for w in restored {
+            if !w.cmdline.is_empty() {
+                log::info!("Spawning restored window command: {}", w.cmdline);
+                self.execute_action(&crate::config::Action::Spawn, Some(&w.cmdline));
+            }
+        }
+    }
+
     pub fn start_ipc(&mut self, display_socket: Option<String>) {
         if self.ipc_rx.is_none() {
             let rx = crate::ipc_server::spawn_ipc_server(display_socket);
@@ -545,6 +687,7 @@ impl WindowManager {
         if self.scheduled.dirty || self.scheduled.dirty_lazy || self.rendering_scheduled.dirty {
             self.add_dirty_idle();
         }
+        self.save_state();
     }
 }
 
@@ -1930,7 +2073,7 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
                     // Process old PIDs
                     for (old_prog, old_pid) in old_pids {
                         // If it is still in new startup and once == true, keep it running
-                        let still_exists_and_once = self.startup.iter().any(|p| p.exec == old_prog.exec && p.once);
+                        let still_exists_and_once = self.startup.iter().any(|p| p.exec == old_prog.exec && p.once && !p.restart);
                         if still_exists_and_once {
                             self.startup_pids.push((old_prog, old_pid));
                         } else {