From 469b6848768b03195551576c0f7b4a463e441b38 Mon Sep 17 00:00:00 2001 From: Julian Freeman Date: Fri, 17 Apr 2026 14:54:16 -0400 Subject: [PATCH] support delete extensions --- src-tauri/src/commands.rs | 225 ++++++++++++++- src-tauri/src/lib.rs | 3 +- src-tauri/src/models.rs | 54 +++- src-tauri/src/scanner.rs | 17 +- src/App.vue | 38 +++ .../browser-data/AssociatedProfilesModal.vue | 203 +++++++++++++- .../browser-data/BrowserDataView.vue | 66 ++++- .../browser-data/ExtensionRemovalModal.vue | 189 +++++++++++++ .../browser-data/ExtensionsList.vue | 183 ++++++++++++- src/composables/useBrowserManager.ts | 257 +++++++++++++++++- src/types/browser.ts | 37 ++- src/utils/sort.ts | 7 +- 12 files changed, 1253 insertions(+), 26 deletions(-) create mode 100644 src/components/browser-data/ExtensionRemovalModal.vue diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 722f7d4..44501bb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,11 +8,13 @@ use crate::{ config_store, models::{ BrowserConfigListResponse, CleanupHistoryInput, CleanupHistoryResponse, - CleanupHistoryResult, CreateCustomBrowserConfigInput, ScanResponse, + CleanupHistoryResult, CreateCustomBrowserConfigInput, ExtensionInstallSourceSummary, + RemoveExtensionResult, RemoveExtensionsInput, RemoveExtensionsResponse, ScanResponse, }, scanner, }; use tauri::AppHandle; +use serde_json::Value; #[tauri::command] pub fn scan_browsers(app: AppHandle) -> Result { @@ -93,6 +95,35 @@ pub fn cleanup_history_files( Ok(CleanupHistoryResponse { results }) } +#[tauri::command] +pub fn remove_extensions( + app: AppHandle, + input: RemoveExtensionsInput, +) -> Result { + let config = config_store::find_browser_config(&app, &input.browser_id)?; + let user_data_dir = PathBuf::from(&config.user_data_path); + + if !user_data_dir.is_dir() { + return Err(format!( + "User data directory does not exist: {}", + user_data_dir.display() + )); + } + + let mut results = Vec::new(); + for removal in input.removals { + for profile_id in removal.profile_ids { + results.push(remove_extension_from_profile( + &user_data_dir.join(&profile_id), + &removal.extension_id, + &profile_id, + )); + } + } + + Ok(RemoveExtensionsResponse { results }) +} + fn spawn_browser_process( executable_path: PathBuf, user_data_dir: PathBuf, @@ -178,6 +209,198 @@ fn cleanup_profile_history_files(profile_path: &Path, profile_id: &str) -> Clean } } +fn remove_extension_from_profile( + profile_path: &Path, + extension_id: &str, + profile_id: &str, +) -> RemoveExtensionResult { + if !profile_path.is_dir() { + return RemoveExtensionResult { + extension_id: extension_id.to_string(), + profile_id: profile_id.to_string(), + removed_files: Vec::new(), + skipped_files: Vec::new(), + error: Some(format!( + "Profile directory does not exist: {}", + profile_path.display() + )), + }; + } + + let secure_preferences_path = profile_path.join("Secure Preferences"); + let preferences_path = profile_path.join("Preferences"); + let mut removed_files = Vec::new(); + let mut skipped_files = Vec::new(); + + let secure_preferences_outcome = + remove_extension_from_secure_preferences(&secure_preferences_path, extension_id); + let install_source = match secure_preferences_outcome { + Ok(Some(source)) => { + removed_files.push("Secure Preferences".to_string()); + source + } + Ok(None) => { + skipped_files.push("Secure Preferences".to_string()); + ExtensionInstallSourceSummary::External + } + Err(error) => { + return RemoveExtensionResult { + extension_id: extension_id.to_string(), + profile_id: profile_id.to_string(), + removed_files, + skipped_files, + error: Some(error), + }; + } + }; + + match remove_extension_from_preferences(&preferences_path, extension_id) { + Ok(true) => removed_files.push("Preferences".to_string()), + Ok(false) => skipped_files.push("Preferences".to_string()), + Err(error) => { + return RemoveExtensionResult { + extension_id: extension_id.to_string(), + profile_id: profile_id.to_string(), + removed_files, + skipped_files, + error: Some(error), + }; + } + } + + if install_source == ExtensionInstallSourceSummary::Store { + let extension_directory = profile_path.join("Extensions").join(extension_id); + if extension_directory.is_dir() { + if let Err(error) = fs::remove_dir_all(&extension_directory) { + return RemoveExtensionResult { + extension_id: extension_id.to_string(), + profile_id: profile_id.to_string(), + removed_files, + skipped_files, + error: Some(format!( + "Failed to delete {}: {error}", + extension_directory.display() + )), + }; + } + removed_files.push("Extensions".to_string()); + } else { + skipped_files.push("Extensions".to_string()); + } + } + + RemoveExtensionResult { + extension_id: extension_id.to_string(), + profile_id: profile_id.to_string(), + removed_files, + skipped_files, + error: None, + } +} + +fn remove_extension_from_secure_preferences( + path: &Path, + extension_id: &str, +) -> Result, String> { + let mut document = read_json_document(path)?; + let install_source = document + .get("extensions") + .and_then(|value| value.get("settings")) + .and_then(|value| value.get(extension_id)) + .and_then(|value| value.get("path")) + .and_then(Value::as_str) + .map(detect_extension_install_source) + .unwrap_or(ExtensionInstallSourceSummary::External); + + let mut changed = false; + changed |= remove_object_key( + &mut document, + &["extensions", "settings"], + extension_id, + ); + changed |= remove_object_key( + &mut document, + &["protection", "macs", "extensions", "settings"], + extension_id, + ); + changed |= remove_object_key( + &mut document, + &["protection", "macs", "extensions", "settings_encrypted_hash"], + extension_id, + ); + + if changed { + write_json_document(path, &document)?; + Ok(Some(install_source)) + } else { + Ok(None) + } +} + +fn remove_extension_from_preferences(path: &Path, extension_id: &str) -> Result { + let mut document = read_json_document(path)?; + let mut changed = false; + + if let Some(pinned_extensions) = get_value_mut(&mut document, &["extensions", "pinned_extensions"]) + { + if let Some(array) = pinned_extensions.as_array_mut() { + let original_len = array.len(); + array.retain(|value| value.as_str() != Some(extension_id)); + changed |= array.len() != original_len; + } else if let Some(object) = pinned_extensions.as_object_mut() { + changed |= object.remove(extension_id).is_some(); + } + } + + if changed { + write_json_document(path, &document)?; + } + + Ok(changed) +} + +fn detect_extension_install_source(raw_path: &str) -> ExtensionInstallSourceSummary { + let normalized_path = raw_path.trim().trim_start_matches('/'); + if normalized_path.is_empty() { + return ExtensionInstallSourceSummary::External; + } + + let candidate = PathBuf::from(normalized_path); + if candidate.is_absolute() { + ExtensionInstallSourceSummary::External + } else { + ExtensionInstallSourceSummary::Store + } +} + +fn read_json_document(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + serde_json::from_str(&content) + .map_err(|error| format!("Failed to parse {}: {error}", path.display())) +} + +fn write_json_document(path: &Path, document: &Value) -> Result<(), String> { + let content = serde_json::to_string_pretty(document) + .map_err(|error| format!("Failed to serialize {}: {error}", path.display()))?; + fs::write(path, content).map_err(|error| format!("Failed to write {}: {error}", path.display())) +} + +fn remove_object_key(document: &mut Value, object_path: &[&str], key: &str) -> bool { + get_value_mut(document, object_path) + .and_then(Value::as_object_mut) + .and_then(|object| object.remove(key)) + .is_some() +} + +fn get_value_mut<'a>(document: &'a mut Value, path: &[&str]) -> Option<&'a mut Value> { + let mut current = document; + for segment in path { + current = current.get_mut(*segment)?; + } + Some(current) +} + fn remove_sidecar_files(path: &Path) { for suffix in ["-journal", "-wal", "-shm"] { let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix)); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 524662f..5419114 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,8 @@ pub fn run() { commands::create_custom_browser_config, commands::delete_custom_browser_config, commands::open_browser_profile, - commands::cleanup_history_files + commands::cleanup_history_files, + commands::remove_extensions ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a54bb30..3995e79 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -56,7 +56,7 @@ pub struct ExtensionSummary { pub version: Option, pub icon_data_url: Option, pub profile_ids: Vec, - pub profiles: Vec, + pub profiles: Vec, } #[derive(Serialize)] @@ -115,6 +115,36 @@ pub struct CleanupHistoryResult { pub error: Option, } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveExtensionsInput { + pub browser_id: String, + pub removals: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionRemovalRequest { + pub extension_id: String, + pub profile_ids: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveExtensionsResponse { + pub results: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveExtensionResult { + pub extension_id: String, + pub profile_id: String, + pub removed_files: Vec, + pub skipped_files: Vec, + pub error: Option, +} + #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct AssociatedProfileSummary { @@ -127,6 +157,26 @@ pub struct AssociatedProfileSummary { pub avatar_label: String, } +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionAssociatedProfileSummary { + pub id: String, + pub name: String, + pub avatar_data_url: Option, + pub avatar_icon: Option, + pub default_avatar_fill_color: Option, + pub default_avatar_stroke_color: Option, + pub avatar_label: String, + pub install_source: ExtensionInstallSourceSummary, +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ExtensionInstallSourceSummary { + Store, + External, +} + #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct BookmarkAssociatedProfileSummary { @@ -207,7 +257,7 @@ pub struct TempExtension { pub version: Option, pub icon_data_url: Option, pub profile_ids: BTreeSet, - pub profiles: BTreeMap, + pub profiles: BTreeMap, } pub struct TempBookmark { diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs index 7d17e05..1a24eb7 100644 --- a/src-tauri/src/scanner.rs +++ b/src-tauri/src/scanner.rs @@ -12,8 +12,9 @@ use crate::{ models::{ AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary, BrowserConfigEntry, BrowserStats, BrowserView, CleanupFileStatus, ExtensionSummary, - HistoryCleanupSummary, PasswordSiteSummary, ProfileSummary, ScanResponse, TempBookmark, - TempExtension, TempPasswordSite, + ExtensionAssociatedProfileSummary, ExtensionInstallSourceSummary, HistoryCleanupSummary, + PasswordSiteSummary, ProfileSummary, ScanResponse, TempBookmark, TempExtension, + TempPasswordSite, }, utils::{ copy_sqlite_database_to_temp, first_non_empty, load_image_as_data_url, read_json_file, @@ -312,7 +313,7 @@ fn scan_extensions_for_profile( entry .profiles .entry(profile.id.clone()) - .or_insert_with(|| AssociatedProfileSummary { + .or_insert_with(|| ExtensionAssociatedProfileSummary { id: profile.id.clone(), name: profile.name.clone(), avatar_data_url: profile.avatar_data_url.clone(), @@ -320,6 +321,7 @@ fn scan_extensions_for_profile( default_avatar_fill_color: profile.default_avatar_fill_color, default_avatar_stroke_color: profile.default_avatar_stroke_color, avatar_label: profile.avatar_label.clone(), + install_source: install_source.summary(), }); } } @@ -359,6 +361,15 @@ enum ExtensionInstallSource { ExternalAbsolute, } +impl ExtensionInstallSource { + fn summary(&self) -> ExtensionInstallSourceSummary { + match self { + ExtensionInstallSource::StoreRelative => ExtensionInstallSourceSummary::Store, + ExtensionInstallSource::ExternalAbsolute => ExtensionInstallSourceSummary::External, + } + } +} + fn resolve_extension_name(manifest: &Value, version_path: &Path) -> Option { let raw_name = manifest.get("name").and_then(Value::as_str)?; if let Some(localized_name) = resolve_localized_manifest_value(raw_name, manifest, version_path) diff --git a/src/App.vue b/src/App.vue index 16c08f3..174d6f9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,8 +26,20 @@ const { confirmHistoryCleanup, currentBrowser, deleteCustomBrowserConfig, + deleteExtensionFromAllProfiles, + deleteExtensionFromProfile, + deleteSelectedExtensionProfiles, + deleteSelectedExtensions, error, extensionMonogram, + extensionDeleteBusy, + extensionModalSelectedProfileIds, + extensionRemovalConfirmExtensions, + extensionRemovalConfirmProfiles, + extensionRemovalError, + extensionRemovalResultOpen, + extensionRemovalResults, + extensionSelectedIds, extensionSortKey, historyCleanupBusy, historyCleanupConfirmProfiles, @@ -53,6 +65,13 @@ const { sortedExtensions, sortedPasswordSites, sortedProfiles, + closeExtensionRemovalConfirm, + closeExtensionRemovalResult, + confirmExtensionRemoval, + toggleAllExtensions, + toggleAllExtensionModalProfiles, + toggleExtensionModalProfileSelection, + toggleExtensionSelection, toggleAllHistoryProfiles, toggleHistoryProfile, closeAssociatedProfilesModal, @@ -128,6 +147,14 @@ const { :history-cleanup-result-open="historyCleanupResultOpen" :cleanup-history-error="cleanupHistoryError" :cleanup-history-results="cleanupHistoryResults" + :extension-selected-ids="extensionSelectedIds" + :extension-modal-selected-profile-ids="extensionModalSelectedProfileIds" + :extension-delete-busy="extensionDeleteBusy" + :extension-removal-confirm-extensions="extensionRemovalConfirmExtensions" + :extension-removal-confirm-profiles="extensionRemovalConfirmProfiles" + :extension-removal-result-open="extensionRemovalResultOpen" + :extension-removal-error="extensionRemovalError" + :extension-removal-results="extensionRemovalResults" :open-profile-error="openProfileError" :section-count="sectionCount" :is-opening-profile="isOpeningProfile" @@ -142,6 +169,17 @@ const { @show-extension-profiles="showExtensionProfilesModal" @show-bookmark-profiles="showBookmarkProfilesModal" @show-password-site-profiles="showPasswordSiteProfilesModal" + @toggle-extension-selection="toggleExtensionSelection" + @toggle-all-extensions="toggleAllExtensions" + @delete-extension-from-all-profiles="deleteExtensionFromAllProfiles" + @delete-selected-extensions="deleteSelectedExtensions" + @toggle-extension-modal-profile-selection="toggleExtensionModalProfileSelection" + @toggle-all-extension-modal-profiles="toggleAllExtensionModalProfiles" + @delete-extension-from-profile="deleteExtensionFromProfile" + @delete-selected-extension-profiles="deleteSelectedExtensionProfiles" + @confirm-extension-removal="confirmExtensionRemoval" + @close-extension-removal-confirm="closeExtensionRemovalConfirm" + @close-extension-removal-result="closeExtensionRemovalResult" @toggle-history-profile="toggleHistoryProfile" @toggle-all-history-profiles="toggleAllHistoryProfiles" @cleanup-selected-history="cleanupSelectedHistoryProfiles" diff --git a/src/components/browser-data/AssociatedProfilesModal.vue b/src/components/browser-data/AssociatedProfilesModal.vue index 8d48bda..772b09d 100644 --- a/src/components/browser-data/AssociatedProfilesModal.vue +++ b/src/components/browser-data/AssociatedProfilesModal.vue @@ -4,11 +4,15 @@ import type { AssociatedProfileSortKey, AssociatedProfileSummary, BookmarkAssociatedProfileSummary, + ExtensionAssociatedProfileSummary, } from "../../types/browser"; import { profileAvatarSrc } from "../../utils/icons"; import { sortAssociatedProfiles } from "../../utils/sort"; -type ModalProfile = AssociatedProfileSummary | BookmarkAssociatedProfileSummary; +type ModalProfile = + | AssociatedProfileSummary + | BookmarkAssociatedProfileSummary + | ExtensionAssociatedProfileSummary; const props = defineProps<{ title: string; @@ -16,20 +20,41 @@ const props = defineProps<{ browserId: string; browserFamilyId: string | null; isBookmark: boolean; + isExtension?: boolean; + selectedProfileIds?: string[]; + deleteBusy?: boolean; isOpeningProfile: (browserId: string, profileId: string) => boolean; }>(); const emit = defineEmits<{ close: []; openProfile: [browserId: string, profileId: string]; + toggleProfileSelection: [profileId: string]; + toggleAllProfileSelection: []; + deleteProfile: [profileId: string]; + deleteSelectedProfiles: []; }>(); const sortKey = ref("id"); const sortedProfiles = computed(() => sortAssociatedProfiles(props.profiles, sortKey.value)); +const selectedProfileIds = computed(() => props.selectedProfileIds ?? []); +const allSelected = computed( + () => + sortedProfiles.value.length > 0 && + sortedProfiles.value.every((profile) => selectedProfileIds.value.includes(profile.id)), +); function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedProfileSummary { return "bookmarkPath" in profile; } + +function hasInstallSource(profile: ModalProfile): profile is ExtensionAssociatedProfileSummary { + return "installSource" in profile; +} + +function isSelected(profileId: string) { + return selectedProfileIds.value.includes(profileId); +} diff --git a/src/components/browser-data/ExtensionRemovalModal.vue b/src/components/browser-data/ExtensionRemovalModal.vue new file mode 100644 index 0000000..ddd74de --- /dev/null +++ b/src/components/browser-data/ExtensionRemovalModal.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/src/components/browser-data/ExtensionsList.vue b/src/components/browser-data/ExtensionsList.vue index d4a62ee..24fe6b2 100644 --- a/src/components/browser-data/ExtensionsList.vue +++ b/src/components/browser-data/ExtensionsList.vue @@ -1,29 +1,89 @@