Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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 {