This commit is contained in:
Julian Freeman
2026-04-16 10:39:53 -04:00
commit 6eb0b9bdf6
40 changed files with 8258 additions and 0 deletions

328
src/App.vue Normal file
View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
type BrowserStats = {
profileCount: number;
extensionCount: number;
bookmarkCount: number;
};
type ProfileSummary = {
id: string;
name: string;
email: string | null;
avatarDataUrl: string | null;
avatarLabel: string;
path: string;
};
type ExtensionSummary = {
id: string;
name: string;
version: string | null;
iconDataUrl: string | null;
profileIds: string[];
};
type BookmarkSummary = {
url: string;
title: string;
profileIds: string[];
};
type BrowserView = {
browserId: string;
browserName: string;
dataRoot: string;
profiles: ProfileSummary[];
extensions: ExtensionSummary[];
bookmarks: BookmarkSummary[];
stats: BrowserStats;
};
type ScanResponse = {
browsers: BrowserView[];
};
const loading = ref(true);
const error = ref("");
const response = ref<ScanResponse>({ browsers: [] });
const selectedBrowserId = ref("");
const browsers = computed(() => response.value.browsers);
const currentBrowser = computed(() =>
browsers.value.find((browser) => browser.browserId === selectedBrowserId.value) ??
browsers.value[0] ??
null,
);
watch(
browsers,
(items) => {
if (!items.length) {
selectedBrowserId.value = "";
return;
}
const hasSelected = items.some(
(browser) => browser.browserId === selectedBrowserId.value,
);
if (!hasSelected) {
selectedBrowserId.value = items[0].browserId;
}
},
{ immediate: true },
);
async function scanBrowsers() {
loading.value = true;
error.value = "";
try {
response.value = await invoke<ScanResponse>("scan_browsers");
} catch (scanError) {
error.value =
scanError instanceof Error
? scanError.message
: "Failed to scan browser data.";
} finally {
loading.value = false;
}
}
function browserMonogram(browserId: string) {
if (browserId === "chrome") return "CH";
if (browserId === "edge") return "ED";
if (browserId === "brave") return "BR";
return browserId.slice(0, 2).toUpperCase();
}
function extensionMonogram(name: string) {
return name.trim().slice(0, 1).toUpperCase() || "?";
}
function domainFromUrl(url: string) {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
onMounted(() => {
void scanBrowsers();
});
</script>
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="sidebar-header">
<p class="eyebrow">Desktop Utility</p>
<h1>Browser Assistant</h1>
<p class="sidebar-copy">
Scan local Chromium profiles, extensions, and bookmarks in one place.
</p>
</div>
<button class="refresh-button" type="button" @click="scanBrowsers">
{{ loading ? "Scanning..." : "Refresh Data" }}
</button>
<div v-if="browsers.length" class="browser-nav">
<button
v-for="browser in browsers"
:key="browser.browserId"
class="browser-nav-item"
:class="[browser.browserId, { active: browser.browserId === currentBrowser?.browserId }]"
type="button"
@click="selectedBrowserId = browser.browserId"
>
<div class="browser-nav-icon">{{ browserMonogram(browser.browserId) }}</div>
<div class="browser-nav-body">
<strong>{{ browser.browserName }}</strong>
<span>
{{ browser.stats.profileCount }} profiles ·
{{ browser.stats.extensionCount }} extensions ·
{{ browser.stats.bookmarkCount }} bookmarks
</span>
</div>
</button>
</div>
<div v-else class="sidebar-empty">
<p>No supported Chromium browser data was found yet.</p>
</div>
</aside>
<main class="content-panel">
<section v-if="loading" class="state-panel">
<p class="eyebrow">Scanning</p>
<h2>Reading local browser data</h2>
<p>Profiles, installed extensions, and bookmarks are being collected.</p>
</section>
<section v-else-if="error" class="state-panel error">
<p class="eyebrow">Error</p>
<h2>Scan failed</h2>
<p>{{ error }}</p>
</section>
<template v-else-if="currentBrowser">
<header class="hero-card" :class="currentBrowser.browserId">
<div class="hero-main">
<p class="eyebrow">Current Browser</p>
<h2>{{ currentBrowser.browserName }}</h2>
<p class="hero-copy">
Local data root: <span>{{ currentBrowser.dataRoot }}</span>
</p>
</div>
<div class="hero-stats">
<div class="stat-card">
<span>Profiles</span>
<strong>{{ currentBrowser.stats.profileCount }}</strong>
</div>
<div class="stat-card">
<span>Extensions</span>
<strong>{{ currentBrowser.stats.extensionCount }}</strong>
</div>
<div class="stat-card">
<span>Bookmarks</span>
<strong>{{ currentBrowser.stats.bookmarkCount }}</strong>
</div>
</div>
</header>
<section class="content-section">
<div class="section-heading">
<div>
<p class="eyebrow">Profiles</p>
<h3>User Profiles</h3>
</div>
<span class="count-pill">{{ currentBrowser.profiles.length }}</span>
</div>
<div v-if="currentBrowser.profiles.length" class="profile-grid">
<article
v-for="profile in currentBrowser.profiles"
:key="profile.id"
class="profile-card"
>
<div class="profile-avatar">
<img
v-if="profile.avatarDataUrl"
:src="profile.avatarDataUrl"
:alt="`${profile.name} avatar`"
/>
<span v-else>{{ profile.avatarLabel }}</span>
</div>
<div class="profile-body">
<div class="profile-topline">
<h4>{{ profile.name }}</h4>
<span class="badge neutral">{{ profile.id }}</span>
</div>
<p class="profile-email">{{ profile.email || "No email found" }}</p>
<p class="profile-path" :title="profile.path">{{ profile.path }}</p>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No profile directories were found for this browser.</p>
</div>
</section>
<section class="content-section">
<div class="section-heading">
<div>
<p class="eyebrow">Extensions</p>
<h3>Installed Extensions</h3>
</div>
<span class="count-pill">{{ currentBrowser.extensions.length }}</span>
</div>
<div v-if="currentBrowser.extensions.length" class="extension-grid">
<article
v-for="extension in currentBrowser.extensions"
:key="extension.id"
class="extension-card"
>
<div class="extension-icon">
<img
v-if="extension.iconDataUrl"
:src="extension.iconDataUrl"
:alt="`${extension.name} icon`"
/>
<span v-else>{{ extensionMonogram(extension.name) }}</span>
</div>
<div class="extension-body">
<div class="extension-topline">
<h4>{{ extension.name }}</h4>
<span v-if="extension.version" class="badge neutral">
v{{ extension.version }}
</span>
</div>
<p class="meta-line">{{ extension.id }}</p>
<div class="badge-row">
<span
v-for="profileId in extension.profileIds"
:key="`${extension.id}-${profileId}`"
class="badge"
>
{{ profileId }}
</span>
</div>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No extensions were discovered for this browser.</p>
</div>
</section>
<section class="content-section">
<div class="section-heading">
<div>
<p class="eyebrow">Bookmarks</p>
<h3>Saved Bookmarks</h3>
</div>
<span class="count-pill">{{ currentBrowser.bookmarks.length }}</span>
</div>
<div v-if="currentBrowser.bookmarks.length" class="bookmark-list">
<article
v-for="bookmark in currentBrowser.bookmarks"
:key="bookmark.url"
class="bookmark-card"
>
<div class="bookmark-body">
<div class="bookmark-topline">
<h4>{{ bookmark.title }}</h4>
<span class="badge neutral">{{ domainFromUrl(bookmark.url) }}</span>
</div>
<p class="bookmark-url" :title="bookmark.url">{{ bookmark.url }}</p>
<div class="badge-row">
<span
v-for="profileId in bookmark.profileIds"
:key="`${bookmark.url}-${profileId}`"
class="badge"
>
{{ profileId }}
</span>
</div>
</div>
</article>
</div>
<div v-else class="empty-card">
<p>No bookmarks were discovered for this browser.</p>
</div>
</section>
</template>
<section v-else class="state-panel">
<p class="eyebrow">No Data</p>
<h2>No supported browser was detected</h2>
<p>Install or sign in to Chrome, Edge, or Brave and refresh the scan.</p>
</section>
</main>
</div>
</template>

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");

503
src/styles.css Normal file
View File

@@ -0,0 +1,503 @@
:root {
color: #15202b;
background:
radial-gradient(circle at top left, rgba(108, 145, 255, 0.16), transparent 32%),
radial-gradient(circle at top right, rgba(16, 185, 129, 0.14), transparent 24%),
linear-gradient(180deg, #f5f8fc 0%, #edf2f7 100%);
font-family:
"Segoe UI Variable Text",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--panel: rgba(255, 255, 255, 0.76);
--panel-strong: rgba(255, 255, 255, 0.94);
--panel-border: rgba(148, 163, 184, 0.18);
--shadow: 0 20px 60px rgba(15, 23, 42, 0.08);
--text: #0f172a;
--muted: #526277;
--muted-soft: #7b8aa2;
--badge-bg: rgba(226, 232, 240, 0.7);
--badge-text: #344256;
--accent: #2f6fed;
--accent-soft: rgba(47, 111, 237, 0.12);
--chrome: #2563eb;
--edge: #0891b2;
--brave: #ea580c;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
color: var(--text);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 0;
}
.app-shell {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
min-height: 100vh;
padding: 24px;
gap: 20px;
}
.sidebar,
.content-panel {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 18px;
padding: 28px 22px;
border: 1px solid var(--panel-border);
border-radius: 28px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.7));
box-shadow: var(--shadow);
}
.sidebar-header h1,
.hero-main h2,
.section-heading h3,
.state-panel h2,
.profile-topline h4,
.extension-topline h4,
.bookmark-topline h4 {
margin: 0;
font-weight: 600;
letter-spacing: -0.03em;
}
.sidebar-header h1 {
font-size: 2rem;
line-height: 1;
}
.sidebar-copy,
.hero-copy,
.state-panel p,
.meta-line,
.profile-email,
.profile-path,
.bookmark-url,
.browser-nav-body span,
.sidebar-empty p {
margin: 0;
color: var(--muted);
}
.eyebrow {
margin: 0 0 8px;
color: var(--muted-soft);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.refresh-button {
padding: 13px 16px;
border-radius: 18px;
background: linear-gradient(135deg, #10213f 0%, #213f75 100%);
color: #fff;
cursor: pointer;
transition:
transform 160ms ease,
box-shadow 160ms ease;
box-shadow: 0 16px 30px rgba(20, 44, 82, 0.22);
}
.refresh-button:hover {
transform: translateY(-1px);
}
.browser-nav {
display: flex;
flex-direction: column;
gap: 12px;
}
.browser-nav-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px;
border-radius: 20px;
text-align: left;
cursor: pointer;
background: rgba(255, 255, 255, 0.54);
border: 1px solid transparent;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.browser-nav-item:hover {
transform: translateY(-1px);
border-color: var(--panel-border);
}
.browser-nav-item.active {
background: var(--accent-soft);
border-color: rgba(47, 111, 237, 0.18);
}
.browser-nav-item.chrome.active {
background: rgba(37, 99, 235, 0.12);
}
.browser-nav-item.edge.active {
background: rgba(8, 145, 178, 0.12);
}
.browser-nav-item.brave.active {
background: rgba(234, 88, 12, 0.12);
}
.browser-nav-icon,
.profile-avatar,
.extension-icon {
display: grid;
place-items: center;
flex-shrink: 0;
overflow: hidden;
}
.browser-nav-icon {
width: 50px;
height: 50px;
border-radius: 16px;
color: #fff;
font-weight: 700;
letter-spacing: 0.08em;
background: linear-gradient(135deg, #10213f, #365f9f);
}
.browser-nav-item.chrome .browser-nav-icon,
.hero-card.chrome {
--accent: var(--chrome);
}
.browser-nav-item.edge .browser-nav-icon,
.hero-card.edge {
--accent: var(--edge);
}
.browser-nav-item.brave .browser-nav-icon,
.hero-card.brave {
--accent: var(--brave);
}
.browser-nav-item.chrome .browser-nav-icon {
background: linear-gradient(135deg, #2563eb, #22c55e);
}
.browser-nav-item.edge .browser-nav-icon {
background: linear-gradient(135deg, #0f766e, #38bdf8);
}
.browser-nav-item.brave .browser-nav-icon {
background: linear-gradient(135deg, #c2410c, #fb923c);
}
.browser-nav-body {
min-width: 0;
}
.browser-nav-body strong,
.stat-card strong {
display: block;
color: var(--text);
}
.browser-nav-body span {
display: block;
margin-top: 4px;
font-size: 0.88rem;
line-height: 1.4;
}
.sidebar-empty {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.58);
border: 1px dashed rgba(148, 163, 184, 0.35);
}
.content-panel {
padding: 12px 6px 12px 0;
overflow: auto;
}
.hero-card,
.content-section,
.state-panel {
border: 1px solid var(--panel-border);
border-radius: 28px;
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-card {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 28px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.72)),
linear-gradient(135deg, color-mix(in srgb, var(--accent) 16%, white), rgba(255, 255, 255, 0));
}
.hero-copy span {
color: var(--text);
word-break: break-all;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 14px;
width: min(460px, 100%);
}
.stat-card {
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(148, 163, 184, 0.18);
}
.stat-card span {
display: block;
color: var(--muted-soft);
font-size: 0.84rem;
}
.stat-card strong {
margin-top: 10px;
font-size: 2rem;
letter-spacing: -0.05em;
}
.content-section {
margin-top: 20px;
padding: 24px;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.count-pill,
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
}
.count-pill {
min-width: 44px;
padding: 8px 12px;
}
.badge {
padding: 6px 10px;
}
.badge.neutral {
background: var(--badge-bg);
color: var(--badge-text);
}
.profile-grid,
.extension-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.profile-card,
.extension-card,
.bookmark-card,
.empty-card {
border-radius: 24px;
padding: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: var(--panel-strong);
}
.profile-card,
.extension-card,
.bookmark-card {
display: flex;
gap: 16px;
}
.profile-avatar {
width: 64px;
height: 64px;
border-radius: 20px;
background: linear-gradient(135deg, #dbeafe, #eff6ff);
color: #1d4ed8;
font-size: 1.1rem;
font-weight: 700;
}
.profile-avatar img,
.extension-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-body,
.extension-body,
.bookmark-body {
min-width: 0;
flex: 1;
}
.profile-topline,
.extension-topline,
.bookmark-topline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.profile-topline h4,
.extension-topline h4,
.bookmark-topline h4 {
font-size: 1rem;
line-height: 1.35;
}
.profile-email,
.profile-path,
.meta-line,
.bookmark-url {
margin-top: 8px;
font-size: 0.92rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-icon {
width: 56px;
height: 56px;
border-radius: 18px;
background: linear-gradient(135deg, #e2e8f0, #f8fafc);
color: #475569;
font-weight: 700;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.bookmark-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.state-panel,
.empty-card {
display: grid;
gap: 8px;
}
.state-panel {
min-height: 320px;
place-content: center;
padding: 36px;
}
.state-panel.error {
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;
padding: 16px;
}
.content-panel {
padding: 0;
}
.hero-card {
flex-direction: column;
}
.hero-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
}
}
@media (max-width: 720px) {
.hero-stats {
grid-template-columns: 1fr;
}
.profile-grid,
.extension-grid {
grid-template-columns: 1fr;
}
.profile-card,
.extension-card,
.bookmark-card {
flex-direction: column;
}
.profile-avatar,
.extension-icon {
width: 56px;
height: 56px;
}
}

7
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}