Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/desktop/__tests__/brand-icons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';

import { lookupBrandIcon } from '../src/main/brand-icons';

describe('lookupBrandIcon', () => {
it('matches plain and decorated service names', () => {
expect(lookupBrandIcon('GitHub')?.slug).toBe('github');
expect(lookupBrandIcon('github.com')?.slug).toBe('github');
expect(lookupBrandIcon('GitHub (alice@example.com)')?.slug).toBe('github');
expect(lookupBrandIcon('Coinbase')?.slug).toBe('coinbase');
});

it('prefers svgl full-colour logos', () => {
const icon = lookupBrandIcon('GitHub');
expect(icon?.kind).toBe('logo');
if (icon?.kind !== 'logo') throw new Error('expected a logo');
expect(icon.light).toMatch(/^<svg/);
expect(icon.dark).toMatch(/^<svg/);
});

it('falls back to a simple-icons glyph when svgl has no logo', () => {
const icon = lookupBrandIcon('Namecheap');
expect(icon?.kind).toBe('glyph');
if (icon?.kind !== 'glyph') throw new Error('expected a glyph');
expect(icon.hex).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(icon.path.length).toBeGreaterThan(0);
});

it('returns null for services it does not know', () => {
expect(lookupBrandIcon('Totally Made Up Bank')).toBeNull();
});
});
3 changes: 2 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"@decryption/shamir": "workspace:*",
"@decryption/vault": "workspace:*",
"@decryption/wallet": "workspace:*",
"appstash": "^0.7.0"
"appstash": "^0.7.0",
"simple-icons": "16.26.0"
},
"devDependencies": {
"@base-ui/react": "^1.6.0",
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/scripts/vendor-svgl.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Vendors the svgl (MIT, github.com/pheralb/svgl) logo library into
* src/main/svgl-icons.json so the app ships full-colour brand marks offline —
* it never touches api.svgl.app at runtime.
*
* Usage: node scripts/vendor-svgl.mjs <path-to-svgl-checkout>
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const svglRoot = resolve(process.argv[2] ?? '');
if (!svglRoot) {
console.error('usage: node scripts/vendor-svgl.mjs <path-to-svgl-checkout>');
process.exit(1);
}

const out = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'main', 'svgl-icons.json');

// the data file is a plain object-literal array behind a type annotation
const source = readFileSync(join(svglRoot, 'src/data/svgs.ts'), 'utf8')
.replace(/^import[^\n]*\n/gm, '')
.replace(/export const svgs: iSVG\[\] =/, 'return');
const entries = new Function(source)();

/** Inline SVG is rendered as markup, so drop anything executable. */
const sanitize = (svg) =>
svg
.replace(/<\?xml[^>]*\?>/g, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*')/gi, '')
.replace(/\s+/g, ' ')
.trim();

const readSvg = (route) => sanitize(readFileSync(join(svglRoot, 'static', route.replace(/^\//, '')), 'utf8'));

const BANNED = /1password|authy/i;

// a few logos are elaborate illustrations; at this size they only ever render
// as a smudge, so the monochrome simple-icons fallback serves them better
const MAX_BYTES = 16_384;

const icons = {};
let skipped = 0;
for (const entry of entries) {
if (BANNED.test(entry.title)) continue;
const route = entry.route;
try {
const light = typeof route === 'string' ? readSvg(route) : readSvg(route.light);
const dark = typeof route === 'string' ? light : readSvg(route.dark);
if (light.length > MAX_BYTES || dark.length > MAX_BYTES) {
skipped += 1;
continue;
}
const slug = (typeof route === 'string' ? route : route.light)
.replace(/^\/library\//, '')
.replace(/\.svg$/, '')
// variant files are named foo-light.svg / foo_dark.svg
.replace(/[-_](light|dark)$/, '');
icons[entry.title] = { title: entry.title, slug, light, ...(dark === light ? {} : { dark }) };
} catch {
// a handful of entries reference wordmark-only assets
}
}

writeFileSync(out, `${JSON.stringify(icons)}\n`);
console.log(`wrote ${Object.keys(icons).length} icons (${skipped} too large) -> ${out}`);
78 changes: 78 additions & 0 deletions apps/desktop/src/main/brand-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as simpleIcons from 'simple-icons';

import type { BrandIcon } from '../shared/api';
import svglIcons from './svgl-icons.json';

/**
* Brand marks for vault items. Full-colour logos come from the vendored svgl
* library, falling back to simple-icons' monochrome glyphs. Both sets are
* bundled, so nothing is ever fetched — the app never reveals which services
* you hold accounts with. Lookup runs here rather than in the renderer to keep
* ~4,000 icons out of the renderer bundle.
*/
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\.(com|org|net|io|co|dev|app|xyz)\b.*$/, '')
.replace(/[^a-z0-9]/g, '');

interface SvglEntry {
title: string;
slug: string;
light: string;
dark?: string;
}

const index = ((): Map<string, BrandIcon> => {
const map = new Map<string, BrandIcon>();
const add = (key: string, icon: BrandIcon): void => {
if (key && !map.has(key)) map.set(key, icon);
};

// simple-icons first so svgl's colour art overrides it below
for (const icon of Object.values(simpleIcons)) {
if (typeof icon !== 'object' || icon === null || !('slug' in icon)) continue;
const { title, slug, path, hex } = icon as {
title: string;
slug: string;
path: string;
hex: string;
};
const entry: BrandIcon = { kind: 'glyph', title, slug, path, hex: `#${hex}` };
add(normalize(slug), entry);
add(normalize(title), entry);
}

for (const entry of Object.values(svglIcons as Record<string, SvglEntry>)) {
const icon: BrandIcon = {
kind: 'logo',
title: entry.title,
slug: entry.slug,
light: entry.light,
dark: entry.dark ?? entry.light,
};
map.set(normalize(entry.slug), icon);
map.set(normalize(entry.title), icon);
}
return map;
})();

/**
* Best-effort match for a vault item's title. Tries the whole title first, then
* its leading words, so "GitHub (alice@example.com)" and "Coinbase Pro" both hit.
*/
export const lookupBrandIcon = (name: string): BrandIcon | null => {
const direct = index.get(normalize(name));
if (direct) return direct;
const words = name.split(/[\s:/(),—-]+/).filter(Boolean);
for (let count = words.length; count > 0; count--) {
const candidate = index.get(normalize(words.slice(0, count).join('')));
if (candidate) return candidate;
}
return null;
};

export const lookupBrandIcons = (names: string[]): Record<string, BrandIcon | null> =>
Object.fromEntries(names.map((name) => [name, lookupBrandIcon(name)]));
4 changes: 4 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ipcMain } from 'electron';

import { CHANNELS, FieldPurpose, ItemKind } from '../shared/api';
import { parseOtpauthUri } from '../shared/otpauth';
import { lookupBrandIcons } from './brand-icons';
import { VaultService } from './vault-service';

const WORD_COUNTS: WordCount[] = [12, 15, 18, 21, 24];
Expand Down Expand Up @@ -133,6 +134,9 @@ export const registerIpc = (service: VaultService): void => {
service.scheduleSave();
});

// ─── brand icons (bundled, offline) ───
handle(CHANNELS.iconsLookup, (names: string[]) => lookupBrandIcons(assertStringArray(names)));

// ─── audit ───
handle(CHANNELS.auditLog, (itemId?: string) => service.current().auditLog(itemId));

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/svgl-icons.json

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions apps/desktop/src/main/vault-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const vaultModulePath = (): string => {
export class VaultService {
private vault: Vault | null = null;
private saveTimer: NodeJS.Timeout | null = null;
/** In-flight lock, so an unlock racing a pending flush waits for it. */
private locking: Promise<void> | null = null;

status(): VaultStatus {
const file = vaultFilePath();
Expand All @@ -45,6 +47,7 @@ export class VaultService {
}

async unlock(passphrase: string): Promise<void> {
await this.locking;
if (this.vault && !this.vault.isLocked) return;
this.vault = await Vault.open({
file: vaultFilePath(),
Expand All @@ -59,10 +62,13 @@ export class VaultService {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (this.vault) {
await this.vault.lock();
this.vault = null;
}
if (!this.vault) return;
const vault = this.vault;
this.vault = null;
this.locking = vault.lock().finally(() => {
this.locking = null;
});
await this.locking;
}

/** Debounced persistence after mutations. */
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const api: DcryptApi & {
invoke(CHANNELS.wbShamirSplit, secret, shares, threshold),
shamirCombine: (shares) => invoke(CHANNELS.wbShamirCombine, shares),
},
icons: {
lookup: (names) => invoke(CHANNELS.iconsLookup, names),
},
theme: {
getSystemDark: () => invoke(CHANNELS.themeGetSystemDark),
},
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ const AppContent = () => {
void dcrypt.vault.status().then((s) => setUnlocked(s.unlocked));
}, []);

const lock = useCallback(async () => {
await dcrypt.vault.lock();
// switch to the unlock screen first: the flush behind `vault.lock()` is fast
// but not instant, and waiting on it makes the click feel stuck
const lock = useCallback(() => {
setUnlocked(false);
void dcrypt.vault.lock();
}, []);

if (!unlocked) {
Expand Down
85 changes: 85 additions & 0 deletions apps/desktop/src/renderer/src/components/BrandGlyph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';

import type { BrandIcon } from '../../../shared/api';
import { dcrypt } from '../lib/ipc';
import { useThemeMode } from '../lib/theme-context';

const cache = new Map<string, BrandIcon | null>();
const pending = new Map<string, Promise<void>>();

/** Resolves brand glyphs through the main process, memoized per title. */
export const useBrandIcons = (names: string[]): Record<string, BrandIcon | null> => {
const [, setVersion] = useState(0);
const wanted = names.join('\u0000');

useEffect(() => {
const missing = wanted
.split('\u0000')
.filter((name) => name && !cache.has(name) && !pending.has(name));
if (!missing.length) return;
const request = dcrypt.icons
.lookup(missing)
.then((found) => {
for (const name of missing) cache.set(name, found[name] ?? null);
setVersion((v) => v + 1);
})
.catch(() => {
for (const name of missing) cache.set(name, null);
})
.finally(() => {
for (const name of missing) pending.delete(name);
});
for (const name of missing) pending.set(name, request);
}, [wanted]);

return Object.fromEntries(names.map((name) => [name, cache.get(name) ?? null]));
};

/**
* A service's brand mark, falling back to its initial when simple-icons has no
* match. Icons are bundled, so nothing is ever fetched from the network.
*/
export const BrandGlyph = ({
name,
icon,
className = 'size-5',
}: {
name: string;
icon: BrandIcon | null;
className?: string;
}) => {
const { dark } = useThemeMode();

if (!icon) {
return (
<span
className={`${className} flex shrink-0 items-center justify-center rounded bg-muted text-[0.7em] font-semibold uppercase text-muted-foreground`}
aria-hidden
>
{name.trim().charAt(0) || '?'}
</span>
);
}
if (icon.kind === 'logo') {
return (
<span
role="img"
aria-label={icon.title}
className={`${className} shrink-0 [&>svg]:size-full`}
// vendored, sanitized at build time and never fetched at runtime
dangerouslySetInnerHTML={{ __html: dark ? icon.dark : icon.light }}
/>
);
}
return (
<svg
role="img"
viewBox="0 0 24 24"
className={`${className} shrink-0`}
fill={icon.hex}
aria-label={icon.title}
>
<path d={icon.path} />
</svg>
);
};
7 changes: 6 additions & 1 deletion apps/desktop/src/renderer/src/components/ItemDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { toast } from 'sonner';

import type { TotpEntry, VaultFieldMeta, VaultItem, VaultTag } from '../../../shared/api';
import { copyWithTimeout, dcrypt } from '../lib/ipc';
import { BrandGlyph, useBrandIcons } from './BrandGlyph';

const KIND_LABEL: Record<string, string> = {
login: 'Login',
Expand Down Expand Up @@ -48,6 +49,7 @@ export const ItemDetail = ({
const [newFieldValue, setNewFieldValue] = useState('');
const [newTag, setNewTag] = useState('');
const [totp, setTotp] = useState<TotpEntry | null>(null);
const icons = useBrandIcons([item.title]);

const refresh = useCallback(async () => {
setRevealed({});
Expand Down Expand Up @@ -134,7 +136,10 @@ export const ItemDetail = ({
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-semibold">{item.title}</h2>
<h2 className="flex items-center gap-2 text-xl font-semibold">
<BrandGlyph name={item.title} icon={icons[item.title]} className="size-6" />
{item.title}
</h2>
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary">{KIND_LABEL[item.kind] ?? item.kind}</Badge>
{tags.map((tag) => (
Expand Down
Loading
Loading