diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b9b6932..1494a5e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,30 +1,47 @@ use std::{path::PathBuf, process::Command}; use crate::{ - browsers::{browser_definition_by_id, resolve_browser_executable}, - models::ScanResponse, + config_store, + models::{BrowserConfigListResponse, CreateCustomBrowserConfigInput, ScanResponse}, scanner, - utils::local_app_data_dir, }; +use tauri::AppHandle; #[tauri::command] -pub fn scan_browsers() -> Result { - scanner::scan_browsers() +pub fn scan_browsers(app: AppHandle) -> Result { + scanner::scan_browsers(&app) } #[tauri::command] -pub fn open_browser_profile(browser_id: String, profile_id: String) -> Result<(), String> { - let definition = browser_definition_by_id(&browser_id) - .ok_or_else(|| format!("Unsupported browser: {browser_id}"))?; - let executable_path = resolve_browser_executable(&browser_id) - .ok_or_else(|| format!("Unable to locate executable for browser: {browser_id}"))?; - let local_app_data = local_app_data_dir().ok_or_else(|| { - "Unable to resolve the LOCALAPPDATA directory for the current user.".to_string() - })?; - let user_data_dir = definition - .local_app_data_segments - .iter() - .fold(local_app_data, |path, segment| path.join(segment)); +pub fn list_browser_configs(app: AppHandle) -> Result { + config_store::load_browser_config_list(&app) +} + +#[tauri::command] +pub fn create_custom_browser_config( + app: AppHandle, + input: CreateCustomBrowserConfigInput, +) -> Result { + config_store::create_custom_browser_config(&app, input) +} + +#[tauri::command] +pub fn delete_custom_browser_config( + app: AppHandle, + config_id: String, +) -> Result { + config_store::delete_custom_browser_config(&app, &config_id) +} + +#[tauri::command] +pub fn open_browser_profile( + app: AppHandle, + browser_id: String, + profile_id: String, +) -> Result<(), String> { + let config = config_store::find_browser_config(&app, &browser_id)?; + let executable_path = PathBuf::from(&config.executable_path); + let user_data_dir = PathBuf::from(&config.user_data_path); let profile_directory = user_data_dir.join(&profile_id); if !user_data_dir.is_dir() { diff --git a/src-tauri/src/config_store.rs b/src-tauri/src/config_store.rs new file mode 100644 index 0000000..29432f9 --- /dev/null +++ b/src-tauri/src/config_store.rs @@ -0,0 +1,188 @@ +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use tauri::{AppHandle, Manager}; + +use crate::{ + browsers::{browser_definitions, resolve_browser_executable}, + models::{ + BrowserConfigEntry, BrowserConfigListResponse, BrowserConfigSource, + CreateCustomBrowserConfigInput, CustomBrowserConfigRecord, StoredBrowserConfigs, + }, + utils::local_app_data_dir, +}; + +const CONFIG_FILE_NAME: &str = "browser-configs.json"; + +pub fn load_browser_config_list(app: &AppHandle) -> Result { + Ok(BrowserConfigListResponse { + configs: resolve_browser_configs(app)?, + }) +} + +pub fn resolve_browser_configs(app: &AppHandle) -> Result, String> { + let mut configs = default_browser_configs()?; + let stored = load_stored_configs(app)?; + + configs.extend( + stored + .custom_configs + .into_iter() + .map(|config| BrowserConfigEntry { + id: config.id, + source: BrowserConfigSource::Custom, + browser_family_id: None, + name: config.name, + executable_path: config.executable_path, + user_data_path: config.user_data_path, + deletable: true, + }), + ); + + Ok(configs) +} + +pub fn create_custom_browser_config( + app: &AppHandle, + input: CreateCustomBrowserConfigInput, +) -> Result { + let name = input.name.trim(); + let executable_path = input.executable_path.trim(); + let user_data_path = input.user_data_path.trim(); + + if name.is_empty() { + return Err("Name is required.".to_string()); + } + if executable_path.is_empty() { + return Err("Executable path is required.".to_string()); + } + if user_data_path.is_empty() { + return Err("User data path is required.".to_string()); + } + + let mut stored = load_stored_configs(app)?; + stored.custom_configs.push(CustomBrowserConfigRecord { + id: generate_custom_config_id(), + name: name.to_string(), + executable_path: executable_path.to_string(), + user_data_path: user_data_path.to_string(), + }); + + save_stored_configs(app, &stored)?; + load_browser_config_list(app) +} + +pub fn delete_custom_browser_config( + app: &AppHandle, + config_id: &str, +) -> Result { + let mut stored = load_stored_configs(app)?; + let original_len = stored.custom_configs.len(); + stored + .custom_configs + .retain(|config| config.id != config_id); + + if stored.custom_configs.len() == original_len { + return Err(format!("Custom browser config not found: {config_id}")); + } + + save_stored_configs(app, &stored)?; + load_browser_config_list(app) +} + +pub fn find_browser_config(app: &AppHandle, config_id: &str) -> Result { + resolve_browser_configs(app)? + .into_iter() + .find(|config| config.id == config_id) + .ok_or_else(|| format!("Browser config not found: {config_id}")) +} + +fn default_browser_configs() -> Result, String> { + let local_app_data = local_app_data_dir().ok_or_else(|| { + "Unable to resolve the LOCALAPPDATA directory for the current user.".to_string() + })?; + + Ok(browser_definitions() + .into_iter() + .map(|definition| { + let user_data_path = definition + .local_app_data_segments + .iter() + .fold(local_app_data.clone(), |path, segment| path.join(segment)); + + BrowserConfigEntry { + id: definition.id.to_string(), + source: BrowserConfigSource::Default, + browser_family_id: Some(definition.id.to_string()), + name: definition.name.to_string(), + executable_path: resolve_browser_executable(definition.id) + .map(|path| path.display().to_string()) + .unwrap_or_default(), + user_data_path: user_data_path.display().to_string(), + deletable: false, + } + }) + .collect()) +} + +fn load_stored_configs(app: &AppHandle) -> Result { + let path = config_file_path(app)?; + if !path.is_file() { + return Ok(StoredBrowserConfigs { + custom_configs: Vec::new(), + }); + } + + let content = fs::read_to_string(&path).map_err(|error| { + format!( + "Failed to read browser config file {}: {error}", + path.display() + ) + })?; + serde_json::from_str(&content).map_err(|error| { + format!( + "Failed to parse browser config file {}: {error}", + path.display() + ) + }) +} + +fn save_stored_configs(app: &AppHandle, stored: &StoredBrowserConfigs) -> Result<(), String> { + let path = config_file_path(app)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create browser config directory {}: {error}", + parent.display() + ) + })?; + } + + let content = serde_json::to_string_pretty(stored) + .map_err(|error| format!("Failed to serialize browser configs: {error}"))?; + fs::write(&path, content).map_err(|error| { + format!( + "Failed to write browser config file {}: {error}", + path.display() + ) + }) +} + +fn config_file_path(app: &AppHandle) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|error| format!("Failed to resolve app data directory: {error}"))?; + Ok(app_data_dir.join(CONFIG_FILE_NAME)) +} + +fn generate_custom_config_id() -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + format!("custom-{timestamp}") +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 848f129..a9f7066 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod browsers; mod commands; +mod config_store; mod models; mod scanner; mod utils; @@ -10,6 +11,9 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ commands::scan_browsers, + commands::list_browser_configs, + commands::create_custom_browser_config, + commands::delete_custom_browser_config, commands::open_browser_profile ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 699b954..90cd594 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -12,6 +12,7 @@ pub struct ScanResponse { #[serde(rename_all = "camelCase")] pub struct BrowserView { pub browser_id: String, + pub browser_family_id: Option, pub browser_name: String, pub data_root: String, pub profiles: Vec, @@ -57,6 +58,54 @@ pub struct BookmarkSummary { pub profile_ids: Vec, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserConfigListResponse { + pub configs: Vec, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BrowserConfigEntry { + pub id: String, + pub source: BrowserConfigSource, + pub browser_family_id: Option, + pub name: String, + pub executable_path: String, + pub user_data_path: String, + pub deletable: bool, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub enum BrowserConfigSource { + Default, + Custom, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateCustomBrowserConfigInput { + pub name: String, + pub executable_path: String, + pub user_data_path: String, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StoredBrowserConfigs { + pub custom_configs: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CustomBrowserConfigRecord { + pub id: String, + pub name: String, + pub executable_path: String, + pub user_data_path: String, +} + pub struct BrowserDefinition { pub id: &'static str, pub name: &'static str, diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs index 6a4f5ea..e5c90cc 100644 --- a/src-tauri/src/scanner.rs +++ b/src-tauri/src/scanner.rs @@ -1,43 +1,32 @@ use std::{ collections::{BTreeMap, BTreeSet}, fs, - path::Path, + path::{Path, PathBuf}, }; use serde_json::Value; +use tauri::AppHandle; use crate::{ - browsers::browser_definitions, + config_store, models::{ - BookmarkSummary, BrowserDefinition, BrowserStats, BrowserView, ExtensionSummary, + BookmarkSummary, BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary, ProfileSummary, ScanResponse, TempBookmark, TempExtension, }, - utils::{ - first_non_empty, load_image_as_data_url, local_app_data_dir, pick_latest_subdirectory, - read_json_file, - }, + utils::{first_non_empty, load_image_as_data_url, pick_latest_subdirectory, read_json_file}, }; -pub fn scan_browsers() -> Result { - let local_app_data = local_app_data_dir().ok_or_else(|| { - "Unable to resolve the LOCALAPPDATA directory for the current user.".to_string() - })?; - - let browsers = browser_definitions() +pub fn scan_browsers(app: &AppHandle) -> Result { + let browsers = config_store::resolve_browser_configs(app)? .into_iter() - .filter_map(|definition| scan_browser(&local_app_data, definition)) + .filter_map(scan_browser) .collect(); Ok(ScanResponse { browsers }) } -fn scan_browser(local_app_data: &Path, definition: BrowserDefinition) -> Option { - let root = definition - .local_app_data_segments - .iter() - .fold(local_app_data.to_path_buf(), |path, segment| { - path.join(segment) - }); +fn scan_browser(config: BrowserConfigEntry) -> Option { + let root = PathBuf::from(&config.user_data_path); if !root.is_dir() { return None; @@ -99,8 +88,9 @@ fn scan_browser(local_app_data: &Path, definition: BrowserDefinition) -> Option< .collect::>(); Some(BrowserView { - browser_id: definition.id.to_string(), - browser_name: definition.name.to_string(), + browser_id: config.id, + browser_family_id: config.browser_family_id, + browser_name: config.name, data_root: root.display().to_string(), stats: BrowserStats { profile_count: profiles.len(), diff --git a/src/App.vue b/src/App.vue index 8d061c9..0a673e1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,20 +6,30 @@ const { activeSection, bookmarkProfilesExpanded, bookmarkSortKey, + browserConfigs, browserMonogram, browsers, + configError, + configMonogram, + configsLoading, + createConfigForm, + createCustomBrowserConfig, currentBrowser, + deleteCustomBrowserConfig, domainFromUrl, error, extensionMonogram, extensionProfilesExpanded, extensionSortKey, + isDeletingConfig, isOpeningProfile, loading, openProfileError, openBrowserProfile, + page, profileSortKey, - scanBrowsers, + refreshAll, + savingConfig, sectionCount, selectedBrowserId, sortedBookmarks, @@ -45,9 +55,9 @@ const { v-for="browser in browsers" :key="browser.browserId" class="browser-nav-item" - :class="[browser.browserId, { active: browser.browserId === currentBrowser?.browserId }]" + :class="[browser.browserFamilyId ?? browser.browserId, { active: browser.browserId === currentBrowser?.browserId }]" type="button" - @click="selectedBrowserId = browser.browserId" + @click="selectedBrowserId = browser.browserId; page = 'browserData'" >
{{ browserMonogram(browser.browserId) }}
@@ -61,23 +71,140 @@ const {

No supported Chromium browser data was found yet.

-
-
-

Scanning

-

Reading local browser data

-

Profiles, installed extensions, and bookmarks are being collected.

+
+ +
-
-

Error

-

Scan failed

-

{{ error }}

-
+ + + + + -
-

No Data

-

No supported browser was detected

-

Install or sign in to Chrome, Edge, or Brave and refresh the scan.

-
+
diff --git a/src/features/browser-assistant/types.ts b/src/features/browser-assistant/types.ts index 1bea25d..75864a3 100644 --- a/src/features/browser-assistant/types.ts +++ b/src/features/browser-assistant/types.ts @@ -31,9 +31,32 @@ export type ProfileSortKey = "name" | "email" | "id"; export type ExtensionSortKey = "name" | "id"; export type BookmarkSortKey = "title" | "url"; export type ActiveSection = "profiles" | "extensions" | "bookmarks"; +export type AppPage = "browserData" | "configuration"; +export type BrowserConfigSource = "default" | "custom"; + +export type BrowserConfigEntry = { + id: string; + source: BrowserConfigSource; + browserFamilyId: string | null; + name: string; + executablePath: string; + userDataPath: string; + deletable: boolean; +}; + +export type BrowserConfigListResponse = { + configs: BrowserConfigEntry[]; +}; + +export type CreateCustomBrowserConfigInput = { + name: string; + executablePath: string; + userDataPath: string; +}; export type BrowserView = { browserId: string; + browserFamilyId: string | null; browserName: string; dataRoot: string; profiles: ProfileSummary[]; diff --git a/src/features/browser-assistant/useBrowserAssistant.ts b/src/features/browser-assistant/useBrowserAssistant.ts index d8758c1..87a34fe 100644 --- a/src/features/browser-assistant/useBrowserAssistant.ts +++ b/src/features/browser-assistant/useBrowserAssistant.ts @@ -4,19 +4,34 @@ import { invoke } from "@tauri-apps/api/core"; import { sortBookmarks, sortExtensions, sortProfiles } from "./sort"; import type { ActiveSection, + AppPage, BookmarkSortKey, + BrowserConfigEntry, + BrowserConfigListResponse, BrowserView, + CreateCustomBrowserConfigInput, ExtensionSortKey, ProfileSortKey, ScanResponse, } from "./types"; export function useBrowserAssistant() { + const page = ref("browserData"); const loading = ref(true); const error = ref(""); const openProfileError = ref(""); const openingProfileKey = ref(""); const response = ref({ browsers: [] }); + const browserConfigs = ref([]); + const configsLoading = ref(true); + const configError = ref(""); + const savingConfig = ref(false); + const deletingConfigId = ref(""); + const createConfigForm = ref({ + name: "", + executablePath: "", + userDataPath: "", + }); const selectedBrowserId = ref(""); const activeSection = ref("profiles"); const expandedExtensionIds = ref([]); @@ -65,8 +80,24 @@ export function useBrowserAssistant() { watch(selectedBrowserId, () => { expandedExtensionIds.value = []; expandedBookmarkUrls.value = []; + openProfileError.value = ""; }); + async function loadBrowserConfigs() { + configsLoading.value = true; + configError.value = ""; + + try { + const result = await invoke("list_browser_configs"); + browserConfigs.value = result.configs; + } catch (loadError) { + configError.value = + loadError instanceof Error ? loadError.message : "Failed to load browser configs."; + } finally { + configsLoading.value = false; + } + } + async function scanBrowsers() { loading.value = true; error.value = ""; @@ -83,6 +114,10 @@ export function useBrowserAssistant() { } } + async function refreshAll() { + await Promise.all([loadBrowserConfigs(), scanBrowsers()]); + } + async function openBrowserProfile(browserId: string, profileId: string) { const profileKey = `${browserId}:${profileId}`; openingProfileKey.value = profileKey; @@ -103,17 +138,89 @@ export function useBrowserAssistant() { } } + async function createCustomBrowserConfig() { + savingConfig.value = true; + configError.value = ""; + + try { + const result = await invoke("create_custom_browser_config", { + input: createConfigForm.value, + }); + browserConfigs.value = result.configs; + createConfigForm.value = { + name: "", + executablePath: "", + userDataPath: "", + }; + await scanBrowsers(); + } catch (saveError) { + configError.value = + saveError instanceof Error ? saveError.message : "Failed to create browser config."; + } finally { + savingConfig.value = false; + } + } + + async function deleteCustomBrowserConfig(configId: string) { + deletingConfigId.value = configId; + configError.value = ""; + + try { + const result = await invoke("delete_custom_browser_config", { + configId, + }); + browserConfigs.value = result.configs; + await scanBrowsers(); + } catch (deleteError) { + configError.value = + deleteError instanceof Error ? deleteError.message : "Failed to delete browser config."; + } finally { + deletingConfigId.value = ""; + } + } + + function isDeletingConfig(configId: string) { + return deletingConfigId.value === configId; + } + function isOpeningProfile(browserId: string, profileId: string) { return openingProfileKey.value === `${browserId}:${profileId}`; } function browserMonogram(browserId: string) { - if (browserId === "chrome") return "CH"; - if (browserId === "edge") return "ED"; - if (browserId === "brave") return "BR"; + const current = browsers.value.find((browser) => browser.browserId === browserId); + const familyId = current?.browserFamilyId; + if (familyId === "chrome") return "CH"; + if (familyId === "edge") return "ED"; + if (familyId === "brave") return "BR"; + + const name = current?.browserName?.trim() ?? ""; + if (name) { + const letters = name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]); + if (letters.length) return letters.join("").toUpperCase(); + } + return browserId.slice(0, 2).toUpperCase(); } + function configMonogram(config: BrowserConfigEntry) { + if (config.browserFamilyId === "chrome") return "CH"; + if (config.browserFamilyId === "edge") return "ED"; + if (config.browserFamilyId === "brave") return "BR"; + + const letters = config.name + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]); + return (letters.join("") || config.id.slice(0, 2)).toUpperCase(); + } + function extensionMonogram(name: string) { return name.trim().slice(0, 1).toUpperCase() || "?"; } @@ -154,27 +261,37 @@ export function useBrowserAssistant() { } onMounted(() => { - void scanBrowsers(); + void refreshAll(); }); return { activeSection, bookmarkProfilesExpanded, bookmarkSortKey, + browserConfigs, browserMonogram, browsers, + configError, + configMonogram, + configsLoading, + createConfigForm, + createCustomBrowserConfig, currentBrowser, + deleteCustomBrowserConfig, domainFromUrl, error, extensionMonogram, extensionProfilesExpanded, extensionSortKey, - loading, + isDeletingConfig, isOpeningProfile, + loading, openProfileError, openBrowserProfile, + page, profileSortKey, - scanBrowsers, + refreshAll, + savingConfig, sectionCount, selectedBrowserId, sortedBookmarks, diff --git a/src/styles.css b/src/styles.css index 6125e7e..af33a00 100644 --- a/src/styles.css +++ b/src/styles.css @@ -276,6 +276,30 @@ button { overflow: hidden; } +.page-tabs { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +.page-tab { + padding: 10px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.58); + color: var(--muted); + cursor: pointer; + transition: + background 160ms ease, + color 160ms ease, + box-shadow 160ms ease; +} + +.page-tab.active { + color: var(--text); + 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); +} + .section-tabs, .content-section, .state-panel { @@ -299,6 +323,137 @@ button { font-size: 0.86rem; } +.config-form-card, +.config-card { + border-radius: 18px; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: var(--panel-strong); +} + +.config-form-header h3, +.config-title-row h4 { + margin: 0; + font-size: 0.98rem; +} + +.config-form-header p, +.config-id, +.config-meta-row p { + margin: 6px 0 0; + color: var(--muted); + font-size: 0.86rem; +} + +.config-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.field-group { + display: grid; + gap: 6px; +} + +.field-group span, +.config-label { + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; +} + +.field-group input { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 12px; + background: rgba(255, 255, 255, 0.94); + color: var(--text); + outline: none; +} + +.field-group input:focus { + border-color: rgba(47, 111, 237, 0.42); + box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.12); +} + +.field-span { + grid-column: 1 / -1; +} + +.config-form-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +.primary-button, +.danger-button { + padding: 9px 14px; + border-radius: 12px; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; +} + +.primary-button { + background: linear-gradient(135deg, #10213f 0%, #213f75 100%); + color: #fff; +} + +.danger-button { + border: 1px solid rgba(239, 68, 68, 0.18); + background: rgba(254, 242, 242, 0.96); + color: #b42318; +} + +.primary-button:disabled, +.danger-button:disabled { + cursor: default; + opacity: 0.72; +} + +.config-card-header, +.config-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.config-card-lead { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} + +.config-icon { + width: 40px; + height: 40px; + border-radius: 12px; + font-size: 0.8rem; +} + +.config-meta { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.config-meta-row { + display: grid; + gap: 4px; +} + +.config-meta-row p { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .sort-bar { display: flex; justify-content: flex-end; @@ -695,6 +850,14 @@ button { } @media (max-width: 720px) { + .config-form-grid { + grid-template-columns: 1fr; + } + + .field-span { + grid-column: auto; + } + .sort-bar { justify-content: stretch; }