git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit5619a7a44a0ec48d284727ebfc5f5c6a3fa8da74
parentf30469d0c5
authorLucas Galante <[email protected]>
date2026-05-23 15:41
Add Backup page to System Interface

 src/app.rs          |   4 +
 src/main.rs         |  26 ++++++
 src/pages/backup.rs | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/pages/mod.rs    |   6 +-
 4 files changed, 265 insertions(+), 1 deletion(-)

diff --git a/src/app.rs b/src/app.rs
index d898d6e..326424b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -11,6 +11,7 @@ use crate::pages::processors;
 use crate::pages::status;
 use crate::pages::storage;
 use crate::pages::system_info;
+use crate::pages::backup;
 use crate::pages::Page;
 
 pub struct AppState {
@@ -26,6 +27,7 @@ pub struct AppState {
     pub status: status::StatusState,
     pub storage: storage::StorageState,
     pub notifications: notifications::NotificationsState,
+    pub backup: backup::BackupState,
 }
 
 impl Default for AppState {
@@ -43,6 +45,7 @@ impl Default for AppState {
             status: status::StatusState::default(),
             storage: storage::StorageState::default(),
             notifications: notifications::read_notifications_config(),
+            backup: backup::BackupState::default(),
         }
     }
 }
@@ -60,6 +63,7 @@ pub enum AppAction {
     Status(status::StatusMessage),
     Storage(storage::StorageMessage),
     Notifications(notifications::NotificationsMessage),
+    Backup(backup::BackupMessage),
 }
 
 pub struct PageContent {
diff --git a/src/main.rs b/src/main.rs
index c430a50..2fd0130 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -126,6 +126,9 @@ struct SystemInterface {
     rx_status: std::sync::mpsc::Receiver<pages::status::StatusState>,
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
     rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsState>,
+    rx_backup_state: std::sync::mpsc::Receiver<pages::backup::BackupState>,
+    tx_backup: std::sync::mpsc::Sender<pages::backup::BackupMessage>,
+    rx_backup: std::sync::mpsc::Receiver<pages::backup::BackupMessage>,
     tx_color_selector: std::sync::mpsc::Sender<ColorSelectorAction>,
     rx_color_selector: std::sync::mpsc::Receiver<ColorSelectorAction>,
 
@@ -283,6 +286,8 @@ impl SystemInterface {
             });
             rx
         };
+        let rx_backup_state = spawn_bg(30, || pages::backup::fetch_backup_state());
+        let (tx_backup, rx_backup) = std::sync::mpsc::channel();
 
         let (tx_color_selector, rx_color_selector) = std::sync::mpsc::channel();
         let scale_factor = (window.scale_factor() as f32).max(2.0) as f64;
@@ -297,6 +302,7 @@ impl SystemInterface {
             scale_factor,
             rx_power, rx_audio, rx_display, rx_network, rx_layout, rx_input,
             rx_processors, rx_system, rx_status, rx_storage, rx_notifications,
+            rx_backup_state, tx_backup, rx_backup,
             tx_color_selector, rx_color_selector,
             width: size.width, height: size.height,
             needs_rebuild: true,
@@ -446,6 +452,7 @@ impl SystemInterface {
             Page::Status => status::view(&self.app.status, cx, cy, cw, ch),
             Page::Storage => storage::view(&self.app.storage, cx, cy, cw, ch),
             Page::Notifications => notifications::view(&mut self.app.notifications, cx, cy, cw, ch),
+            Page::Backup => backup::view(&self.app.backup, cx, cy, cw, ch),
         }
     }
 
@@ -552,6 +559,14 @@ impl SystemInterface {
             notifications::update(&mut self.app.notifications, notifications::NotificationsMessage::Refreshed(s));
             self.needs_rebuild = true;
         }
+        while let Ok(s) = self.rx_backup_state.try_recv() {
+            pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::Refreshed(s));
+            self.needs_rebuild = true;
+        }
+        while let Ok(m) = self.rx_backup.try_recv() {
+            self.handle_action(&AppAction::Backup(m));
+            self.needs_rebuild = true;
+        }
         while let Ok(action) = self.rx_color_selector.try_recv() {
             match action {
                 ColorSelectorAction::Background(rgb) => {
@@ -613,6 +628,17 @@ impl SystemInterface {
             AppAction::Status(m) => status::update(&mut self.app.status, m.clone()),
             AppAction::Storage(m) => storage::update(&mut self.app.storage, m.clone()),
             AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
+            AppAction::Backup(m) => match m {
+                pages::backup::BackupMessage::StartBackup => {
+                    pages::backup::update(&mut self.app.backup, pages::backup::BackupMessage::StartBackup);
+                    let tx = self.tx_backup.clone();
+                    tokio::spawn(async move {
+                        let res = pages::backup::run_backup().await;
+                        let _ = tx.send(pages::backup::BackupMessage::BackupFinished(res));
+                    });
+                }
+                _ => pages::backup::update(&mut self.app.backup, m.clone()),
+            },
         }
     }
 
diff --git a/src/pages/backup.rs b/src/pages/backup.rs
new file mode 100644
index 0000000..37cbff5
--- /dev/null
+++ b/src/pages/backup.rs
@@ -0,0 +1,230 @@
+use crate::app::{AppAction, PageContent};
+use clear_ui::layout::Section;
+use std::fs;
+use std::path::Path;
+
+#[derive(Debug, Clone, Default)]
+pub struct BackupState {
+    pub loaded: bool,
+    pub in_progress: bool,
+    pub last_backup_time: String,
+    pub backup_size: String,
+    pub error_message: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+pub enum BackupMessage {
+    Refreshed(BackupState),
+    StartBackup,
+    BackupFinished(Result<(String, String), String>),
+}
+
+fn status_path() -> String {
+    format!("{}/.config/clear-system-interface/backup_status.txt", std::env::var("HOME").unwrap_or_default())
+}
+
+pub fn read_backup_status() -> (String, String) {
+    let path_str = status_path();
+    let content = fs::read_to_string(path_str).unwrap_or_default();
+    
+    let mut last_backup = "Never".to_string();
+    let mut size = "0 B".to_string();
+    
+    for line in content.lines() {
+        let trimmed = line.trim();
+        if trimmed.starts_with("last_backup_time") {
+            if let Some(val) = trimmed.split('=').nth(1) {
+                last_backup = val.trim().to_string();
+            }
+        } else if trimmed.starts_with("backup_size") {
+            if let Some(val) = trimmed.split('=').nth(1) {
+                size = val.trim().to_string();
+            }
+        }
+    }
+    
+    (last_backup, size)
+}
+
+fn write_backup_status(last_backup: &str, size: &str) {
+    let path_str = status_path();
+    let path = Path::new(&path_str);
+    if let Some(parent) = path.parent() {
+        let _ = fs::create_dir_all(parent);
+    }
+    let content = format!("last_backup_time = {}\nbackup_size = {}\n", last_backup, size);
+    let _ = fs::write(path, content);
+}
+
+pub async fn fetch_backup_state() -> BackupState {
+    let (last_backup, size) = read_backup_status();
+    BackupState {
+        loaded: true,
+        in_progress: false,
+        last_backup_time: last_backup,
+        backup_size: size,
+        error_message: None,
+    }
+}
+
+pub async fn run_backup() -> Result<(String, String), String> {
+    let home = std::env::var("HOME").map_err(|_| "HOME env var not set".to_string())?;
+    
+    // 1. Ensure target backups directory exists
+    let backup_dir = format!("{}/Dropbox/Backups", home);
+    tokio::fs::create_dir_all(&backup_dir).await
+        .map_err(|e| format!("Failed to create backup dir: {}", e))?;
+        
+    let archive_path = format!("{}/clear-backup.tar.gz", backup_dir);
+    
+    // 2. Execute tar command to compress clearwm config and clear projects
+    let output = tokio::process::Command::new("tar")
+        .args([
+            "-czf",
+            &archive_path,
+            "-C",
+            &home,
+            ".config/clearwm",
+            "Dropbox/Clear",
+        ])
+        .output()
+        .await
+        .map_err(|e| format!("Failed to execute tar: {}", e))?;
+        
+    if !output.status.success() {
+        let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
+        return Err(format!("tar error: {}", err_msg));
+    }
+    
+    // 3. Get archive file size
+    let size_output = tokio::process::Command::new("du")
+        .args(["-sh", &archive_path])
+        .output()
+        .await
+        .map_err(|e| format!("Failed to get archive size: {}", e))?;
+        
+    let size_str = if size_output.status.success() {
+        let stdout = String::from_utf8_lossy(&size_output.stdout);
+        stdout.split_whitespace().next().unwrap_or("Unknown").to_string()
+    } else {
+        "Unknown".to_string()
+    };
+    
+    // 4. Get current formatted time
+    let date_output = tokio::process::Command::new("date")
+        .arg("+%Y-%m-%d %H:%M:%S")
+        .output()
+        .await
+        .map_err(|e| format!("Failed to get current date: {}", e))?;
+        
+    let date_str = if date_output.status.success() {
+        String::from_utf8_lossy(&date_output.stdout).trim().to_string()
+    } else {
+        "Unknown Date".to_string()
+    };
+    
+    // 5. Write status back to config file
+    write_backup_status(&date_str, &size_str);
+        
+    Ok((date_str, size_str))
+}
+
+const LABEL_FG: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
+const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
+const GREEN: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
+const BTN_BG: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
+const BTN_HOVER: [f32; 4] = [0.28, 0.50, 0.78, 1.0];
+const BTN_DISABLED: [f32; 4] = [0.15, 0.18, 0.22, 1.0];
+const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+
+pub fn view(state: &BackupState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+    let mut pc = PageContent::new();
+    let y = cy + 12.0;
+
+    let mut sec = Section::new(&mut pc, cx, y, cw, "System Backup");
+
+    if !state.loaded {
+        sec.text(&mut pc, "Loading backup state...", 12.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(18.0);
+    } else {
+        // Status Row
+        sec.text(&mut pc, "Backup Status", 12.0, 0.0, 12.0, LABEL_FG);
+        let status_text = if state.in_progress { "Backing up..." } else { "Idle" };
+        let status_color = if state.in_progress { GREEN } else { TEXT_FG };
+        sec.text(&mut pc, status_text, 120.0, 0.0, 12.0, status_color);
+        sec.spacing(18.0);
+
+        // Last Backup Row
+        sec.text(&mut pc, "Last Backup", 12.0, 0.0, 12.0, LABEL_FG);
+        sec.text(&mut pc, &state.last_backup_time, 120.0, 0.0, 12.0, TEXT_FG);
+        sec.spacing(18.0);
+
+        // Backup Size Row
+        sec.text(&mut pc, "Archive Size", 12.0, 0.0, 12.0, LABEL_FG);
+        sec.text(&mut pc, &state.backup_size, 120.0, 0.0, 12.0, TEXT_FG);
+        sec.spacing(18.0);
+
+        // Target Directories Row
+        sec.text(&mut pc, "Backup Targets", 12.0, 0.0, 12.0, LABEL_FG);
+        sec.text(&mut pc, "~/.config/clearwm/  •  ~/Dropbox/Clear/", 120.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(18.0);
+
+        // Destination Archive Row
+        sec.text(&mut pc, "Destination", 12.0, 0.0, 12.0, LABEL_FG);
+        sec.text(&mut pc, "~/Dropbox/Backups/clear-backup.tar.gz", 120.0, 0.0, 12.0, TEXT_DIM);
+        sec.spacing(24.0);
+
+        // Error message if present
+        if let Some(ref err) = state.error_message {
+            sec.text(&mut pc, "Error:", 12.0, 0.0, 12.0, RED);
+            sec.text(&mut pc, err, 60.0, 0.0, 11.0, RED);
+            sec.spacing(18.0);
+        }
+
+        // Action Button
+        let btn_w = 120.0;
+        let btn_h = 32.0;
+        let yt = sec.ay();
+        
+        let (btn_label, bg, hover, action) = if state.in_progress {
+            ("Backing up...", BTN_DISABLED, BTN_DISABLED, AppAction::Backup(BackupMessage::StartBackup)) // no-op when in progress
+        } else {
+            ("Run Backup", BTN_BG, BTN_HOVER, AppAction::Backup(BackupMessage::StartBackup))
+        };
+        
+        pc.button(btn_label, sec.ax(12.0), yt, btn_w, btn_h, bg, hover, WHITE, action);
+        sec.content_y += btn_h + 12.0;
+    }
+    sec.finish(&mut pc);
+
+    pc
+}
+
+pub fn update(state: &mut BackupState, msg: BackupMessage) {
+    match msg {
+        BackupMessage::Refreshed(new) => {
+            let in_prog = state.in_progress;
+            *state = new;
+            state.in_progress = in_prog;
+        }
+        BackupMessage::StartBackup => {
+            state.in_progress = true;
+            state.error_message = None;
+        }
+        BackupMessage::BackupFinished(res) => {
+            state.in_progress = false;
+            match res {
+                Ok((date, size)) => {
+                    state.last_backup_time = date;
+                    state.backup_size = size;
+                    state.error_message = None;
+                }
+                Err(err) => {
+                    state.error_message = Some(err);
+                }
+            }
+        }
+    }
+}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 07902e8..c6f8378 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -10,6 +10,7 @@ pub mod input;
 pub mod status;
 pub mod processors;
 pub mod notifications;
+pub mod backup;
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum Page {
@@ -24,11 +25,13 @@ pub enum Page {
     Input,
     Status,
     Notifications,
+    Backup,
 }
 
 impl Page {
-    pub const ALL: [Page; 11] = [
+    pub const ALL: [Page; 12] = [
         Page::Audio,
+        Page::Backup,
         Page::Display,
         Page::Input,
         Page::Layout,
@@ -54,6 +57,7 @@ impl Page {
             Page::Input => "Input",
             Page::Status => "Status",
             Page::Notifications => "Notifications",
+            Page::Backup => "Backup",
         }
     }