From 9fe16cd334d5ebda1a810b5d33293960f4df87f5 Mon Sep 17 00:00:00 2001 From: Julian Freeman Date: Thu, 16 Apr 2026 22:43:17 -0400 Subject: [PATCH] support login data --- src-tauri/Cargo.lock | 74 ++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/src/models.rs | 18 ++ src-tauri/src/scanner.rs | 144 +++++++++++++-- src-tauri/src/utils.rs | 47 +++++ src/App.vue | 7 + .../browser-data/BrowserDataView.vue | 25 ++- .../browser-data/PasswordSitesList.vue | 174 ++++++++++++++++++ src/composables/useBrowserManager.ts | 24 ++- src/types/browser.ts | 12 +- src/utils/sort.ts | 12 ++ 11 files changed, 519 insertions(+), 20 deletions(-) create mode 100644 src/components/browser-data/PasswordSitesList.vue diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e3b4ceb..14c5d9c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -449,6 +461,7 @@ name = "chrom-tool" version = "0.1.0" dependencies = [ "base64 0.22.1", + "rusqlite", "serde", "serde_json", "tauri", @@ -938,6 +951,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1446,6 +1471,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1461,6 +1495,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1990,6 +2033,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3033,6 +3087,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4421,6 +4489,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 60b868f..2de7d79 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" tauri-plugin-dialog = "2.7.0" - +rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 8bd0db5..f2d95d1 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -19,6 +19,7 @@ pub struct BrowserView { pub profiles: Vec, pub extensions: Vec, pub bookmarks: Vec, + pub password_sites: Vec, pub stats: BrowserStats, } @@ -28,6 +29,7 @@ pub struct BrowserStats { pub profile_count: usize, pub extension_count: usize, pub bookmark_count: usize, + pub password_site_count: usize, } #[derive(Serialize)] @@ -64,6 +66,15 @@ pub struct BookmarkSummary { pub profiles: Vec, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordSiteSummary { + pub url: String, + pub domain: String, + pub profile_ids: Vec, + pub profiles: Vec, +} + #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct AssociatedProfileSummary { @@ -165,3 +176,10 @@ pub struct TempBookmark { pub profile_ids: BTreeSet, pub profiles: BTreeMap, } + +pub struct TempPasswordSite { + pub url: String, + pub domain: String, + pub profile_ids: BTreeSet, + pub profiles: BTreeMap, +} diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs index 0b74bbd..d528924 100644 --- a/src-tauri/src/scanner.rs +++ b/src-tauri/src/scanner.rs @@ -3,6 +3,7 @@ use std::{ path::{Path, PathBuf}, }; +use rusqlite::{Connection, OpenFlags}; use serde_json::Value; use tauri::AppHandle; @@ -10,10 +11,12 @@ use crate::{ config_store, models::{ AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary, - BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, ProfileSummary, - ScanResponse, TempBookmark, TempExtension, + BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, PasswordSiteSummary, + ProfileSummary, ScanResponse, TempBookmark, TempExtension, TempPasswordSite, + }, + utils::{ + copy_sqlite_database_to_temp, first_non_empty, load_image_as_data_url, read_json_file, }, - utils::{first_non_empty, load_image_as_data_url, read_json_file}, }; pub fn scan_browsers(app: &AppHandle) -> Result { @@ -43,6 +46,7 @@ fn scan_browser(config: BrowserConfigEntry) -> Option { let mut profiles = Vec::new(); let mut extensions = BTreeMap::::new(); let mut bookmarks = BTreeMap::::new(); + let mut password_sites = BTreeMap::::new(); for profile_id in profile_ids { let profile_path = root.join(&profile_id); @@ -51,14 +55,11 @@ fn scan_browser(config: BrowserConfigEntry) -> Option { } let profile_info = profile_cache.and_then(|cache| cache.get(&profile_id)); - let profile_summary = build_profile_summary( - &root, - &profile_path, - &profile_id, - profile_info, - ); + let profile_summary = + build_profile_summary(&root, &profile_path, &profile_id, profile_info); scan_extensions_for_profile(&profile_path, &profile_summary, &mut extensions); scan_bookmarks_for_profile(&profile_path, &profile_summary, &mut bookmarks); + scan_password_sites_for_profile(&profile_path, &profile_summary, &mut password_sites); profiles.push(profile_summary); } @@ -83,6 +84,15 @@ fn scan_browser(config: BrowserConfigEntry) -> Option { profiles: entry.profiles.into_values().collect(), }) .collect::>(); + let password_sites = password_sites + .into_values() + .map(|entry| PasswordSiteSummary { + url: entry.url, + domain: entry.domain, + profile_ids: entry.profile_ids.into_iter().collect(), + profiles: entry.profiles.into_values().collect(), + }) + .collect::>(); Some(BrowserView { browser_id: config.id, @@ -94,10 +104,12 @@ fn scan_browser(config: BrowserConfigEntry) -> Option { profile_count: profiles.len(), extension_count: extensions.len(), bookmark_count: bookmarks.len(), + password_site_count: password_sites.len(), }, profiles, extensions: sort_extensions(extensions), bookmarks: sort_bookmarks(bookmarks), + password_sites: sort_password_sites(password_sites), }) } @@ -291,7 +303,10 @@ fn resolve_extension_install_dir( } else if candidate.is_absolute() { (candidate, ExtensionInstallSource::ExternalAbsolute) } else { - (PathBuf::from(raw_path), ExtensionInstallSource::ExternalAbsolute) + ( + PathBuf::from(raw_path), + ExtensionInstallSource::ExternalAbsolute, + ) }; resolved.is_dir().then_some((resolved, source)) @@ -451,10 +466,8 @@ fn collect_bookmarks( } else { ancestors.join(" > ") }; - entry - .profiles - .entry(profile.id.clone()) - .or_insert_with(|| BookmarkAssociatedProfileSummary { + entry.profiles.entry(profile.id.clone()).or_insert_with(|| { + BookmarkAssociatedProfileSummary { id: profile.id.clone(), name: profile.name.clone(), avatar_data_url: profile.avatar_data_url.clone(), @@ -463,7 +476,8 @@ fn collect_bookmarks( default_avatar_stroke_color: profile.default_avatar_stroke_color, avatar_label: profile.avatar_label.clone(), bookmark_path, - }); + } + }); } Some("folder") => { if let Some(children) = node.get("children").and_then(Value::as_array) { @@ -526,3 +540,103 @@ fn sort_bookmarks(mut bookmarks: Vec) -> Vec { }); bookmarks } + +fn scan_password_sites_for_profile( + profile_path: &Path, + profile: &ProfileSummary, + password_sites: &mut BTreeMap, +) { + let login_data_path = profile_path.join("Login Data"); + if !login_data_path.is_file() { + return; + } + + let Some(temp_copy) = copy_sqlite_database_to_temp(&login_data_path) else { + return; + }; + let Ok(connection) = Connection::open_with_flags( + temp_copy.path(), + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) else { + return; + }; + + let Ok(mut statement) = connection + .prepare("SELECT origin_url, signon_realm FROM logins WHERE blacklisted_by_user = 0") + else { + return; + }; + let Ok(rows) = statement.query_map([], |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }) else { + return; + }; + + for row in rows.flatten() { + let Some(url) = normalize_login_site(row.0.as_deref(), row.1.as_deref()) else { + continue; + }; + let domain = domain_from_url(&url).unwrap_or_else(|| url.clone()); + + let entry = password_sites + .entry(url.clone()) + .or_insert_with(|| TempPasswordSite { + url: url.clone(), + domain: domain.clone(), + profile_ids: BTreeSet::new(), + profiles: BTreeMap::new(), + }); + + if entry.domain == entry.url && domain != entry.url { + entry.domain = domain.clone(); + } + entry.profile_ids.insert(profile.id.clone()); + entry + .profiles + .entry(profile.id.clone()) + .or_insert_with(|| AssociatedProfileSummary { + id: profile.id.clone(), + name: profile.name.clone(), + avatar_data_url: profile.avatar_data_url.clone(), + avatar_icon: profile.avatar_icon.clone(), + default_avatar_fill_color: profile.default_avatar_fill_color, + default_avatar_stroke_color: profile.default_avatar_stroke_color, + avatar_label: profile.avatar_label.clone(), + }); + } +} + +fn normalize_login_site(origin_url: Option<&str>, signon_realm: Option<&str>) -> Option { + let candidate = [signon_realm, origin_url] + .into_iter() + .flatten() + .map(str::trim) + .find(|value| { + !value.is_empty() && (value.starts_with("http://") || value.starts_with("https://")) + })?; + + Some(candidate.to_string()) +} + +fn domain_from_url(url: &str) -> Option { + let (_, remainder) = url.split_once("://")?; + let host = remainder.split('/').next()?.trim(); + if host.is_empty() { + return None; + } + + Some(host.to_string()) +} + +fn sort_password_sites(mut password_sites: Vec) -> Vec { + password_sites.sort_by(|left, right| { + left.domain + .to_lowercase() + .cmp(&right.domain.to_lowercase()) + .then_with(|| left.url.cmp(&right.url)) + }); + password_sites +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 291eea0..c538438 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,6 +1,8 @@ use std::{ env, fs, path::{Path, PathBuf}, + process, + time::{SystemTime, UNIX_EPOCH}, }; use base64::{engine::general_purpose::STANDARD, Engine as _}; @@ -46,3 +48,48 @@ pub fn first_non_empty<'a>(values: impl IntoIterator>) -> .flatten() .find(|value| !value.trim().is_empty()) } + +pub struct TempSqliteCopy { + path: PathBuf, + directory: PathBuf, +} + +impl TempSqliteCopy { + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempSqliteCopy { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } +} + +pub fn copy_sqlite_database_to_temp(path: &Path) -> Option { + let file_name = path.file_name()?.to_str()?; + let unique_id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_nanos(); + let directory = + env::temp_dir().join(format!("chrom-tool-scan-{}-{}", process::id(), unique_id)); + + fs::create_dir_all(&directory).ok()?; + + let main_target = directory.join(file_name); + fs::copy(path, &main_target).ok()?; + + for suffix in ["-wal", "-shm"] { + let source = PathBuf::from(format!("{}{}", path.display(), suffix)); + if source.is_file() { + let target = directory.join(format!("{file_name}{suffix}")); + let _ = fs::copy(source, target); + } + } + + Some(TempSqliteCopy { + path: main_target, + directory, + }) +} diff --git a/src/App.vue b/src/App.vue index c06fd8a..fd7ea70 100644 --- a/src/App.vue +++ b/src/App.vue @@ -30,6 +30,7 @@ const { page, pickExecutablePath, pickUserDataPath, + passwordSiteSortKey, profileSortKey, refreshAll, savingConfig, @@ -37,8 +38,10 @@ const { selectedBrowserId, showBookmarkProfilesModal, showExtensionProfilesModal, + showPasswordSiteProfilesModal, sortedBookmarks, sortedExtensions, + sortedPasswordSites, sortedProfiles, closeAssociatedProfilesModal, } = useBrowserManager(); @@ -102,9 +105,11 @@ const { :profile-sort-key="profileSortKey" :extension-sort-key="extensionSortKey" :bookmark-sort-key="bookmarkSortKey" + :password-site-sort-key="passwordSiteSortKey" :sorted-profiles="sortedProfiles" :sorted-extensions="sortedExtensions" :sorted-bookmarks="sortedBookmarks" + :sorted-password-sites="sortedPasswordSites" :open-profile-error="openProfileError" :section-count="sectionCount" :is-opening-profile="isOpeningProfile" @@ -115,9 +120,11 @@ const { @update:profile-sort-key="profileSortKey = $event" @update:extension-sort-key="extensionSortKey = $event" @update:bookmark-sort-key="bookmarkSortKey = $event" + @update:password-site-sort-key="passwordSiteSortKey = $event" @open-profile="(browserId, profileId) => openBrowserProfile(browserId, profileId)" @show-extension-profiles="showExtensionProfilesModal" @show-bookmark-profiles="showBookmarkProfilesModal" + @show-password-site-profiles="showPasswordSiteProfilesModal" @close-associated-profiles="closeAssociatedProfilesModal" /> diff --git a/src/components/browser-data/BrowserDataView.vue b/src/components/browser-data/BrowserDataView.vue index aa289dc..e2a4d66 100644 --- a/src/components/browser-data/BrowserDataView.vue +++ b/src/components/browser-data/BrowserDataView.vue @@ -6,11 +6,13 @@ import type { BookmarkSortKey, BrowserView, ExtensionSortKey, + PasswordSiteSortKey, ProfileSortKey, } from "../../types/browser"; import AssociatedProfilesModal from "./AssociatedProfilesModal.vue"; import BookmarksList from "./BookmarksList.vue"; import ExtensionsList from "./ExtensionsList.vue"; +import PasswordSitesList from "./PasswordSitesList.vue"; import ProfilesList from "./ProfilesList.vue"; defineProps<{ @@ -19,9 +21,11 @@ defineProps<{ profileSortKey: ProfileSortKey; extensionSortKey: ExtensionSortKey; bookmarkSortKey: BookmarkSortKey; + passwordSiteSortKey: PasswordSiteSortKey; sortedProfiles: BrowserView["profiles"]; sortedExtensions: BrowserView["extensions"]; sortedBookmarks: BrowserView["bookmarks"]; + sortedPasswordSites: BrowserView["passwordSites"]; openProfileError: string; sectionCount: (section: ActiveSection) => number; isOpeningProfile: (browserId: string, profileId: string) => boolean; @@ -40,9 +44,11 @@ const emit = defineEmits<{ "update:profileSortKey": [value: ProfileSortKey]; "update:extensionSortKey": [value: ExtensionSortKey]; "update:bookmarkSortKey": [value: BookmarkSortKey]; + "update:passwordSiteSortKey": [value: PasswordSiteSortKey]; openProfile: [browserId: string, profileId: string]; showExtensionProfiles: [extensionId: string]; showBookmarkProfiles: [url: string]; + showPasswordSiteProfiles: [url: string]; closeAssociatedProfiles: []; }>(); @@ -76,6 +82,15 @@ const emit = defineEmits<{ Bookmarks {{ sectionCount("bookmarks") }} +
@@ -101,12 +116,20 @@ const emit = defineEmits<{ /> + +
+import type { PasswordSiteSortKey, PasswordSiteSummary } from "../../types/browser"; + +defineProps<{ + passwordSites: PasswordSiteSummary[]; + sortKey: PasswordSiteSortKey; +}>(); + +const emit = defineEmits<{ + "update:sortKey": [value: PasswordSiteSortKey]; + showProfiles: [url: string]; +}>(); + + + + + diff --git a/src/composables/useBrowserManager.ts b/src/composables/useBrowserManager.ts index cf60b57..f483160 100644 --- a/src/composables/useBrowserManager.ts +++ b/src/composables/useBrowserManager.ts @@ -2,7 +2,7 @@ import { computed, onMounted, ref, watch } from "vue"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; -import { sortBookmarks, sortExtensions, sortProfiles } from "../utils/sort"; +import { sortBookmarks, sortExtensions, sortPasswordSites, sortProfiles } from "../utils/sort"; import type { ActiveSection, AppPage, @@ -14,6 +14,7 @@ import type { BrowserView, CreateCustomBrowserConfigInput, ExtensionSortKey, + PasswordSiteSortKey, ProfileSortKey, ScanResponse, } from "../types/browser"; @@ -47,6 +48,7 @@ export function useBrowserManager() { const profileSortKey = ref("name"); const extensionSortKey = ref("name"); const bookmarkSortKey = ref("title"); + const passwordSiteSortKey = ref("domain"); const browsers = computed(() => response.value.browsers); const currentBrowser = computed( @@ -65,6 +67,9 @@ export function useBrowserManager() { const sortedBookmarks = computed(() => sortBookmarks(currentBrowser.value?.bookmarks ?? [], bookmarkSortKey.value), ); + const sortedPasswordSites = computed(() => + sortPasswordSites(currentBrowser.value?.passwordSites ?? [], passwordSiteSortKey.value), + ); watch( browsers, @@ -280,7 +285,8 @@ export function useBrowserManager() { if (!currentBrowser.value) return 0; if (section === "profiles") return currentBrowser.value.profiles.length; if (section === "extensions") return currentBrowser.value.extensions.length; - return currentBrowser.value.bookmarks.length; + if (section === "bookmarks") return currentBrowser.value.bookmarks.length; + return currentBrowser.value.passwordSites.length; } function showExtensionProfilesModal(extensionId: string) { @@ -305,6 +311,17 @@ export function useBrowserManager() { }; } + function showPasswordSiteProfilesModal(url: string) { + const passwordSite = currentBrowser.value?.passwordSites.find((item) => item.url === url); + if (!passwordSite || !currentBrowser.value) return; + associatedProfilesModal.value = { + title: `${passwordSite.domain} Profiles`, + browserId: currentBrowser.value.browserId, + profiles: passwordSite.profiles, + isBookmark: false, + }; + } + function closeAssociatedProfilesModal() { associatedProfilesModal.value = null; } @@ -339,6 +356,7 @@ export function useBrowserManager() { page, pickExecutablePath, pickUserDataPath, + passwordSiteSortKey, profileSortKey, refreshAll, savingConfig, @@ -346,8 +364,10 @@ export function useBrowserManager() { selectedBrowserId, showBookmarkProfilesModal, showExtensionProfilesModal, + showPasswordSiteProfilesModal, sortedBookmarks, sortedExtensions, + sortedPasswordSites, sortedProfiles, closeAssociatedProfilesModal, }; diff --git a/src/types/browser.ts b/src/types/browser.ts index 19cd478..555857f 100644 --- a/src/types/browser.ts +++ b/src/types/browser.ts @@ -2,6 +2,7 @@ export type BrowserStats = { profileCount: number; extensionCount: number; bookmarkCount: number; + passwordSiteCount: number; }; export type ProfileSummary = { @@ -32,6 +33,13 @@ export type BookmarkSummary = { profiles: BookmarkAssociatedProfileSummary[]; }; +export type PasswordSiteSummary = { + url: string; + domain: string; + profileIds: string[]; + profiles: AssociatedProfileSummary[]; +}; + export type AssociatedProfileSummary = { id: string; name: string; @@ -56,8 +64,9 @@ export type BookmarkAssociatedProfileSummary = { export type ProfileSortKey = "name" | "email" | "id"; export type ExtensionSortKey = "name" | "id"; export type BookmarkSortKey = "title" | "url"; +export type PasswordSiteSortKey = "domain" | "url"; export type AssociatedProfileSortKey = "id" | "name"; -export type ActiveSection = "profiles" | "extensions" | "bookmarks"; +export type ActiveSection = "profiles" | "extensions" | "bookmarks" | "passwords"; export type AppPage = "browserData" | "configuration"; export type BrowserConfigSource = "default" | "custom"; @@ -92,6 +101,7 @@ export type BrowserView = { profiles: ProfileSummary[]; extensions: ExtensionSummary[]; bookmarks: BookmarkSummary[]; + passwordSites: PasswordSiteSummary[]; stats: BrowserStats; }; diff --git a/src/utils/sort.ts b/src/utils/sort.ts index 21d1faf..ea52846 100644 --- a/src/utils/sort.ts +++ b/src/utils/sort.ts @@ -3,6 +3,8 @@ import type { BookmarkSummary, ExtensionSortKey, ExtensionSummary, + PasswordSiteSortKey, + PasswordSiteSummary, AssociatedProfileSortKey, AssociatedProfileSummary, BookmarkAssociatedProfileSummary, @@ -76,6 +78,16 @@ export function sortBookmarks(items: BookmarkSummary[], sortKey: BookmarkSortKey }); } +export function sortPasswordSites(items: PasswordSiteSummary[], sortKey: PasswordSiteSortKey) { + const passwordSites = [...items]; + return passwordSites.sort((left, right) => { + if (sortKey === "url") { + return compareOptionalText(left.url, right.url) || compareText(left.domain, right.domain); + } + return compareOptionalText(left.domain, right.domain) || compareText(left.url, right.url); + }); +} + export function sortAssociatedProfiles( items: (AssociatedProfileSummary | BookmarkAssociatedProfileSummary)[], sortKey: AssociatedProfileSortKey,