feat: implement caching for cryptographic keys to improve performance and reduce overhead

This commit is contained in:
shuaiplus
2026-04-27 22:49:52 +08:00
parent 4b69f71ddb
commit fdb4cb91bf
7 changed files with 414 additions and 192 deletions
+80 -37
View File
@@ -90,6 +90,39 @@ type SessionTimeoutAction = 'lock' | 'logout';
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1'; const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1'; const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]); const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
const DECRYPT_BATCH_SIZE = 16;
function yieldToMainThread(): Promise<void> {
return new Promise((resolve) => {
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
window.setTimeout(resolve, 0);
return;
}
setTimeout(resolve, 0);
});
}
async function mapAsyncInBatches<T, R>(
items: readonly T[],
mapper: (item: T, index: number) => Promise<R>,
options?: { batchSize?: number; shouldContinue?: () => boolean }
): Promise<R[]> {
const batchSize = Math.max(1, options?.batchSize || DECRYPT_BATCH_SIZE);
const result: R[] = new Array(items.length);
for (let start = 0; start < items.length; start += batchSize) {
if (options?.shouldContinue && !options.shouldContinue()) break;
const end = Math.min(items.length, start + batchSize);
const chunk = items.slice(start, end);
const mapped = await Promise.all(chunk.map((item, offset) => mapper(item, start + offset)));
for (let i = 0; i < mapped.length; i += 1) {
result[start + i] = mapped[i];
}
if (end < items.length) {
await yieldToMainThread();
}
}
return result;
}
function readThemePreference(): ThemePreference { function readThemePreference(): ThemePreference {
if (typeof window === 'undefined') return 'system'; if (typeof window === 'undefined') return 'system';
@@ -817,7 +850,8 @@ export default function App() {
const decryptFieldWithSource = async ( const decryptFieldWithSource = async (
value: string | null | undefined, value: string | null | undefined,
itemEnc: Uint8Array, itemEnc: Uint8Array,
itemMac: Uint8Array itemMac: Uint8Array,
canFallbackToUserKey: boolean
): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => { ): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return { text: '', source: 'plain' }; if (!raw) return { text: '', source: 'plain' };
@@ -826,7 +860,7 @@ export default function App() {
} catch { } catch {
// 继续尝试旧 user key 数据。 // 继续尝试旧 user key 数据。
} }
if (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) { if (canFallbackToUserKey) {
try { try {
return { text: await decryptStr(raw, encKey, macKey), source: 'user' }; return { text: await decryptStr(raw, encKey, macKey), source: 'user' };
} catch { } catch {
@@ -836,15 +870,18 @@ export default function App() {
return { text: raw, source: 'plain' }; return { text: raw, source: 'plain' };
}; };
const folders = await Promise.all( const folders = await mapAsyncInBatches(
foldersQuery.data.map(async (folder) => ({ foldersQuery.data,
async (folder) => ({
...folder, ...folder,
decName: await decryptField(folder.name, encKey, macKey), decName: await decryptField(folder.name, encKey, macKey),
})) }),
{ shouldContinue: () => active }
); );
const ciphers = await Promise.all( const ciphers = await mapAsyncInBatches(
ciphersQuery.data.map(async (cipher) => { ciphersQuery.data,
async (cipher) => {
let itemEnc = encKey; let itemEnc = encKey;
let itemMac = macKey; let itemMac = macKey;
if (cipher.key) { if (cipher.key) {
@@ -856,6 +893,7 @@ export default function App() {
// keep user key when item key decrypt fails // keep user key when item key decrypt fails
} }
} }
const itemUsesUserKey = sameBytes(itemEnc, encKey) && sameBytes(itemMac, macKey);
const nextCipher: Cipher = { const nextCipher: Cipher = {
...cipher, ...cipher,
@@ -942,7 +980,7 @@ export default function App() {
nextCipher.attachments = await Promise.all( nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => { cipher.attachments.map(async (attachment) => {
const attachmentId = String(attachment?.id || '').trim(); const attachmentId = String(attachment?.id || '').trim();
const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac); const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac, !itemUsesUserKey);
const metadata: { fileName?: string; key?: string | null } = {}; const metadata: { fileName?: string; key?: string | null } = {};
if (attachmentId && fileNameResult.source === 'user') { if (attachmentId && fileNameResult.source === 'user') {
@@ -954,7 +992,7 @@ export default function App() {
attachmentId && attachmentId &&
attachmentKey && attachmentKey &&
looksLikeCipherString(attachmentKey) && looksLikeCipherString(attachmentKey) &&
(!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) !itemUsesUserKey
) { ) {
try { try {
await decryptBw(attachmentKey, itemEnc, itemMac); await decryptBw(attachmentKey, itemEnc, itemMac);
@@ -982,7 +1020,8 @@ export default function App() {
); );
} }
return nextCipher; return nextCipher;
}) },
{ shouldContinue: () => active }
); );
if (!active) return; if (!active) return;
@@ -1024,35 +1063,39 @@ export default function App() {
return value; return value;
} }
}; };
const sends = await Promise.all(sendsQuery.data.map(async (send) => { const sends = await mapAsyncInBatches(
const nextSend: Send = { ...send }; sendsQuery.data,
try { async (send) => {
if (send.key) { const nextSend: Send = { ...send };
const sendKeyRaw = await decryptBw(send.key, encKey, macKey); try {
const derived = await deriveSendKeyParts(sendKeyRaw); if (send.key) {
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); const derived = await deriveSendKeyParts(sendKeyRaw);
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
if (send.file?.fileName) { nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
nextSend.file = { if (send.file?.fileName) {
...(send.file || {}), const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac);
fileName: decFileName || send.file.fileName, nextSend.file = {
}; ...(send.file || {}),
fileName: decFileName || send.file.fileName,
};
}
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!);
nextSend.decShareKey = shareKey;
nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey);
} else {
nextSend.decName = '';
nextSend.decNotes = '';
nextSend.decText = '';
} }
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); } catch {
nextSend.decShareKey = shareKey; nextSend.decName = t('txt_decrypt_failed');
nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey);
} else {
nextSend.decName = '';
nextSend.decNotes = '';
nextSend.decText = '';
} }
} catch { return nextSend;
nextSend.decName = t('txt_decrypt_failed'); },
} { shouldContinue: () => active }
return nextSend; );
}));
if (!active) return; if (!active) return;
setDecryptedSends(sends); setDecryptedSends(sends);
+96 -31
View File
@@ -33,6 +33,8 @@ const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14; const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const TOTP_REFRESH_BATCH_SIZE = 16;
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
const failedIconHosts = new Set<string>(); const failedIconHosts = new Set<string>();
function getTotpTimeState(): { windowId: number; remain: number } { function getTotpTimeState(): { windowId: number; remain: number } {
@@ -71,41 +73,80 @@ function hostFromUri(uri: string): string {
function TotpListIcon({ cipher }: { cipher: Cipher }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const iconStackRef = useRef<HTMLSpanElement | null>(null);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false); const [shouldLoad, setShouldLoad] = useState(() => !host);
const markIconError = () => { const markIconError = () => {
if (host) failedIconHosts.add(host); if (host) failedIconHosts.add(host);
setErrored(true); setErrored(true);
}; };
const syncCachedIconState = (img: HTMLImageElement | null) => { const hideFallback = () => {
if (!img || !img.complete) return; const stack = iconStackRef.current;
if (img.naturalWidth > 0) { if (stack) {
setLoaded(true); const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
return; if (fallback) fallback.style.display = 'none';
} }
markIconError();
}; };
const handleImgRef = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) hideFallback();
};
useEffect(() => { useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false); setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false); setShouldLoad(!host);
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
if (fallback) fallback.style.display = '';
}, [host]); }, [host]);
useEffect(() => {
if (!host || errored || shouldLoad) return;
const node = iconStackRef.current;
if (!node) return;
if (typeof IntersectionObserver !== 'function') {
setShouldLoad(true);
return;
}
let cancelled = false;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue;
if (!cancelled) setShouldLoad(true);
observer.disconnect();
break;
}
},
{ rootMargin: ICON_LOAD_ROOT_MARGIN }
);
observer.observe(node);
return () => {
cancelled = true;
observer.disconnect();
};
}, [host, errored, shouldLoad]);
if (host && !errored) { if (host && !errored) {
return ( return (
<span className="list-icon-stack"> <span className="list-icon-stack" ref={iconStackRef}>
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}> <span className="list-icon-fallback">
<Globe size={18} /> <Globe size={18} />
</span> </span>
<img {shouldLoad && (
className={`list-icon ${loaded ? 'loaded' : ''}`} <img
src={websiteIconUrl(host)} className="list-icon loaded"
alt="" src={websiteIconUrl(host)}
loading="lazy" alt=""
referrerPolicy="no-referrer" loading="lazy"
ref={syncCachedIconState} decoding="async"
onLoad={() => setLoaded(true)} referrerPolicy="no-referrer"
onError={markIconError} ref={handleImgRef}
/> onLoad={hideFallback}
onError={markIconError}
/>
)}
</span> </span>
); );
} }
@@ -294,17 +335,41 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
const refreshCodes = async () => { const refreshCodes = async () => {
const runId = ++activeRun; const runId = ++activeRun;
const entries = await Promise.all( const nextCodes: Record<string, string | null> = {};
totpItems.map(async (cipher) => { for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
try { if (stopped || runId !== activeRun) return;
const next = await calcTotpNow(cipher.login?.decTotp || ''); const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
return [cipher.id, next?.code || null] as const; const entries = await Promise.all(
} catch { batch.map(async (cipher) => {
return [cipher.id, null] as const; try {
} const next = await calcTotpNow(cipher.login?.decTotp || '');
}) return [cipher.id, next?.code || null] as const;
); } catch {
if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries)); return [cipher.id, null] as const;
}
})
);
for (const [id, code] of entries) nextCodes[id] = code;
if (start + TOTP_REFRESH_BATCH_SIZE < totpItems.length) {
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}
}
if (stopped || runId !== activeRun) return;
setTotpCodes((prev) => {
let changed = false;
const next: Record<string, string | null> = { ...prev };
for (const id of Object.keys(next)) {
if (id in nextCodes) continue;
delete next[id];
changed = true;
}
for (const [id, code] of Object.entries(nextCodes)) {
if (next[id] === code) continue;
next[id] = code;
changed = true;
}
return changed ? next : prev;
});
}; };
const tick = () => { const tick = () => {
+116 -91
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import VaultDialogs from '@/components/vault/VaultDialogs'; import VaultDialogs from '@/components/vault/VaultDialogs';
import VaultDetailView from '@/components/vault/VaultDetailView'; import VaultDetailView from '@/components/vault/VaultDetailView';
import VaultEditor from '@/components/vault/VaultEditor'; import VaultEditor from '@/components/vault/VaultEditor';
@@ -474,7 +474,7 @@ export default function VaultPage(props: VaultPageProps) {
!props.loading && !props.loading &&
!busy; !busy;
function handleReorderVaultCipher(activeId: string, overId: string): void { const handleReorderVaultCipher = useCallback((activeId: string, overId: string): void => {
if (!canReorderVaultList || activeId === overId) return; if (!canReorderVaultList || activeId === overId) return;
const currentIds = filteredCiphers.map((cipher) => cipher.id); const currentIds = filteredCiphers.map((cipher) => cipher.id);
const fromIndex = currentIds.indexOf(activeId); const fromIndex = currentIds.indexOf(activeId);
@@ -498,7 +498,7 @@ export default function VaultPage(props: VaultPageProps) {
suppressNextSortScrollRef.current = true; suppressNextSortScrollRef.current = true;
setSortMode('manual'); setSortMode('manual');
} }
} }, [canReorderVaultList, filteredCiphers, props.ciphers, sortMode]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
@@ -575,27 +575,27 @@ export default function VaultPage(props: VaultPageProps) {
); );
const totalCipherCount = filteredCiphers.length; const totalCipherCount = filteredCiphers.length;
function folderName(id: string | null | undefined): string { const folderName = useCallback((id: string | null | undefined): string => {
if (!id) return t('txt_no_folder'); if (!id) return t('txt_no_folder');
const folder = folderById.get(id); const folder = folderById.get(id);
return folder?.decName || folder?.name || id; return folder?.decName || folder?.name || id;
} }, [folderById]);
function listSubtitle(cipher: Cipher): string { const listSubtitle = useCallback((cipher: Cipher): string => {
if (Number(cipher.type || 1) === 1) { if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || ''; return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
} }
return cipherTypeLabel(Number(cipher.type || 1)); return cipherTypeLabel(Number(cipher.type || 1));
} }, [cipherMetaById]);
function handleListScroll(top: number): void { const handleListScroll = useCallback((top: number): void => {
const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT); const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT);
if (bucket === listScrollBucketRef.current) return; if (bucket === listScrollBucketRef.current) return;
listScrollBucketRef.current = bucket; listScrollBucketRef.current = bucket;
setListScrollTop(top); setListScrollTop(top);
} }, []);
function startCreate(type: number): void { const startCreate = useCallback((type: number): void => {
setDraft(createEmptyDraft(type)); setDraft(createEmptyDraft(type));
setIsCreating(true); setIsCreating(true);
setIsEditing(true); setIsEditing(true);
@@ -608,9 +608,9 @@ function folderName(id: string | null | undefined): string {
if (isMobileLayout) setMobilePanel('edit'); if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
if (type === 5) void seedSshDefaults(); if (type === 5) void seedSshDefaults();
} }, [isMobileLayout]);
function startEdit(): void { const startEdit = useCallback((): void => {
if (!selectedCipher) return; if (!selectedCipher) return;
setDraft(draftFromCipher(selectedCipher)); setDraft(draftFromCipher(selectedCipher));
setIsCreating(false); setIsCreating(false);
@@ -621,9 +621,9 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({}); setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit'); if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
} }, [selectedCipher, isMobileLayout]);
function cancelEdit(): void { const cancelEdit = useCallback((): void => {
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher; const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
setDraft(null); setDraft(null);
setIsEditing(false); setIsEditing(false);
@@ -633,11 +633,11 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({}); setRemovedAttachmentIds({});
setPendingDeletePasskeyIndex(null); setPendingDeletePasskeyIndex(null);
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list'); if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
} }, [isMobileLayout, isCreating, selectedCipher]);
function updateDraft(patch: Partial<VaultDraft>): void { const updateDraft = useCallback((patch: Partial<VaultDraft>): void => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
} }, []);
function confirmDeleteLoginPasskey(): void { function confirmDeleteLoginPasskey(): void {
if (pendingDeletePasskeyIndex == null) return; if (pendingDeletePasskeyIndex == null) return;
@@ -1002,16 +1002,88 @@ function folderName(id: string | null | undefined): string {
} }
} }
const handleClearSearch = useCallback(() => setSearchInput(''), []);
const handleSearchCompositionStart = useCallback(() => setSearchComposing(true), []);
const handleSearchCompositionEnd = useCallback((value: string) => {
setSearchComposing(false);
setSearchInput(value);
}, []);
const handleToggleSortMenu = useCallback(() => setSortMenuOpen((open) => !open), []);
const handleSelectSortMode = useCallback((value: VaultSortMode) => {
setSortMode(value);
setSortMenuOpen(false);
}, []);
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => {
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}, [filteredCiphers]);
const handleToggleCreateMenu = useCallback(() => setCreateMenuOpen((open) => !open), []);
const handleBulkRestore = useCallback(() => { void confirmBulkRestore(); }, [selectedMap, props.onBulkRestore]);
const handleBulkArchive = useCallback(() => setBulkArchiveOpen(true), []);
const handleBulkUnarchive = useCallback(() => { void confirmBulkUnarchive(); }, [selectedMap, props.onBulkUnarchive]);
const handleOpenMove = useCallback(() => {
setMoveFolderId('__none__');
setMoveOpen(true);
}, []);
const handleClearSelection = useCallback(() => setSelectedMap({}), []);
const handleToggleSelected = useCallback((cipherId: string, checked: boolean) =>
setSelectedMap((prev) => {
if (checked) return { ...prev, [cipherId]: true };
if (!prev[cipherId]) return prev;
const next = { ...prev };
delete next[cipherId];
return next;
})
, []);
const handleSelectCipher = useCallback((cipherId: string) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
const handleCloseMobileSidebar = useCallback(() => setMobileSidebarOpen(false), []);
const handleOpenDeleteAllFolders = useCallback(() => setDeleteAllFoldersOpen(true), []);
const handleOpenCreateFolder = useCallback(() => setCreateFolderOpen(true), []);
const handleOpenRenameFolder = useCallback((folder: Folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}, []);
const handleToggleFolderSortMenu = useCallback(() => setFolderSortMenuOpen((open) => !open), []);
const handleSelectFolderSortMode = useCallback((value: VaultSortMode) => {
setFolderSortMode(value);
setFolderSortMenuOpen(false);
}, []);
const handleMobileSidebarMaskClick = useCallback(() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}, [mobileSidebarOpen]);
return ( return (
<> <>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}> <div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && ( {isMobileLayout && (
<div <div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`} className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => { onClick={handleMobileSidebarMaskClick}
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
/> />
)} )}
<VaultSidebar <VaultSidebar
@@ -1023,20 +1095,14 @@ function folderName(id: string | null | undefined): string {
folderSortMode={folderSortMode} folderSortMode={folderSortMode}
folderSortMenuOpen={folderSortMenuOpen} folderSortMenuOpen={folderSortMenuOpen}
folderSortMenuRef={folderSortMenuRef} folderSortMenuRef={folderSortMenuRef}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)} onCloseMobileSidebar={handleCloseMobileSidebar}
onChangeFilter={setSidebarFilter} onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)} onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
onOpenCreateFolder={() => setCreateFolderOpen(true)} onOpenCreateFolder={handleOpenCreateFolder}
onOpenRenameFolder={(folder) => { onOpenRenameFolder={handleOpenRenameFolder}
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}}
onOpenDeleteFolder={setPendingDeleteFolder} onOpenDeleteFolder={setPendingDeleteFolder}
onToggleFolderSortMenu={() => setFolderSortMenuOpen((open) => !open)} onToggleFolderSortMenu={handleToggleFolderSortMenu}
onSelectFolderSortMode={(value) => { onSelectFolderSortMode={handleSelectFolderSortMode}
setFolderSortMode(value);
setFolderSortMenuOpen(false);
}}
/> />
<VaultListPanel <VaultListPanel
@@ -1061,67 +1127,26 @@ function folderName(id: string | null | undefined): string {
sortMenuRef={sortMenuRef} sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef} listPanelRef={listPanelRef}
onSearchInput={setSearchInput} onSearchInput={setSearchInput}
onClearSearch={() => setSearchInput('')} onClearSearch={handleClearSearch}
onSearchCompositionStart={() => setSearchComposing(true)} onSearchCompositionStart={handleSearchCompositionStart}
onSearchCompositionEnd={(value) => { onSearchCompositionEnd={handleSearchCompositionEnd}
setSearchComposing(false); onToggleSortMenu={handleToggleSortMenu}
setSearchInput(value); onSelectSortMode={handleSelectSortMode}
}} onSyncVault={handleSyncVault}
onToggleSortMenu={() => setSortMenuOpen((open) => !open)} onOpenBulkDelete={handleOpenBulkDelete}
onSelectSortMode={(value) => { onSelectDuplicates={handleSelectDuplicates}
setSortMode(value); onSelectAll={handleSelectAll}
setSortMenuOpen(false); onToggleCreateMenu={handleToggleCreateMenu}
}}
onSyncVault={() => void syncVault()}
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
onSelectDuplicates={() => {
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}}
onSelectAll={() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}}
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
onStartCreate={startCreate} onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()} onBulkRestore={handleBulkRestore}
onBulkArchive={() => setBulkArchiveOpen(true)} onBulkArchive={handleBulkArchive}
onBulkUnarchive={() => void confirmBulkUnarchive()} onBulkUnarchive={handleBulkUnarchive}
onOpenMove={() => { onOpenMove={handleOpenMove}
setMoveFolderId('__none__'); onClearSelection={handleClearSelection}
setMoveOpen(true);
}}
onClearSelection={() => setSelectedMap({})}
onReorderCipher={handleReorderVaultCipher} onReorderCipher={handleReorderVaultCipher}
onScroll={handleListScroll} onScroll={handleListScroll}
onToggleSelected={(cipherId, checked) => onToggleSelected={handleToggleSelected}
setSelectedMap((prev) => { onSelectCipher={handleSelectCipher}
if (checked) return { ...prev, [cipherId]: true };
if (!prev[cipherId]) return prev;
const next = { ...prev };
delete next[cipherId];
return next;
})
}
onSelectCipher={(cipherId) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
listSubtitle={listSubtitle} listSubtitle={listSubtitle}
/> />
@@ -1,4 +1,5 @@
import type { JSX, RefObject } from 'preact'; import type { JSX, RefObject } from 'preact';
import { memo } from 'preact/compat';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useMemo, useState } from 'preact/hooks'; import { useMemo, useState } from 'preact/hooks';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
@@ -106,7 +107,7 @@ interface CipherListItemBodyProps {
onSelectCipher?: (cipherId: string) => void; onSelectCipher?: (cipherId: string) => void;
} }
function CipherListItemBody(props: CipherListItemBodyProps) { const CipherListItemBody = memo(function CipherListItemBody(props: CipherListItemBodyProps) {
return ( return (
<> <>
<input <input
@@ -143,12 +144,12 @@ function CipherListItemBody(props: CipherListItemBodyProps) {
</button> </button>
</> </>
); );
} });
const animateLayoutChanges: AnimateLayoutChanges = (args) => const animateLayoutChanges: AnimateLayoutChanges = (args) =>
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false; args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false;
function SortableCipherListItem(props: SortableCipherListItemProps) { const SortableCipherListItem = memo(function SortableCipherListItem(props: SortableCipherListItemProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id, id: props.cipher.id,
disabled: !props.canReorder, disabled: !props.canReorder,
@@ -184,9 +185,9 @@ function SortableCipherListItem(props: SortableCipherListItemProps) {
/> />
</div> </div>
); );
} });
function PlainCipherListItem(props: SortableCipherListItemProps) { const PlainCipherListItem = memo(function PlainCipherListItem(props: SortableCipherListItemProps) {
return ( return (
<div <div
className={`list-item ${props.selected ? 'active' : ''}`} className={`list-item ${props.selected ? 'active' : ''}`}
@@ -206,7 +207,7 @@ function PlainCipherListItem(props: SortableCipherListItemProps) {
/> />
</div> </div>
); );
} });
export default function VaultListPanel(props: VaultListPanelProps) { export default function VaultListPanel(props: VaultListPanelProps) {
const [activeDragId, setActiveDragId] = useState(''); const [activeDragId, setActiveDragId] = useState('');
+7 -3
View File
@@ -43,6 +43,10 @@ interface VaultSidebarProps {
} }
export default function VaultSidebar(props: VaultSidebarProps) { export default function VaultSidebar(props: VaultSidebarProps) {
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const sortedFolders = useMemo(() => { const sortedFolders = useMemo(() => {
const sorted = [...props.folders]; const sorted = [...props.folders];
sorted.sort((a, b) => { sorted.sort((a, b) => {
@@ -67,14 +71,14 @@ export default function VaultSidebar(props: VaultSidebarProps) {
} }
if (aValid !== bValid) return aValid ? -1 : 1; if (aValid !== bValid) return aValid ? -1 : 1;
} }
const nameDiff = String(a.decName || a.name || '').localeCompare( const nameDiff = nameCollator.compare(
String(b.decName || b.name || ''), undefined, { sensitivity: 'base', numeric: true } String(a.decName || a.name || ''), String(b.decName || b.name || '')
); );
if (nameDiff !== 0) return nameDiff; if (nameDiff !== 0) return nameDiff;
return String(a.id || '').localeCompare(String(b.id || '')); return String(a.id || '').localeCompare(String(b.id || ''));
}); });
return sorted; return sorted;
}, [props.folders, props.folderSortMode]); }, [props.folders, props.folderSortMode, nameCollator]);
return ( return (
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}> <aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { import {
CreditCard, CreditCard,
FileKey2, FileKey2,
@@ -436,44 +436,85 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
} }
const failedIconHosts = new Set<string>(); const failedIconHosts = new Set<string>();
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
export function VaultListIcon({ cipher }: { cipher: Cipher }) { export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
const iconStackRef = useRef<HTMLSpanElement | null>(null);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false); const [shouldLoad, setShouldLoad] = useState(() => !host);
const markIconError = () => { const markIconError = () => {
if (host) failedIconHosts.add(host); if (host) failedIconHosts.add(host);
setErrored(true); setErrored(true);
}; };
const syncCachedIconState = (img: HTMLImageElement | null) => { const hideFallback = () => {
if (!img || !img.complete) return; const stack = iconStackRef.current;
if (img.naturalWidth > 0) { if (stack) {
setLoaded(true); const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
return; if (fallback) fallback.style.display = 'none';
} }
markIconError();
}; };
const handleImgRef = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) hideFallback();
};
useEffect(() => { useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false); setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false); setShouldLoad(!host);
// Reset fallback visibility so it shows while loading the new icon
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
if (fallback) fallback.style.display = '';
}, [host]); }, [host]);
useEffect(() => {
if (!host || errored || shouldLoad) return;
const node = iconStackRef.current;
if (!node) return;
if (typeof IntersectionObserver !== 'function') {
setShouldLoad(true);
return;
}
let cancelled = false;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue;
if (!cancelled) setShouldLoad(true);
observer.disconnect();
break;
}
},
{ rootMargin: ICON_LOAD_ROOT_MARGIN }
);
observer.observe(node);
return () => {
cancelled = true;
observer.disconnect();
};
}, [host, errored, shouldLoad]);
if (host && !errored) { if (host && !errored) {
return ( return (
<span className="list-icon-stack"> <span className="list-icon-stack" ref={iconStackRef}>
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}> <span className="list-icon-fallback">
<Globe size={18} /> <Globe size={18} />
</span> </span>
<img {shouldLoad && (
className={`list-icon ${loaded ? 'loaded' : ''}`} <img
src={websiteIconUrl(host)} className="list-icon loaded"
alt="" src={websiteIconUrl(host)}
loading="lazy" alt=""
referrerPolicy="no-referrer" loading="lazy"
ref={syncCachedIconState} decoding="async"
onLoad={() => setLoaded(true)} referrerPolicy="no-referrer"
onError={markIconError} ref={handleImgRef}
/> onLoad={hideFallback}
onError={markIconError}
/>
)}
</span> </span>
); );
} }
+46 -3
View File
@@ -22,6 +22,49 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer; return new Uint8Array(bytes).buffer;
} }
const hmacSha256KeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
const aesCbcEncryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
const aesCbcDecryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
function getCachedCryptoKey(
cache: WeakMap<Uint8Array, Promise<CryptoKey>>,
keyBytes: Uint8Array,
create: () => Promise<CryptoKey>
): Promise<CryptoKey> {
const cached = cache.get(keyBytes);
if (cached) return cached;
const pending = create().catch((error) => {
cache.delete(keyBytes);
throw error;
});
cache.set(keyBytes, pending);
return pending;
}
function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey(
hmacSha256KeyCache,
keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
);
}
function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey(
aesCbcEncryptKeyCache,
keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
);
}
function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey(
aesCbcDecryptKeyCache,
keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
);
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
let diff = 0; let diff = 0;
@@ -91,17 +134,17 @@ export async function hkdf(
} }
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> { async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const key = await getHmacSha256Key(keyBytes);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes))); return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
} }
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']); const cryptoKey = await getAesCbcEncryptKey(key);
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']); const cryptoKey = await getAesCbcDecryptKey(key);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }