Skip to main content

null_e/cleaners/
electron.rs

1//! Electron Apps cleanup module
2//!
3//! Handles cleanup of Electron-based application caches:
4//! - Slack
5//! - Discord
6//! - Spotify
7//! - Microsoft Teams
8//! - Notion
9//! - Figma
10//! - And many more Electron/Chromium apps
11
12use super::{calculate_dir_size, get_mtime, CleanableItem, SafetyLevel};
13use crate::error::Result;
14use std::path::PathBuf;
15
16/// Known Electron apps with their cache locations
17const ELECTRON_APPS: &[(&str, &str, &str)] = &[
18    // (App Name, Application Support folder name, Icon)
19    ("Slack", "Slack", "💬"),
20    ("Discord", "discord", "🎮"),
21    ("Spotify", "Spotify", "🎵"),
22    ("Microsoft Teams", "Microsoft Teams", "👥"),
23    ("Microsoft Teams (Classic)", "Microsoft/Teams", "👥"),
24    ("Notion", "Notion", "📝"),
25    ("Figma", "Figma", "🎨"),
26    ("Obsidian", "obsidian", "💎"),
27    ("Postman", "Postman", "📮"),
28    ("Insomnia", "Insomnia", "🌙"),
29    ("Hyper", "Hyper", "⚡"),
30    ("GitKraken", "GitKraken", "🐙"),
31    ("Atom", "Atom", "⚛️"),
32    ("Signal", "Signal", "🔒"),
33    ("WhatsApp", "WhatsApp", "📱"),
34    ("Telegram Desktop", "Telegram Desktop", "✈️"),
35    ("Linear", "Linear", "📊"),
36    ("Loom", "Loom", "🎥"),
37    ("Cron", "Cron", "📅"),
38    ("Raycast", "com.raycast.macos", "🔍"),
39    ("1Password", "1Password", "🔐"),
40    ("Bitwarden", "Bitwarden", "🔐"),
41    ("Franz", "Franz", "📬"),
42    ("Station", "Station", "🚉"),
43    ("Skype", "Skype", "📞"),
44    ("Zoom", "zoom.us", "📹"),
45    ("Webex", "Cisco Webex Meetings", "🌐"),
46    ("Miro", "Miro", "🖼️"),
47    ("ClickUp", "ClickUp", "✅"),
48    ("Todoist", "Todoist", "☑️"),
49    ("Trello", "Trello", "📋"),
50];
51
52/// Electron apps cleaner
53pub struct ElectronCleaner {
54    home: PathBuf,
55}
56
57impl ElectronCleaner {
58    /// Create a new Electron apps cleaner
59    pub fn new() -> Option<Self> {
60        let home = dirs::home_dir()?;
61        Some(Self { home })
62    }
63
64    /// Detect all Electron app cleanable items
65    pub fn detect(&self) -> Result<Vec<CleanableItem>> {
66        let mut items = Vec::new();
67
68        #[cfg(target_os = "macos")]
69        {
70            items.extend(self.detect_macos_apps()?);
71        }
72
73        #[cfg(target_os = "linux")]
74        {
75            items.extend(self.detect_linux_apps()?);
76        }
77
78        #[cfg(target_os = "windows")]
79        {
80            items.extend(self.detect_windows_apps()?);
81        }
82
83        Ok(items)
84    }
85
86    #[cfg(target_os = "macos")]
87    fn detect_macos_apps(&self) -> Result<Vec<CleanableItem>> {
88        let mut items = Vec::new();
89
90        let app_support = self.home.join("Library/Application Support");
91        let caches = self.home.join("Library/Caches");
92
93        for (app_name, folder_name, icon) in ELECTRON_APPS {
94            let mut app_items = Vec::new();
95
96            // Check Application Support
97            let support_path = app_support.join(folder_name);
98            if support_path.exists() {
99                // Check for specific cache directories within
100                let cache_subdirs = ["Cache", "CachedData", "GPUCache", "Code Cache", "Service Worker", "blob_storage"];
101
102                for subdir in cache_subdirs {
103                    let cache_path = support_path.join(subdir);
104                    if cache_path.exists() {
105                        let (size, file_count) = calculate_dir_size(&cache_path)?;
106                        if size > 20_000_000 {
107                            app_items.push((cache_path, size, file_count, subdir));
108                        }
109                    }
110                }
111
112                // Also check for old versions (common in Electron apps)
113                if let Ok(entries) = std::fs::read_dir(&support_path) {
114                    for entry in entries.filter_map(|e| e.ok()) {
115                        let name = entry.file_name().to_string_lossy().to_string();
116                        // Old versions often have version numbers
117                        if name.starts_with("app-") || name.contains(".old") {
118                            let (size, file_count) = calculate_dir_size(&entry.path())?;
119                            if size > 50_000_000 {
120                                app_items.push((entry.path(), size, file_count, "Old Version"));
121                            }
122                        }
123                    }
124                }
125            }
126
127            // Check Caches folder
128            let cache_variants = [
129                folder_name.to_string(),
130                format!("com.{}.desktop", folder_name.to_lowercase()),
131                format!("com.{}", folder_name.to_lowercase()),
132            ];
133
134            for variant in cache_variants {
135                let cache_path = caches.join(&variant);
136                if cache_path.exists() {
137                    let (size, file_count) = calculate_dir_size(&cache_path)?;
138                    if size > 30_000_000 {
139                        app_items.push((cache_path, size, file_count, "System Cache"));
140                    }
141                }
142            }
143
144            // Group small caches together, list large ones separately
145            let total_size: u64 = app_items.iter().map(|(_, s, _, _)| *s).sum();
146
147            if total_size > 50_000_000 {
148                if app_items.len() == 1 {
149                    let (path, size, file_count, cache_type) = app_items.remove(0);
150                    items.push(CleanableItem {
151                        name: format!("{} {}", app_name, cache_type),
152                        category: "Electron Apps".to_string(),
153                        subcategory: app_name.to_string(),
154                        icon,
155                        path,
156                        size,
157                        file_count: Some(file_count),
158                        last_modified: None,
159                        description: "Electron app cache. Will be rebuilt on next launch.",
160                        safe_to_delete: SafetyLevel::SafeWithCost,
161                        clean_command: None,
162                    });
163                } else {
164                    // Report the main Application Support folder
165                    let support_path = app_support.join(folder_name);
166                    if support_path.exists() {
167                        items.push(CleanableItem {
168                            name: format!("{} Caches", app_name),
169                            category: "Electron Apps".to_string(),
170                            subcategory: app_name.to_string(),
171                            icon,
172                            path: support_path,
173                            size: total_size,
174                            file_count: Some(app_items.iter().map(|(_, _, c, _)| *c).sum()),
175                            last_modified: None,
176                            description: "Electron app cache and data. Consider cleaning individual subdirectories.",
177                            safe_to_delete: SafetyLevel::Caution,
178                            clean_command: None,
179                        });
180                    }
181                }
182            }
183        }
184
185        // Also detect any other Electron-like apps by looking for Chromium cache patterns
186        items.extend(self.detect_chromium_caches()?);
187
188        Ok(items)
189    }
190
191    #[cfg(target_os = "macos")]
192    fn detect_chromium_caches(&self) -> Result<Vec<CleanableItem>> {
193        let mut items = Vec::new();
194
195        let caches = self.home.join("Library/Caches");
196        if !caches.exists() {
197            return Ok(items);
198        }
199
200        // Look for Chromium-style cache patterns
201        if let Ok(entries) = std::fs::read_dir(&caches) {
202            for entry in entries.filter_map(|e| e.ok()) {
203                let path = entry.path();
204                let name = path.file_name()
205                    .map(|n| n.to_string_lossy().to_string())
206                    .unwrap_or_default();
207
208                // Skip known apps we already handle
209                let already_handled = ELECTRON_APPS.iter().any(|(_, folder, _)| {
210                    name.to_lowercase().contains(&folder.to_lowercase())
211                });
212
213                if already_handled {
214                    continue;
215                }
216
217                // Check if it looks like an Electron app (has GPUCache or similar)
218                let gpu_cache = path.join("GPUCache");
219                let code_cache = path.join("Code Cache");
220
221                if gpu_cache.exists() || code_cache.exists() {
222                    let (size, file_count) = calculate_dir_size(&path)?;
223                    if size > 100_000_000 {
224                        items.push(CleanableItem {
225                            name: format!("App Cache: {}", name),
226                            category: "Electron Apps".to_string(),
227                            subcategory: "Other".to_string(),
228                            icon: "📦",
229                            path,
230                            size,
231                            file_count: Some(file_count),
232                            last_modified: get_mtime(&entry.path()),
233                            description: "Electron/Chromium-based app cache.",
234                            safe_to_delete: SafetyLevel::SafeWithCost,
235                            clean_command: None,
236                        });
237                    }
238                }
239            }
240        }
241
242        Ok(items)
243    }
244
245    #[cfg(target_os = "linux")]
246    fn detect_linux_apps(&self) -> Result<Vec<CleanableItem>> {
247        let mut items = Vec::new();
248
249        let config_dir = self.home.join(".config");
250
251        for (app_name, folder_name, icon) in ELECTRON_APPS {
252            let app_path = config_dir.join(folder_name);
253            if !app_path.exists() {
254                continue;
255            }
256
257            // Check for cache subdirectories
258            let cache_path = app_path.join("Cache");
259            if cache_path.exists() {
260                let (size, file_count) = calculate_dir_size(&cache_path)?;
261                if size > 50_000_000 {
262                    items.push(CleanableItem {
263                        name: format!("{} Cache", app_name),
264                        category: "Electron Apps".to_string(),
265                        subcategory: app_name.to_string(),
266                        icon,
267                        path: cache_path,
268                        size,
269                        file_count: Some(file_count),
270                        last_modified: None,
271                        description: "Electron app cache.",
272                        safe_to_delete: SafetyLevel::SafeWithCost,
273                        clean_command: None,
274                    });
275                }
276            }
277        }
278
279        Ok(items)
280    }
281
282    #[cfg(target_os = "windows")]
283    fn detect_windows_apps(&self) -> Result<Vec<CleanableItem>> {
284        let mut items = Vec::new();
285
286        let appdata = self.home.join("AppData/Roaming");
287
288        for (app_name, folder_name, icon) in ELECTRON_APPS {
289            let app_path = appdata.join(folder_name);
290            if !app_path.exists() {
291                continue;
292            }
293
294            let cache_path = app_path.join("Cache");
295            if cache_path.exists() {
296                let (size, file_count) = calculate_dir_size(&cache_path)?;
297                if size > 50_000_000 {
298                    items.push(CleanableItem {
299                        name: format!("{} Cache", app_name),
300                        category: "Electron Apps".to_string(),
301                        subcategory: app_name.to_string(),
302                        icon,
303                        path: cache_path,
304                        size,
305                        file_count: Some(file_count),
306                        last_modified: None,
307                        description: "Electron app cache.",
308                        safe_to_delete: SafetyLevel::SafeWithCost,
309                        clean_command: None,
310                    });
311                }
312            }
313        }
314
315        Ok(items)
316    }
317}
318
319impl Default for ElectronCleaner {
320    fn default() -> Self {
321        Self::new().expect("ElectronCleaner requires home directory")
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_electron_cleaner_creation() {
331        let cleaner = ElectronCleaner::new();
332        assert!(cleaner.is_some());
333    }
334
335    #[test]
336    fn test_electron_detection() {
337        if let Some(cleaner) = ElectronCleaner::new() {
338            let items = cleaner.detect().unwrap();
339            println!("Found {} Electron app items", items.len());
340            for item in &items {
341                println!("  {} {} ({} bytes)", item.icon, item.name, item.size);
342            }
343        }
344    }
345}