support login data

This commit is contained in:
Julian Freeman
2026-04-16 22:43:17 -04:00
parent a976dc3fc5
commit 9fe16cd334
11 changed files with 519 additions and 20 deletions

74
src-tauri/Cargo.lock generated
View File

@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -449,6 +461,7 @@ name = "chrom-tool"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -938,6 +951,18 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -1446,6 +1471,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1461,6 +1495,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" 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]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -1990,6 +2033,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -3033,6 +3087,20 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -4421,6 +4489,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"

View File

@@ -24,4 +24,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
tauri-plugin-dialog = "2.7.0" tauri-plugin-dialog = "2.7.0"
rusqlite = { version = "0.32", features = ["bundled"] }

View File

@@ -19,6 +19,7 @@ pub struct BrowserView {
pub profiles: Vec<ProfileSummary>, pub profiles: Vec<ProfileSummary>,
pub extensions: Vec<ExtensionSummary>, pub extensions: Vec<ExtensionSummary>,
pub bookmarks: Vec<BookmarkSummary>, pub bookmarks: Vec<BookmarkSummary>,
pub password_sites: Vec<PasswordSiteSummary>,
pub stats: BrowserStats, pub stats: BrowserStats,
} }
@@ -28,6 +29,7 @@ pub struct BrowserStats {
pub profile_count: usize, pub profile_count: usize,
pub extension_count: usize, pub extension_count: usize,
pub bookmark_count: usize, pub bookmark_count: usize,
pub password_site_count: usize,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -64,6 +66,15 @@ pub struct BookmarkSummary {
pub profiles: Vec<BookmarkAssociatedProfileSummary>, pub profiles: Vec<BookmarkAssociatedProfileSummary>,
} }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordSiteSummary {
pub url: String,
pub domain: String,
pub profile_ids: Vec<String>,
pub profiles: Vec<AssociatedProfileSummary>,
}
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AssociatedProfileSummary { pub struct AssociatedProfileSummary {
@@ -165,3 +176,10 @@ pub struct TempBookmark {
pub profile_ids: BTreeSet<String>, pub profile_ids: BTreeSet<String>,
pub profiles: BTreeMap<String, BookmarkAssociatedProfileSummary>, pub profiles: BTreeMap<String, BookmarkAssociatedProfileSummary>,
} }
pub struct TempPasswordSite {
pub url: String,
pub domain: String,
pub profile_ids: BTreeSet<String>,
pub profiles: BTreeMap<String, AssociatedProfileSummary>,
}

View File

@@ -3,6 +3,7 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use rusqlite::{Connection, OpenFlags};
use serde_json::Value; use serde_json::Value;
use tauri::AppHandle; use tauri::AppHandle;
@@ -10,10 +11,12 @@ use crate::{
config_store, config_store,
models::{ models::{
AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary, AssociatedProfileSummary, BookmarkAssociatedProfileSummary, BookmarkSummary,
BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, ProfileSummary, BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, PasswordSiteSummary,
ScanResponse, TempBookmark, TempExtension, 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<ScanResponse, String> { pub fn scan_browsers(app: &AppHandle) -> Result<ScanResponse, String> {
@@ -43,6 +46,7 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
let mut profiles = Vec::new(); let mut profiles = Vec::new();
let mut extensions = BTreeMap::<String, TempExtension>::new(); let mut extensions = BTreeMap::<String, TempExtension>::new();
let mut bookmarks = BTreeMap::<String, TempBookmark>::new(); let mut bookmarks = BTreeMap::<String, TempBookmark>::new();
let mut password_sites = BTreeMap::<String, TempPasswordSite>::new();
for profile_id in profile_ids { for profile_id in profile_ids {
let profile_path = root.join(&profile_id); let profile_path = root.join(&profile_id);
@@ -51,14 +55,11 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
} }
let profile_info = profile_cache.and_then(|cache| cache.get(&profile_id)); let profile_info = profile_cache.and_then(|cache| cache.get(&profile_id));
let profile_summary = build_profile_summary( let profile_summary =
&root, build_profile_summary(&root, &profile_path, &profile_id, profile_info);
&profile_path,
&profile_id,
profile_info,
);
scan_extensions_for_profile(&profile_path, &profile_summary, &mut extensions); scan_extensions_for_profile(&profile_path, &profile_summary, &mut extensions);
scan_bookmarks_for_profile(&profile_path, &profile_summary, &mut bookmarks); 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); profiles.push(profile_summary);
} }
@@ -83,6 +84,15 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
profiles: entry.profiles.into_values().collect(), profiles: entry.profiles.into_values().collect(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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::<Vec<_>>();
Some(BrowserView { Some(BrowserView {
browser_id: config.id, browser_id: config.id,
@@ -94,10 +104,12 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
profile_count: profiles.len(), profile_count: profiles.len(),
extension_count: extensions.len(), extension_count: extensions.len(),
bookmark_count: bookmarks.len(), bookmark_count: bookmarks.len(),
password_site_count: password_sites.len(),
}, },
profiles, profiles,
extensions: sort_extensions(extensions), extensions: sort_extensions(extensions),
bookmarks: sort_bookmarks(bookmarks), 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() { } else if candidate.is_absolute() {
(candidate, ExtensionInstallSource::ExternalAbsolute) (candidate, ExtensionInstallSource::ExternalAbsolute)
} else { } else {
(PathBuf::from(raw_path), ExtensionInstallSource::ExternalAbsolute) (
PathBuf::from(raw_path),
ExtensionInstallSource::ExternalAbsolute,
)
}; };
resolved.is_dir().then_some((resolved, source)) resolved.is_dir().then_some((resolved, source))
@@ -451,10 +466,8 @@ fn collect_bookmarks(
} else { } else {
ancestors.join(" > ") ancestors.join(" > ")
}; };
entry entry.profiles.entry(profile.id.clone()).or_insert_with(|| {
.profiles BookmarkAssociatedProfileSummary {
.entry(profile.id.clone())
.or_insert_with(|| BookmarkAssociatedProfileSummary {
id: profile.id.clone(), id: profile.id.clone(),
name: profile.name.clone(), name: profile.name.clone(),
avatar_data_url: profile.avatar_data_url.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, default_avatar_stroke_color: profile.default_avatar_stroke_color,
avatar_label: profile.avatar_label.clone(), avatar_label: profile.avatar_label.clone(),
bookmark_path, bookmark_path,
}); }
});
} }
Some("folder") => { Some("folder") => {
if let Some(children) = node.get("children").and_then(Value::as_array) { if let Some(children) = node.get("children").and_then(Value::as_array) {
@@ -526,3 +540,103 @@ fn sort_bookmarks(mut bookmarks: Vec<BookmarkSummary>) -> Vec<BookmarkSummary> {
}); });
bookmarks bookmarks
} }
fn scan_password_sites_for_profile(
profile_path: &Path,
profile: &ProfileSummary,
password_sites: &mut BTreeMap<String, TempPasswordSite>,
) {
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<String>>(0)?,
row.get::<_, Option<String>>(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<String> {
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<String> {
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<PasswordSiteSummary>) -> Vec<PasswordSiteSummary> {
password_sites.sort_by(|left, right| {
left.domain
.to_lowercase()
.cmp(&right.domain.to_lowercase())
.then_with(|| left.url.cmp(&right.url))
});
password_sites
}

View File

@@ -1,6 +1,8 @@
use std::{ use std::{
env, fs, env, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
process,
time::{SystemTime, UNIX_EPOCH},
}; };
use base64::{engine::general_purpose::STANDARD, Engine as _}; use base64::{engine::general_purpose::STANDARD, Engine as _};
@@ -46,3 +48,48 @@ pub fn first_non_empty<'a>(values: impl IntoIterator<Item = Option<&'a str>>) ->
.flatten() .flatten()
.find(|value| !value.trim().is_empty()) .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<TempSqliteCopy> {
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,
})
}

View File

@@ -30,6 +30,7 @@ const {
page, page,
pickExecutablePath, pickExecutablePath,
pickUserDataPath, pickUserDataPath,
passwordSiteSortKey,
profileSortKey, profileSortKey,
refreshAll, refreshAll,
savingConfig, savingConfig,
@@ -37,8 +38,10 @@ const {
selectedBrowserId, selectedBrowserId,
showBookmarkProfilesModal, showBookmarkProfilesModal,
showExtensionProfilesModal, showExtensionProfilesModal,
showPasswordSiteProfilesModal,
sortedBookmarks, sortedBookmarks,
sortedExtensions, sortedExtensions,
sortedPasswordSites,
sortedProfiles, sortedProfiles,
closeAssociatedProfilesModal, closeAssociatedProfilesModal,
} = useBrowserManager(); } = useBrowserManager();
@@ -102,9 +105,11 @@ const {
:profile-sort-key="profileSortKey" :profile-sort-key="profileSortKey"
:extension-sort-key="extensionSortKey" :extension-sort-key="extensionSortKey"
:bookmark-sort-key="bookmarkSortKey" :bookmark-sort-key="bookmarkSortKey"
:password-site-sort-key="passwordSiteSortKey"
:sorted-profiles="sortedProfiles" :sorted-profiles="sortedProfiles"
:sorted-extensions="sortedExtensions" :sorted-extensions="sortedExtensions"
:sorted-bookmarks="sortedBookmarks" :sorted-bookmarks="sortedBookmarks"
:sorted-password-sites="sortedPasswordSites"
:open-profile-error="openProfileError" :open-profile-error="openProfileError"
:section-count="sectionCount" :section-count="sectionCount"
:is-opening-profile="isOpeningProfile" :is-opening-profile="isOpeningProfile"
@@ -115,9 +120,11 @@ const {
@update:profile-sort-key="profileSortKey = $event" @update:profile-sort-key="profileSortKey = $event"
@update:extension-sort-key="extensionSortKey = $event" @update:extension-sort-key="extensionSortKey = $event"
@update:bookmark-sort-key="bookmarkSortKey = $event" @update:bookmark-sort-key="bookmarkSortKey = $event"
@update:password-site-sort-key="passwordSiteSortKey = $event"
@open-profile="(browserId, profileId) => openBrowserProfile(browserId, profileId)" @open-profile="(browserId, profileId) => openBrowserProfile(browserId, profileId)"
@show-extension-profiles="showExtensionProfilesModal" @show-extension-profiles="showExtensionProfilesModal"
@show-bookmark-profiles="showBookmarkProfilesModal" @show-bookmark-profiles="showBookmarkProfilesModal"
@show-password-site-profiles="showPasswordSiteProfilesModal"
@close-associated-profiles="closeAssociatedProfilesModal" @close-associated-profiles="closeAssociatedProfilesModal"
/> />

View File

@@ -6,11 +6,13 @@ import type {
BookmarkSortKey, BookmarkSortKey,
BrowserView, BrowserView,
ExtensionSortKey, ExtensionSortKey,
PasswordSiteSortKey,
ProfileSortKey, ProfileSortKey,
} from "../../types/browser"; } from "../../types/browser";
import AssociatedProfilesModal from "./AssociatedProfilesModal.vue"; import AssociatedProfilesModal from "./AssociatedProfilesModal.vue";
import BookmarksList from "./BookmarksList.vue"; import BookmarksList from "./BookmarksList.vue";
import ExtensionsList from "./ExtensionsList.vue"; import ExtensionsList from "./ExtensionsList.vue";
import PasswordSitesList from "./PasswordSitesList.vue";
import ProfilesList from "./ProfilesList.vue"; import ProfilesList from "./ProfilesList.vue";
defineProps<{ defineProps<{
@@ -19,9 +21,11 @@ defineProps<{
profileSortKey: ProfileSortKey; profileSortKey: ProfileSortKey;
extensionSortKey: ExtensionSortKey; extensionSortKey: ExtensionSortKey;
bookmarkSortKey: BookmarkSortKey; bookmarkSortKey: BookmarkSortKey;
passwordSiteSortKey: PasswordSiteSortKey;
sortedProfiles: BrowserView["profiles"]; sortedProfiles: BrowserView["profiles"];
sortedExtensions: BrowserView["extensions"]; sortedExtensions: BrowserView["extensions"];
sortedBookmarks: BrowserView["bookmarks"]; sortedBookmarks: BrowserView["bookmarks"];
sortedPasswordSites: BrowserView["passwordSites"];
openProfileError: string; openProfileError: string;
sectionCount: (section: ActiveSection) => number; sectionCount: (section: ActiveSection) => number;
isOpeningProfile: (browserId: string, profileId: string) => boolean; isOpeningProfile: (browserId: string, profileId: string) => boolean;
@@ -40,9 +44,11 @@ const emit = defineEmits<{
"update:profileSortKey": [value: ProfileSortKey]; "update:profileSortKey": [value: ProfileSortKey];
"update:extensionSortKey": [value: ExtensionSortKey]; "update:extensionSortKey": [value: ExtensionSortKey];
"update:bookmarkSortKey": [value: BookmarkSortKey]; "update:bookmarkSortKey": [value: BookmarkSortKey];
"update:passwordSiteSortKey": [value: PasswordSiteSortKey];
openProfile: [browserId: string, profileId: string]; openProfile: [browserId: string, profileId: string];
showExtensionProfiles: [extensionId: string]; showExtensionProfiles: [extensionId: string];
showBookmarkProfiles: [url: string]; showBookmarkProfiles: [url: string];
showPasswordSiteProfiles: [url: string];
closeAssociatedProfiles: []; closeAssociatedProfiles: [];
}>(); }>();
</script> </script>
@@ -76,6 +82,15 @@ const emit = defineEmits<{
<span>Bookmarks</span> <span>Bookmarks</span>
<span class="count-pill">{{ sectionCount("bookmarks") }}</span> <span class="count-pill">{{ sectionCount("bookmarks") }}</span>
</button> </button>
<button
class="section-tab"
:class="{ active: activeSection === 'passwords' }"
type="button"
@click="emit('update:activeSection', 'passwords')"
>
<span>Saved Logins</span>
<span class="count-pill">{{ sectionCount("passwords") }}</span>
</button>
</section> </section>
<div class="content-scroll-area"> <div class="content-scroll-area">
@@ -101,12 +116,20 @@ const emit = defineEmits<{
/> />
<BookmarksList <BookmarksList
v-else v-else-if="activeSection === 'bookmarks'"
:bookmarks="sortedBookmarks" :bookmarks="sortedBookmarks"
:sort-key="bookmarkSortKey" :sort-key="bookmarkSortKey"
@update:sort-key="emit('update:bookmarkSortKey', $event)" @update:sort-key="emit('update:bookmarkSortKey', $event)"
@show-profiles="emit('showBookmarkProfiles', $event)" @show-profiles="emit('showBookmarkProfiles', $event)"
/> />
<PasswordSitesList
v-else
:password-sites="sortedPasswordSites"
:sort-key="passwordSiteSortKey"
@update:sort-key="emit('update:passwordSiteSortKey', $event)"
@show-profiles="emit('showPasswordSiteProfiles', $event)"
/>
</div> </div>
<AssociatedProfilesModal <AssociatedProfilesModal

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { PasswordSiteSortKey, PasswordSiteSummary } from "../../types/browser";
defineProps<{
passwordSites: PasswordSiteSummary[];
sortKey: PasswordSiteSortKey;
}>();
const emit = defineEmits<{
"update:sortKey": [value: PasswordSiteSortKey];
showProfiles: [url: string];
}>();
</script>
<template>
<section class="table-section">
<div v-if="passwordSites.length" class="data-table">
<div class="data-table-header passwords-grid">
<button class="header-cell sortable" :class="{ active: sortKey === 'domain' }" type="button" @click="emit('update:sortKey', 'domain')">Domain</button>
<button class="header-cell sortable" :class="{ active: sortKey === 'url' }" type="button" @click="emit('update:sortKey', 'url')">URL</button>
<div class="header-cell actions-cell">Profiles</div>
</div>
<div class="data-table-body styled-scrollbar">
<article
v-for="passwordSite in passwordSites"
:key="passwordSite.url"
class="data-table-row passwords-grid"
>
<div class="row-cell primary-cell">
<strong>{{ passwordSite.domain }}</strong>
</div>
<div class="row-cell muted-cell" :title="passwordSite.url">{{ passwordSite.url }}</div>
<div class="row-cell actions-cell">
<button class="disclosure-button" type="button" @click="emit('showProfiles', passwordSite.url)">
<span>View</span>
<span class="badge neutral">{{ passwordSite.profileIds.length }}</span>
</button>
</div>
</article>
</div>
</div>
<div v-else class="empty-card">
<p>No saved login sites were discovered for this browser.</p>
</div>
</section>
</template>
<style scoped>
.table-section {
padding: 0;
height: 100%;
min-height: 0;
}
.data-table {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 22px;
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
}
.data-table-body {
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.passwords-grid {
display: grid;
grid-template-columns: minmax(180px, 0.9fr) minmax(280px, 1.2fr) 154px;
gap: 12px;
align-items: center;
}
.data-table-header {
position: sticky;
top: 0;
z-index: 2;
padding: 10px 24px 10px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.header-cell {
color: var(--muted);
font-size: 0.81rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.header-cell.sortable {
padding: 0;
text-align: left;
background: transparent;
cursor: pointer;
}
.header-cell.sortable.active {
color: var(--text);
}
.data-table-row {
padding: 12px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.data-table-row:last-child {
border-bottom: 0;
}
.data-table-row:hover {
background: rgba(248, 250, 252, 0.65);
}
.row-cell {
min-width: 0;
}
.primary-cell strong {
display: block;
font-size: 0.93rem;
line-height: 1.3;
}
.muted-cell {
color: var(--muted);
font-size: 0.87rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.disclosure-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: fit-content;
min-width: 120px;
padding: 7px 10px;
border-radius: 12px;
background: rgba(241, 245, 249, 0.9);
color: var(--badge-text);
cursor: pointer;
}
.actions-cell {
display: flex;
justify-content: flex-end;
}
@media (max-width: 900px) {
.passwords-grid {
grid-template-columns: minmax(160px, 0.9fr) minmax(200px, 1fr) 148px;
}
}
@media (max-width: 720px) {
.passwords-grid {
grid-template-columns: minmax(0, 1fr) 132px;
}
.passwords-grid > :nth-child(2) {
display: none;
}
}
</style>

View File

@@ -2,7 +2,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { sortBookmarks, sortExtensions, sortProfiles } from "../utils/sort"; import { sortBookmarks, sortExtensions, sortPasswordSites, sortProfiles } from "../utils/sort";
import type { import type {
ActiveSection, ActiveSection,
AppPage, AppPage,
@@ -14,6 +14,7 @@ import type {
BrowserView, BrowserView,
CreateCustomBrowserConfigInput, CreateCustomBrowserConfigInput,
ExtensionSortKey, ExtensionSortKey,
PasswordSiteSortKey,
ProfileSortKey, ProfileSortKey,
ScanResponse, ScanResponse,
} from "../types/browser"; } from "../types/browser";
@@ -47,6 +48,7 @@ export function useBrowserManager() {
const profileSortKey = ref<ProfileSortKey>("name"); const profileSortKey = ref<ProfileSortKey>("name");
const extensionSortKey = ref<ExtensionSortKey>("name"); const extensionSortKey = ref<ExtensionSortKey>("name");
const bookmarkSortKey = ref<BookmarkSortKey>("title"); const bookmarkSortKey = ref<BookmarkSortKey>("title");
const passwordSiteSortKey = ref<PasswordSiteSortKey>("domain");
const browsers = computed(() => response.value.browsers); const browsers = computed(() => response.value.browsers);
const currentBrowser = computed<BrowserView | null>( const currentBrowser = computed<BrowserView | null>(
@@ -65,6 +67,9 @@ export function useBrowserManager() {
const sortedBookmarks = computed(() => const sortedBookmarks = computed(() =>
sortBookmarks(currentBrowser.value?.bookmarks ?? [], bookmarkSortKey.value), sortBookmarks(currentBrowser.value?.bookmarks ?? [], bookmarkSortKey.value),
); );
const sortedPasswordSites = computed(() =>
sortPasswordSites(currentBrowser.value?.passwordSites ?? [], passwordSiteSortKey.value),
);
watch( watch(
browsers, browsers,
@@ -280,7 +285,8 @@ export function useBrowserManager() {
if (!currentBrowser.value) return 0; if (!currentBrowser.value) return 0;
if (section === "profiles") return currentBrowser.value.profiles.length; if (section === "profiles") return currentBrowser.value.profiles.length;
if (section === "extensions") return currentBrowser.value.extensions.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) { 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() { function closeAssociatedProfilesModal() {
associatedProfilesModal.value = null; associatedProfilesModal.value = null;
} }
@@ -339,6 +356,7 @@ export function useBrowserManager() {
page, page,
pickExecutablePath, pickExecutablePath,
pickUserDataPath, pickUserDataPath,
passwordSiteSortKey,
profileSortKey, profileSortKey,
refreshAll, refreshAll,
savingConfig, savingConfig,
@@ -346,8 +364,10 @@ export function useBrowserManager() {
selectedBrowserId, selectedBrowserId,
showBookmarkProfilesModal, showBookmarkProfilesModal,
showExtensionProfilesModal, showExtensionProfilesModal,
showPasswordSiteProfilesModal,
sortedBookmarks, sortedBookmarks,
sortedExtensions, sortedExtensions,
sortedPasswordSites,
sortedProfiles, sortedProfiles,
closeAssociatedProfilesModal, closeAssociatedProfilesModal,
}; };

View File

@@ -2,6 +2,7 @@ export type BrowserStats = {
profileCount: number; profileCount: number;
extensionCount: number; extensionCount: number;
bookmarkCount: number; bookmarkCount: number;
passwordSiteCount: number;
}; };
export type ProfileSummary = { export type ProfileSummary = {
@@ -32,6 +33,13 @@ export type BookmarkSummary = {
profiles: BookmarkAssociatedProfileSummary[]; profiles: BookmarkAssociatedProfileSummary[];
}; };
export type PasswordSiteSummary = {
url: string;
domain: string;
profileIds: string[];
profiles: AssociatedProfileSummary[];
};
export type AssociatedProfileSummary = { export type AssociatedProfileSummary = {
id: string; id: string;
name: string; name: string;
@@ -56,8 +64,9 @@ export type BookmarkAssociatedProfileSummary = {
export type ProfileSortKey = "name" | "email" | "id"; export type ProfileSortKey = "name" | "email" | "id";
export type ExtensionSortKey = "name" | "id"; export type ExtensionSortKey = "name" | "id";
export type BookmarkSortKey = "title" | "url"; export type BookmarkSortKey = "title" | "url";
export type PasswordSiteSortKey = "domain" | "url";
export type AssociatedProfileSortKey = "id" | "name"; 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 AppPage = "browserData" | "configuration";
export type BrowserConfigSource = "default" | "custom"; export type BrowserConfigSource = "default" | "custom";
@@ -92,6 +101,7 @@ export type BrowserView = {
profiles: ProfileSummary[]; profiles: ProfileSummary[];
extensions: ExtensionSummary[]; extensions: ExtensionSummary[];
bookmarks: BookmarkSummary[]; bookmarks: BookmarkSummary[];
passwordSites: PasswordSiteSummary[];
stats: BrowserStats; stats: BrowserStats;
}; };

View File

@@ -3,6 +3,8 @@ import type {
BookmarkSummary, BookmarkSummary,
ExtensionSortKey, ExtensionSortKey,
ExtensionSummary, ExtensionSummary,
PasswordSiteSortKey,
PasswordSiteSummary,
AssociatedProfileSortKey, AssociatedProfileSortKey,
AssociatedProfileSummary, AssociatedProfileSummary,
BookmarkAssociatedProfileSummary, 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( export function sortAssociatedProfiles(
items: (AssociatedProfileSummary | BookmarkAssociatedProfileSummary)[], items: (AssociatedProfileSummary | BookmarkAssociatedProfileSummary)[],
sortKey: AssociatedProfileSortKey, sortKey: AssociatedProfileSortKey,