//! Clear-on-close: wipe a profile's browsing data when the browser exits, //! keeping extensions and bookmarks (and the settings files Chromium needs to //! keep those working). The middle ground between a fully persistent profile //! and a RAM-backed ephemeral one. use std::fs; use std::path::Path; use crate::profile::types::BrowserProfile; /// Entries kept inside a Chromium profile directory (one with `Preferences`). /// Everything else — cookies, History, caches, storage, sessions, autofill, /// saved logins — is browsing data and gets removed. const PROFILE_KEEP: &[&str] = &[ "Bookmarks", "Bookmarks.bak", "Extensions", "Extension State", "Extension Rules", "Extension Scripts", "Extension Cookies", "Local Extension Settings", "Managed Extension Settings", // Preferences hold the extension registry + user settings; deleting them // disables every installed extension, so they stay. "Preferences", "Secure Preferences", ]; /// Entries kept at the user-data-dir top level (outside profile subdirs). const TOP_KEEP: &[&str] = &["Local State", "First Run"]; fn is_kept(name: &str) -> bool { PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name) } /// Whether a top-level entry name is one of Chromium's profile directories. /// /// Identity must not rest on `Preferences` existing. Chromium writes it lazily, /// so a crash — or a user deleting a corrupt copy, a standard troubleshooting /// step since it regenerates — leaves a populated `Default/` without it. Such a /// directory would then be treated as stale and removed wholesale, destroying the /// Extensions and Bookmarks this feature exists to preserve. pub(crate) fn is_profile_dir_name(name: &str) -> bool { matches!(name, "Default" | "Guest Profile" | "System Profile") || name .strip_prefix("Profile ") .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) } fn remove_entry(path: &Path) { let result = if path.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }; if let Err(e) = result { log::warn!("clear-on-close: failed to remove {}: {e}", path.display()); } } /// Wipe browsing data inside a Chromium profile dir, keeping `PROFILE_KEEP` /// (and `TOP_KEEP`, harmless at this level). fn clear_chromium_profile_dir(profile_dir: &Path) -> usize { let mut cleared = 0usize; let Ok(entries) = fs::read_dir(profile_dir) else { return 0; }; for entry in entries.flatten() { let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; if is_kept(name) { continue; } remove_entry(&entry.path()); cleared += 1; } cleared } /// Clear browsing data in a Wayfern user-data directory. Handles both /// layouts: profile content at the root (imported profiles copy a Chromium /// profile dir directly) and the standard `Default` / `Profile N` subdirs /// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache, /// component_crx_cache, …) are removed; `Local State` stays because it holds /// the os_crypt key extensions may rely on. pub fn clear_user_data_dir(user_data_dir: &Path) -> usize { if !user_data_dir.exists() { return 0; } // Root itself is a profile dir (legacy/imported layout). if user_data_dir.join("Preferences").exists() { return clear_chromium_profile_dir(user_data_dir); } let mut cleared = 0usize; let Ok(entries) = fs::read_dir(user_data_dir) else { return 0; }; for entry in entries.flatten() { let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; if is_kept(name) { continue; } let path = entry.path(); if path.is_dir() && (path.join("Preferences").exists() || is_profile_dir_name(name)) { // A profile subdir (Default / Profile N) — clear inside, keep the dir. cleared += clear_chromium_profile_dir(&path); } else { remove_entry(&path); cleared += 1; } } cleared } /// Clear a profile's browsing data if its `clear_on_close` flag is set. /// No-ops for ephemeral (already wiped) and password-protected (dir is /// re-encrypted; plaintext never persists) profiles. Runs the filesystem work /// on a blocking thread. pub async fn clear_profile_browsing_data(profile: &BrowserProfile) { if !profile.clear_on_close || profile.ephemeral || profile.password_protected { return; } let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir(); let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir); let name = profile.name.clone(); let cleared = tokio::task::spawn_blocking(move || clear_user_data_dir(&user_data_dir)) .await .unwrap_or(0); log::info!("clear-on-close: cleared {cleared} browsing-data entries for profile '{name}'"); } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn touch(dir: &Path, name: &str) { fs::write(dir.join(name), "x").unwrap(); } fn mkdir(dir: &Path, name: &str) { fs::create_dir_all(dir.join(name)).unwrap(); } #[test] fn clears_root_profile_layout_keeping_extensions_and_bookmarks() { let tmp = TempDir::new().unwrap(); let dir = tmp.path(); touch(dir, "Preferences"); touch(dir, "Secure Preferences"); touch(dir, "Bookmarks"); touch(dir, "History"); touch(dir, "Web Data"); touch(dir, "Login Data"); mkdir(dir, "Extensions"); mkdir(dir, "Local Extension Settings"); mkdir(dir, "Cache"); mkdir(dir, "Network"); touch(&dir.join("Network"), "Cookies"); mkdir(dir, "Local Storage"); let cleared = clear_user_data_dir(dir); assert!( cleared >= 5, "should clear History/WebData/Login/Cache/Network/LocalStorage, got {cleared}" ); assert!(dir.join("Preferences").exists()); assert!(dir.join("Bookmarks").exists()); assert!(dir.join("Extensions").exists()); assert!(dir.join("Local Extension Settings").exists()); assert!(!dir.join("History").exists()); assert!(!dir.join("Web Data").exists()); assert!(!dir.join("Login Data").exists()); assert!(!dir.join("Cache").exists()); assert!(!dir.join("Network").exists(), "Network/Cookies must go"); assert!(!dir.join("Local Storage").exists()); } #[test] fn clears_standard_user_data_layout() { let tmp = TempDir::new().unwrap(); let dir = tmp.path(); touch(dir, "Local State"); mkdir(dir, "ShaderCache"); mkdir(dir, "Default"); let default = dir.join("Default"); touch(&default, "Preferences"); touch(&default, "Bookmarks"); touch(&default, "History"); mkdir(&default, "Extensions"); mkdir(&default, "IndexedDB"); clear_user_data_dir(dir); assert!(dir.join("Local State").exists()); assert!(!dir.join("ShaderCache").exists()); assert!(default.join("Preferences").exists()); assert!(default.join("Bookmarks").exists()); assert!(default.join("Extensions").exists()); assert!(!default.join("History").exists()); assert!(!default.join("IndexedDB").exists()); } #[test] fn profile_subdir_without_preferences_keeps_extensions_and_bookmarks() { // Chromium writes Preferences lazily, so a crash (or a user removing a // corrupt copy) can leave a populated Default/ without it. Identifying // profile dirs by Preferences alone would delete the whole directory. let tmp = TempDir::new().unwrap(); let dir = tmp.path(); touch(dir, "Local State"); mkdir(dir, "Default"); let default = dir.join("Default"); touch(&default, "Bookmarks"); touch(&default, "History"); mkdir(&default, "Extensions"); mkdir(&default, "Local Extension Settings"); mkdir(&default, "IndexedDB"); clear_user_data_dir(dir); assert!(default.exists(), "the profile dir must survive"); assert!(default.join("Bookmarks").exists()); assert!(default.join("Extensions").exists()); assert!(default.join("Local Extension Settings").exists()); // Browsing data inside it is still cleared. assert!(!default.join("History").exists()); assert!(!default.join("IndexedDB").exists()); } #[test] fn numbered_profile_dirs_are_recognised() { let tmp = TempDir::new().unwrap(); let dir = tmp.path(); mkdir(dir, "Profile 2"); let p2 = dir.join("Profile 2"); touch(&p2, "Bookmarks"); touch(&p2, "History"); clear_user_data_dir(dir); assert!(p2.join("Bookmarks").exists()); assert!(!p2.join("History").exists()); } #[test] fn non_profile_dirs_are_still_removed_wholesale() { // The permissive branch must not turn into "never delete a directory": // top-level caches are browsing data and have to go. let tmp = TempDir::new().unwrap(); let dir = tmp.path(); mkdir(dir, "ShaderCache"); mkdir(dir, "component_crx_cache"); mkdir(dir, "Profileless"); clear_user_data_dir(dir); assert!(!dir.join("ShaderCache").exists()); assert!(!dir.join("component_crx_cache").exists()); assert!( !dir.join("Profileless").exists(), "a name that merely starts with 'Profile' is not a profile dir" ); } #[test] fn profile_dir_name_matching() { assert!(is_profile_dir_name("Default")); assert!(is_profile_dir_name("Profile 1")); assert!(is_profile_dir_name("Profile 42")); assert!(is_profile_dir_name("Guest Profile")); assert!(is_profile_dir_name("System Profile")); assert!(!is_profile_dir_name("Profile")); assert!(!is_profile_dir_name("Profile ")); assert!(!is_profile_dir_name("Profile abc")); assert!(!is_profile_dir_name("ShaderCache")); } #[test] fn missing_dir_is_a_noop() { let tmp = TempDir::new().unwrap(); assert_eq!(clear_user_data_dir(&tmp.path().join("nope")), 0); } }