Compare commits
10 Commits
e041523dbd
...
189d7c02f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
189d7c02f5 | ||
|
|
83f762435b | ||
|
|
ca649f700f | ||
|
|
81e16a184f | ||
|
|
d2ffdf2954 | ||
|
|
40a49da672 | ||
|
|
5d5d9c3c52 | ||
|
|
309e2219f5 | ||
|
|
16eb25d552 | ||
|
|
c8e1641896 |
@@ -73,6 +73,35 @@ pub fn browser_definitions() -> Vec<BrowserDefinition> {
|
||||
]),
|
||||
],
|
||||
},
|
||||
BrowserDefinition {
|
||||
id: "vivaldi",
|
||||
name: "Vivaldi",
|
||||
local_app_data_segments: &["Vivaldi", "User Data"],
|
||||
executable_candidates: &[
|
||||
ExecutableCandidate::LocalAppData(&["Vivaldi", "Application", "vivaldi.exe"]),
|
||||
ExecutableCandidate::ProgramFiles(&["Vivaldi", "Application", "vivaldi.exe"]),
|
||||
],
|
||||
},
|
||||
BrowserDefinition {
|
||||
id: "yandex",
|
||||
name: "Yandex Browser",
|
||||
local_app_data_segments: &["Yandex", "YandexBrowser", "User Data"],
|
||||
executable_candidates: &[ExecutableCandidate::LocalAppData(&[
|
||||
"Yandex",
|
||||
"YandexBrowser",
|
||||
"Application",
|
||||
"browser.exe",
|
||||
])],
|
||||
},
|
||||
BrowserDefinition {
|
||||
id: "chromium",
|
||||
name: "Chromium",
|
||||
local_app_data_segments: &["Chromium", "User Data"],
|
||||
executable_candidates: &[
|
||||
ExecutableCandidate::LocalAppData(&["Chromium", "Application", "chrome.exe"]),
|
||||
ExecutableCandidate::ProgramFiles(&["Chromium", "Application", "chrome.exe"]),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,9 @@ fn infer_browser_family_id(icon_key: Option<&str>) -> Option<String> {
|
||||
Some("chrome") => Some("chrome".to_string()),
|
||||
Some("edge") => Some("edge".to_string()),
|
||||
Some("brave") => Some("brave".to_string()),
|
||||
Some("vivaldi") => Some("vivaldi".to_string()),
|
||||
Some("yandex") => Some("yandex".to_string()),
|
||||
Some("chromium") => Some("chromium".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
@@ -14,7 +13,7 @@ use crate::{
|
||||
BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, ProfileSummary,
|
||||
ScanResponse, TempBookmark, TempExtension,
|
||||
},
|
||||
utils::{first_non_empty, load_image_as_data_url, pick_latest_subdirectory, read_json_file},
|
||||
utils::{first_non_empty, load_image_as_data_url, read_json_file},
|
||||
};
|
||||
|
||||
pub fn scan_browsers(app: &AppHandle) -> Result<ScanResponse, String> {
|
||||
@@ -39,13 +38,7 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
|
||||
.and_then(|value| value.get("info_cache"))
|
||||
.and_then(Value::as_object);
|
||||
|
||||
let mut profile_ids = BTreeSet::new();
|
||||
collect_profile_ids_from_fs(&root, &mut profile_ids);
|
||||
if let Some(cache) = profile_cache {
|
||||
for profile_id in cache.keys() {
|
||||
profile_ids.insert(profile_id.to_string());
|
||||
}
|
||||
}
|
||||
let profile_ids = collect_profile_ids_from_local_state(profile_cache);
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
let mut extensions = BTreeMap::<String, TempExtension>::new();
|
||||
@@ -108,23 +101,12 @@ fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_profile_ids_from_fs(root: &Path, profile_ids: &mut BTreeSet<String>) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name == "Default" || name.starts_with("Profile ") {
|
||||
profile_ids.insert(name);
|
||||
}
|
||||
}
|
||||
fn collect_profile_ids_from_local_state(
|
||||
profile_cache: Option<&serde_json::Map<String, Value>>,
|
||||
) -> BTreeSet<String> {
|
||||
profile_cache
|
||||
.map(|cache| cache.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_profile_summary(
|
||||
@@ -209,37 +191,48 @@ fn scan_extensions_for_profile(
|
||||
profile: &ProfileSummary,
|
||||
extensions: &mut BTreeMap<String, TempExtension>,
|
||||
) {
|
||||
let extensions_root = profile_path.join("Extensions");
|
||||
let Ok(entries) = fs::read_dir(&extensions_root) else {
|
||||
let secure_preferences_path = profile_path.join("Secure Preferences");
|
||||
let Some(secure_preferences) = read_json_file(&secure_preferences_path) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(extension_settings) = secure_preferences
|
||||
.get("extensions")
|
||||
.and_then(|value| value.get("settings"))
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let extension_id = entry.file_name().to_string_lossy().to_string();
|
||||
let Some(version_path) = pick_latest_subdirectory(&entry.path()) else {
|
||||
for (extension_id, extension_value) in extension_settings {
|
||||
let Some((install_dir, install_source)) =
|
||||
resolve_extension_install_dir(profile_path, extension_id, extension_value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let manifest_path = version_path.join("manifest.json");
|
||||
let Some(manifest) = read_json_file(&manifest_path) else {
|
||||
let external_manifest = match install_source {
|
||||
ExtensionInstallSource::ExternalAbsolute => {
|
||||
read_json_file(&install_dir.join("manifest.json"))
|
||||
}
|
||||
ExtensionInstallSource::StoreRelative => None,
|
||||
};
|
||||
let manifest = match install_source {
|
||||
ExtensionInstallSource::StoreRelative => extension_value.get("manifest"),
|
||||
ExtensionInstallSource::ExternalAbsolute => external_manifest.as_ref(),
|
||||
};
|
||||
let Some(manifest) = manifest else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let name = resolve_extension_name(&manifest, &version_path)
|
||||
let name = resolve_extension_name(manifest, &install_dir)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| extension_id.clone());
|
||||
let version = manifest
|
||||
.get("version")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let icon_data_url = resolve_extension_icon(&manifest, &version_path);
|
||||
let icon_data_url = resolve_extension_icon(manifest, &install_dir);
|
||||
|
||||
let entry = extensions
|
||||
.entry(extension_id.clone())
|
||||
@@ -252,7 +245,7 @@ fn scan_extensions_for_profile(
|
||||
profiles: BTreeMap::new(),
|
||||
});
|
||||
|
||||
if entry.name == entry.id && name != extension_id {
|
||||
if entry.name == entry.id && name != *extension_id {
|
||||
entry.name = name.clone();
|
||||
}
|
||||
if entry.version.is_none() {
|
||||
@@ -277,6 +270,38 @@ fn scan_extensions_for_profile(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_extension_install_dir(
|
||||
profile_path: &Path,
|
||||
extension_id: &str,
|
||||
extension_value: &Value,
|
||||
) -> Option<(PathBuf, ExtensionInstallSource)> {
|
||||
let raw_path = extension_value
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
|
||||
let normalized_path = raw_path.trim_start_matches('/');
|
||||
let candidate = PathBuf::from(normalized_path);
|
||||
let (resolved, source) = if normalized_path.starts_with(extension_id) {
|
||||
(
|
||||
profile_path.join("Extensions").join(candidate),
|
||||
ExtensionInstallSource::StoreRelative,
|
||||
)
|
||||
} else if candidate.is_absolute() {
|
||||
(candidate, ExtensionInstallSource::ExternalAbsolute)
|
||||
} else {
|
||||
(PathBuf::from(raw_path), ExtensionInstallSource::ExternalAbsolute)
|
||||
};
|
||||
|
||||
resolved.is_dir().then_some((resolved, source))
|
||||
}
|
||||
|
||||
enum ExtensionInstallSource {
|
||||
StoreRelative,
|
||||
ExternalAbsolute,
|
||||
}
|
||||
|
||||
fn resolve_extension_name(manifest: &Value, version_path: &Path) -> Option<String> {
|
||||
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)
|
||||
@@ -350,9 +375,10 @@ fn resolve_extension_icon(manifest: &Value, version_path: &Path) -> Option<Strin
|
||||
}
|
||||
|
||||
candidates.sort_by(|left, right| right.0.cmp(&left.0));
|
||||
candidates
|
||||
.into_iter()
|
||||
.find_map(|(_, relative_path)| load_image_as_data_url(&version_path.join(relative_path)))
|
||||
candidates.into_iter().find_map(|(_, relative_path)| {
|
||||
let normalized_path = relative_path.trim_start_matches('/');
|
||||
load_image_as_data_url(&version_path.join(normalized_path))
|
||||
})
|
||||
}
|
||||
|
||||
fn icon_candidates_from_object(map: &serde_json::Map<String, Value>) -> Vec<(u32, String)> {
|
||||
|
||||
@@ -14,29 +14,6 @@ pub fn local_app_data_dir() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pick_latest_subdirectory(root: &Path) -> Option<PathBuf> {
|
||||
let entries = fs::read_dir(root).ok()?;
|
||||
let mut candidates = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let file_type = entry.file_type().ok()?;
|
||||
if !file_type.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let metadata = entry.metadata().ok()?;
|
||||
let modified = metadata.modified().ok();
|
||||
Some((
|
||||
modified,
|
||||
entry.file_name().to_string_lossy().to_string(),
|
||||
entry.path(),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
|
||||
candidates.into_iter().next().map(|(_, _, path)| path)
|
||||
}
|
||||
|
||||
pub fn load_image_as_data_url(path: &Path) -> Option<String> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let extension = path
|
||||
|
||||
BIN
src/assets/chromium.png
Normal file
BIN
src/assets/chromium.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
src/assets/vivaldi.png
Normal file
BIN
src/assets/vivaldi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/yandex.png
Normal file
BIN
src/assets/yandex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -50,8 +50,7 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
|
||||
<div v-if="isBookmark" class="header-cell">Bookmark Path</div>
|
||||
<div class="header-cell actions-cell">Action</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-list">
|
||||
<div class="modal-table-body styled-scrollbar">
|
||||
<article
|
||||
v-for="profile in sortedProfiles"
|
||||
:key="profile.id"
|
||||
@@ -159,7 +158,14 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
|
||||
.modal-table-header {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
background: rgba(248, 250, 252, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.modal-table-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
@@ -180,13 +186,6 @@ function hasBookmarkPath(profile: ModalProfile): profile is BookmarkAssociatedPr
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-table-row {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
|
||||
@@ -20,19 +20,20 @@ const emit = defineEmits<{
|
||||
<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>
|
||||
|
||||
<article v-for="bookmark in bookmarks" :key="bookmark.url" class="data-table-row bookmarks-grid">
|
||||
<div class="row-cell primary-cell">
|
||||
<strong>{{ bookmark.title }}</strong>
|
||||
</div>
|
||||
<div class="row-cell muted-cell" :title="bookmark.url">{{ bookmark.url }}</div>
|
||||
<div class="row-cell actions-cell">
|
||||
<button class="disclosure-button" type="button" @click="emit('showProfiles', bookmark.url)">
|
||||
<span>View</span>
|
||||
<span class="badge neutral">{{ bookmark.profileIds.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<div class="data-table-body styled-scrollbar">
|
||||
<article v-for="bookmark in bookmarks" :key="bookmark.url" class="data-table-row bookmarks-grid">
|
||||
<div class="row-cell primary-cell">
|
||||
<strong>{{ bookmark.title }}</strong>
|
||||
</div>
|
||||
<div class="row-cell muted-cell" :title="bookmark.url">{{ bookmark.url }}</div>
|
||||
<div class="row-cell actions-cell">
|
||||
<button class="disclosure-button" type="button" @click="emit('showProfiles', bookmark.url)">
|
||||
<span>View</span>
|
||||
<span class="badge neutral">{{ bookmark.profileIds.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-card">
|
||||
<p>No bookmarks were discovered for this browser.</p>
|
||||
@@ -43,17 +44,25 @@ const emit = defineEmits<{
|
||||
<style scoped>
|
||||
.table-section {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
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: clip;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookmarks-grid {
|
||||
|
||||
@@ -163,4 +163,13 @@ const emit = defineEmits<{
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(232, 240, 255, 0.92));
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.content-scroll-area {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.content-scroll-area > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,30 +20,26 @@ const emit = defineEmits<{
|
||||
<div class="header-cell icon-cell">Icon</div>
|
||||
<button class="header-cell sortable" :class="{ active: sortKey === 'name' }" type="button" @click="emit('update:sortKey', 'name')">Name</button>
|
||||
<button class="header-cell sortable" :class="{ active: sortKey === 'id' }" type="button" @click="emit('update:sortKey', 'id')">Extension ID</button>
|
||||
<div class="header-cell">Version</div>
|
||||
<div class="header-cell actions-cell">Profiles</div>
|
||||
</div>
|
||||
|
||||
<article v-for="extension in extensions" :key="extension.id" class="data-table-row extensions-grid">
|
||||
<div class="extension-icon table-icon" :class="{ filled: Boolean(extension.iconDataUrl) }">
|
||||
<img v-if="extension.iconDataUrl" :src="extension.iconDataUrl" :alt="`${extension.name} icon`" />
|
||||
<span v-else>{{ extensionMonogram(extension.name) }}</span>
|
||||
</div>
|
||||
<div class="row-cell primary-cell">
|
||||
<strong>{{ extension.name }}</strong>
|
||||
</div>
|
||||
<div class="row-cell muted-cell" :title="extension.id">{{ extension.id }}</div>
|
||||
<div class="row-cell">
|
||||
<span v-if="extension.version" class="badge neutral">v{{ extension.version }}</span>
|
||||
<span v-else class="muted-cell">-</span>
|
||||
</div>
|
||||
<div class="row-cell actions-cell">
|
||||
<button class="disclosure-button" type="button" @click="emit('showProfiles', extension.id)">
|
||||
<span>View</span>
|
||||
<span class="badge neutral">{{ extension.profileIds.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<div class="data-table-body styled-scrollbar">
|
||||
<article v-for="extension in extensions" :key="extension.id" class="data-table-row extensions-grid">
|
||||
<div class="extension-icon table-icon" :class="{ filled: Boolean(extension.iconDataUrl) }">
|
||||
<img v-if="extension.iconDataUrl" :src="extension.iconDataUrl" :alt="`${extension.name} icon`" />
|
||||
<span v-else>{{ extensionMonogram(extension.name) }}</span>
|
||||
</div>
|
||||
<div class="row-cell primary-cell">
|
||||
<strong>{{ extension.name }}</strong>
|
||||
</div>
|
||||
<div class="row-cell muted-cell" :title="extension.id">{{ extension.id }}</div>
|
||||
<div class="row-cell actions-cell">
|
||||
<button class="disclosure-button" type="button" @click="emit('showProfiles', extension.id)">
|
||||
<span>View</span>
|
||||
<span class="badge neutral">{{ extension.profileIds.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-card">
|
||||
<p>No extensions were discovered for this browser.</p>
|
||||
@@ -54,22 +50,30 @@ const emit = defineEmits<{
|
||||
<style scoped>
|
||||
.table-section {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
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: clip;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.extensions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 60px minmax(180px, 1.1fr) minmax(220px, 1fr) 108px 154px;
|
||||
grid-template-columns: 60px minmax(180px, 1.1fr) minmax(220px, 1fr) 154px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -185,7 +189,7 @@ const emit = defineEmits<{
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.extensions-grid {
|
||||
grid-template-columns: 56px minmax(160px, 1fr) minmax(160px, 1fr) 96px 148px;
|
||||
grid-template-columns: 56px minmax(160px, 1fr) minmax(160px, 1fr) 148px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,8 +198,7 @@ const emit = defineEmits<{
|
||||
grid-template-columns: 56px minmax(0, 1fr) 132px;
|
||||
}
|
||||
|
||||
.extensions-grid > :nth-child(3),
|
||||
.extensions-grid > :nth-child(4) {
|
||||
.extensions-grid > :nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,36 +31,37 @@ const emit = defineEmits<{
|
||||
<button class="header-cell sortable" :class="{ active: sortKey === 'id' }" type="button" @click="emit('update:sortKey', 'id')">Profile ID</button>
|
||||
<div class="header-cell actions-cell">Action</div>
|
||||
</div>
|
||||
|
||||
<article v-for="profile in profiles" :key="profile.id" class="data-table-row profiles-grid">
|
||||
<div class="profile-avatar table-avatar">
|
||||
<img
|
||||
v-if="profileAvatarSrc(profile, browserFamilyId)"
|
||||
:src="profileAvatarSrc(profile, browserFamilyId) ?? undefined"
|
||||
:alt="`${profile.name} avatar`"
|
||||
/>
|
||||
<span v-else>{{ profile.avatarLabel }}</span>
|
||||
</div>
|
||||
<div class="row-cell primary-cell">
|
||||
<strong>{{ profile.name }}</strong>
|
||||
</div>
|
||||
<div class="row-cell muted-cell" :title="profile.email ?? undefined">
|
||||
{{ profile.email || "" }}
|
||||
</div>
|
||||
<div class="row-cell">
|
||||
<span class="badge neutral">{{ profile.id }}</span>
|
||||
</div>
|
||||
<div class="row-cell actions-cell">
|
||||
<button
|
||||
class="card-action-button"
|
||||
:disabled="isOpeningProfile(browserId, profile.id)"
|
||||
type="button"
|
||||
@click="emit('openProfile', browserId, profile.id)"
|
||||
>
|
||||
{{ isOpeningProfile(browserId, profile.id) ? "Opening..." : "Open" }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<div class="data-table-body styled-scrollbar">
|
||||
<article v-for="profile in profiles" :key="profile.id" class="data-table-row profiles-grid">
|
||||
<div class="profile-avatar table-avatar">
|
||||
<img
|
||||
v-if="profileAvatarSrc(profile, browserFamilyId)"
|
||||
:src="profileAvatarSrc(profile, browserFamilyId) ?? undefined"
|
||||
:alt="`${profile.name} avatar`"
|
||||
/>
|
||||
<span v-else>{{ profile.avatarLabel }}</span>
|
||||
</div>
|
||||
<div class="row-cell primary-cell">
|
||||
<strong>{{ profile.name }}</strong>
|
||||
</div>
|
||||
<div class="row-cell muted-cell" :title="profile.email ?? undefined">
|
||||
{{ profile.email || "" }}
|
||||
</div>
|
||||
<div class="row-cell">
|
||||
<span class="badge neutral">{{ profile.id }}</span>
|
||||
</div>
|
||||
<div class="row-cell actions-cell">
|
||||
<button
|
||||
class="card-action-button"
|
||||
:disabled="isOpeningProfile(browserId, profile.id)"
|
||||
type="button"
|
||||
@click="emit('openProfile', browserId, profile.id)"
|
||||
>
|
||||
{{ isOpeningProfile(browserId, profile.id) ? "Opening..." : "Open" }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-card">
|
||||
<p>No profile directories were found for this browser.</p>
|
||||
@@ -71,17 +72,25 @@ const emit = defineEmits<{
|
||||
<style scoped>
|
||||
.table-section {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
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: clip;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.profiles-grid {
|
||||
|
||||
@@ -37,139 +37,146 @@ const iconOptions = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="content-scroll-area">
|
||||
<section class="content-section">
|
||||
<div v-if="configError" class="inline-error">
|
||||
{{ configError }}
|
||||
</div>
|
||||
<section class="config-page styled-scrollbar">
|
||||
<div v-if="configError" class="inline-error">
|
||||
{{ configError }}
|
||||
</div>
|
||||
|
||||
<div class="config-form-card">
|
||||
<div class="config-form-header collapsible">
|
||||
<div>
|
||||
<h3>Add Custom Browser</h3>
|
||||
<p>Add a custom executable and Chromium user data path when needed.</p>
|
||||
<div class="config-form-card">
|
||||
<div class="config-form-header collapsible">
|
||||
<div>
|
||||
<h3>Add Custom Browser</h3>
|
||||
<p>Add a custom executable and Chromium user data path when needed.</p>
|
||||
</div>
|
||||
<button
|
||||
class="secondary-button config-toggle-button"
|
||||
type="button"
|
||||
@click="formExpanded = !formExpanded"
|
||||
>
|
||||
{{ formExpanded ? "Collapse" : "Expand" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="formExpanded" class="config-form-fields compact">
|
||||
<div class="config-inline-row">
|
||||
<label class="field-group">
|
||||
<span>Name</span>
|
||||
<input
|
||||
:value="createConfigForm.name"
|
||||
placeholder="Work Chrome"
|
||||
@input="emit('updateName', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-group">
|
||||
<span>Icon</span>
|
||||
<SortDropdown
|
||||
:model-value="createConfigForm.iconKey ?? 'chrome'"
|
||||
:options="iconOptions"
|
||||
@update:model-value="emit('updateIconKey', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field-group">
|
||||
<span>Executable Path</span>
|
||||
<div class="path-input-row">
|
||||
<input
|
||||
:value="createConfigForm.executablePath"
|
||||
placeholder="C:\Program Files\...\chrome.exe"
|
||||
@input="emit('updateExecutablePath', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button class="secondary-button" type="button" @click="emit('pickExecutablePath')">
|
||||
Browse File
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field-group">
|
||||
<span>User Data Path</span>
|
||||
<div class="path-input-row">
|
||||
<input
|
||||
:value="createConfigForm.userDataPath"
|
||||
placeholder="C:\Users\...\User Data"
|
||||
@input="emit('updateUserDataPath', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button class="secondary-button" type="button" @click="emit('pickUserDataPath')">
|
||||
Browse Folder
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="config-form-actions">
|
||||
<button
|
||||
class="secondary-button config-toggle-button"
|
||||
class="primary-button"
|
||||
type="button"
|
||||
@click="formExpanded = !formExpanded"
|
||||
:disabled="savingConfig"
|
||||
@click="emit('createConfig')"
|
||||
>
|
||||
{{ formExpanded ? "Collapse" : "Expand" }}
|
||||
{{ savingConfig ? "Saving..." : "Add Config" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="formExpanded" class="config-form-fields compact">
|
||||
<div class="config-inline-row">
|
||||
<label class="field-group">
|
||||
<span>Name</span>
|
||||
<input
|
||||
:value="createConfigForm.name"
|
||||
placeholder="Work Chrome"
|
||||
@input="emit('updateName', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<label class="field-group">
|
||||
<span>Icon</span>
|
||||
<SortDropdown
|
||||
:model-value="createConfigForm.iconKey ?? 'chrome'"
|
||||
:options="iconOptions"
|
||||
@update:model-value="emit('updateIconKey', $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field-group">
|
||||
<span>Executable Path</span>
|
||||
<div class="path-input-row">
|
||||
<input
|
||||
:value="createConfigForm.executablePath"
|
||||
placeholder="C:\Program Files\...\chrome.exe"
|
||||
@input="emit('updateExecutablePath', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button class="secondary-button" type="button" @click="emit('pickExecutablePath')">
|
||||
Browse File
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field-group">
|
||||
<span>User Data Path</span>
|
||||
<div class="path-input-row">
|
||||
<input
|
||||
:value="createConfigForm.userDataPath"
|
||||
placeholder="C:\Users\...\User Data"
|
||||
@input="emit('updateUserDataPath', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button class="secondary-button" type="button" @click="emit('pickUserDataPath')">
|
||||
Browse Folder
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="config-form-actions">
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="savingConfig"
|
||||
@click="emit('createConfig')"
|
||||
>
|
||||
{{ savingConfig ? "Saving..." : "Add Config" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="config-form-collapsed-note">
|
||||
<span>Collapsed by default to keep this page focused on existing configs.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="config-form-collapsed-note">
|
||||
<span>Collapsed by default to keep this page focused on existing configs.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="configsLoading" class="empty-card">
|
||||
<p>Loading browser configs...</p>
|
||||
</div>
|
||||
<div v-else class="stack-list">
|
||||
<article
|
||||
v-for="config in browserConfigs"
|
||||
:key="config.id"
|
||||
class="config-card"
|
||||
>
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-lead">
|
||||
<div class="browser-nav-icon config-icon">
|
||||
<img
|
||||
v-if="browserIconSrc(config.iconKey ?? config.browserFamilyId)"
|
||||
:src="browserIconSrc(config.iconKey ?? config.browserFamilyId) ?? undefined"
|
||||
:alt="`${config.name} icon`"
|
||||
/>
|
||||
<span v-else>{{ configMonogram(config) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-title-row">
|
||||
<h4>{{ config.name }}</h4>
|
||||
</div>
|
||||
<div v-if="configsLoading" class="empty-card">
|
||||
<p>Loading browser configs...</p>
|
||||
</div>
|
||||
<div v-else class="stack-list">
|
||||
<article
|
||||
v-for="config in browserConfigs"
|
||||
:key="config.id"
|
||||
class="config-card"
|
||||
>
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-lead">
|
||||
<div class="browser-nav-icon config-icon">
|
||||
<img
|
||||
v-if="browserIconSrc(config.iconKey ?? config.browserFamilyId)"
|
||||
:src="browserIconSrc(config.iconKey ?? config.browserFamilyId) ?? undefined"
|
||||
:alt="`${config.name} icon`"
|
||||
/>
|
||||
<span v-else>{{ configMonogram(config) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-title-row">
|
||||
<h4>{{ config.name }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="config.deletable"
|
||||
class="danger-button"
|
||||
type="button"
|
||||
:disabled="isDeletingConfig(config.id)"
|
||||
@click="emit('deleteConfig', config.id)"
|
||||
>
|
||||
{{ isDeletingConfig(config.id) ? "Deleting..." : "Delete" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="config-meta">
|
||||
<div class="config-meta-row">
|
||||
<span class="config-label">Executable</span>
|
||||
<p :title="config.executablePath">{{ config.executablePath || "Not resolved" }}</p>
|
||||
</div>
|
||||
<div class="config-meta-row">
|
||||
<span class="config-label">User Data</span>
|
||||
<p :title="config.userDataPath">{{ config.userDataPath }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="config.deletable"
|
||||
class="danger-button"
|
||||
type="button"
|
||||
:disabled="isDeletingConfig(config.id)"
|
||||
@click="emit('deleteConfig', config.id)"
|
||||
>
|
||||
{{ isDeletingConfig(config.id) ? "Deleting..." : "Delete" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="config-meta">
|
||||
<div class="config-meta-row">
|
||||
<span class="config-label">Executable</span>
|
||||
<p :title="config.executablePath">{{ config.executablePath || "Not resolved" }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="config-meta-row">
|
||||
<span class="config-label">User Data</span>
|
||||
<p :title="config.userDataPath">{{ config.userDataPath }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.config-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 2px 0 0;
|
||||
}
|
||||
|
||||
.config-form-card,
|
||||
.config-card {
|
||||
border-radius: 18px;
|
||||
|
||||
@@ -229,6 +229,9 @@ export function useBrowserManager() {
|
||||
if (iconKey === "chrome") return "CH";
|
||||
if (iconKey === "edge") return "ED";
|
||||
if (iconKey === "brave") return "BR";
|
||||
if (iconKey === "vivaldi") return "VI";
|
||||
if (iconKey === "yandex") return "YA";
|
||||
if (iconKey === "chromium") return "CR";
|
||||
|
||||
const name = current?.browserName?.trim() ?? "";
|
||||
if (name) {
|
||||
@@ -248,6 +251,9 @@ export function useBrowserManager() {
|
||||
if (iconKey === "chrome") return "CH";
|
||||
if (iconKey === "edge") return "ED";
|
||||
if (iconKey === "brave") return "BR";
|
||||
if (iconKey === "vivaldi") return "VI";
|
||||
if (iconKey === "yandex") return "YA";
|
||||
if (iconKey === "chromium") return "CR";
|
||||
|
||||
const letters = config.name
|
||||
.trim()
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
|
||||
.content-scroll-area {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
overflow: hidden;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.count-pill,
|
||||
@@ -175,23 +175,24 @@
|
||||
}
|
||||
|
||||
.browser-nav,
|
||||
.content-scroll-area {
|
||||
.styled-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 116, 139, 0.42) transparent;
|
||||
}
|
||||
.content-scroll-area::-webkit-scrollbar {
|
||||
.styled-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.content-scroll-area::-webkit-scrollbar-track {
|
||||
.styled-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.content-scroll-area::-webkit-scrollbar-thumb {
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(148, 163, 184, 0.72), rgba(100, 116, 139, 0.58));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.content-scroll-area::-webkit-scrollbar-thumb:hover {
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(100, 116, 139, 0.82), rgba(71, 85, 105, 0.72));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
@@ -200,25 +201,6 @@
|
||||
background: linear-gradient(180deg, rgba(254, 242, 242, 0.92), rgba(255, 255, 255, 0.86));
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
padding: 12px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.content-scroll-area {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sort-bar {
|
||||
justify-content: stretch;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import braveIcon from "../assets/brave.png";
|
||||
import chromiumIcon from "../assets/chromium.png";
|
||||
import chromeIcon from "../assets/google-chrome.png";
|
||||
import edgeIcon from "../assets/microsoft-edge.png";
|
||||
import settingsIcon from "../assets/settings.png";
|
||||
import vivaldiIcon from "../assets/vivaldi.png";
|
||||
import yandexIcon from "../assets/yandex.png";
|
||||
import type {
|
||||
AssociatedProfileSummary,
|
||||
BookmarkAssociatedProfileSummary,
|
||||
@@ -12,6 +15,9 @@ export const browserIconOptions = [
|
||||
{ key: "chrome", label: "Google Chrome", src: chromeIcon },
|
||||
{ key: "edge", label: "Microsoft Edge", src: edgeIcon },
|
||||
{ key: "brave", label: "Brave", src: braveIcon },
|
||||
{ key: "vivaldi", label: "Vivaldi", src: vivaldiIcon },
|
||||
{ key: "yandex", label: "Yandex Browser", src: yandexIcon },
|
||||
{ key: "chromium", label: "Chromium", src: chromiumIcon },
|
||||
] as const;
|
||||
|
||||
export function browserIconSrc(iconKey: string | null | undefined) {
|
||||
@@ -83,15 +89,16 @@ export function profileAvatarSrc(
|
||||
return profile.avatarDataUrl;
|
||||
}
|
||||
|
||||
const avatarFamilyId = resolveAvatarFamilyId(browserFamilyId);
|
||||
const avatarKey = normalizeAvatarIcon(profile.avatarIcon);
|
||||
if (avatarKey) {
|
||||
const familyMap = browserFamilyId ? avatarMap[browserFamilyId] : undefined;
|
||||
const familyMap = avatarFamilyId ? avatarMap[avatarFamilyId] : undefined;
|
||||
if (familyMap?.[avatarKey]) {
|
||||
return familyMap[avatarKey];
|
||||
}
|
||||
}
|
||||
|
||||
if (browserFamilyId === "chrome") {
|
||||
if (avatarFamilyId === "chrome") {
|
||||
return createChromeGeneratedAvatar(
|
||||
profile.defaultAvatarFillColor,
|
||||
profile.defaultAvatarStrokeColor,
|
||||
@@ -110,6 +117,14 @@ function normalizeAvatarIcon(value: string | null | undefined) {
|
||||
return lastSegment.replace(/\.png$/i, "");
|
||||
}
|
||||
|
||||
function resolveAvatarFamilyId(browserFamilyId: string | null | undefined) {
|
||||
if (browserFamilyId === "chromium") {
|
||||
return "chrome";
|
||||
}
|
||||
|
||||
return browserFamilyId ?? null;
|
||||
}
|
||||
|
||||
function createChromeGeneratedAvatar(
|
||||
backgroundArgb: number | null | undefined,
|
||||
foregroundArgb: number | null | undefined,
|
||||
|
||||
Reference in New Issue
Block a user