mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: improve offline PWA resilience
This commit is contained in:
+7
-5
@@ -434,6 +434,7 @@ export default function App() {
|
||||
(async () => {
|
||||
const boot = await bootstrapAppSession(initialBootstrap);
|
||||
if (!mounted) return;
|
||||
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) return;
|
||||
setDefaultKdfIterations(boot.defaultKdfIterations);
|
||||
setRegistrationInviteRequired(boot.registrationInviteRequired);
|
||||
setJwtWarning(boot.jwtWarning);
|
||||
@@ -912,7 +913,7 @@ export default function App() {
|
||||
const vaultCoreQuery = useQuery({
|
||||
queryKey: ['vault-core', vaultCacheKey],
|
||||
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
||||
@@ -923,7 +924,7 @@ export default function App() {
|
||||
const sendsQuery = useQuery({
|
||||
queryKey: sendsQueryKey,
|
||||
queryFn: () => getSends(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
|
||||
@@ -952,13 +953,13 @@ export default function App() {
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users', vaultCacheKey],
|
||||
queryFn: () => listAdminUsers(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const invitesQuery = useQuery({
|
||||
queryKey: ['admin-invites', vaultCacheKey],
|
||||
queryFn: () => listAdminInvites(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const totpStatusQuery = useQuery({
|
||||
@@ -1015,7 +1016,7 @@ export default function App() {
|
||||
useQuery({
|
||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||
queryFn: () => backupActions.loadSettings(),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -1085,6 +1086,7 @@ export default function App() {
|
||||
setDecryptedFolders(result.folders);
|
||||
setDecryptedCiphers(result.ciphers);
|
||||
setVaultInitialDecryptDone(true);
|
||||
if (!session.accessToken) return;
|
||||
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
|
||||
if (uriChecksumRepairAttemptRef.current !== repairKey) {
|
||||
uriChecksumRepairAttemptRef.current = repairKey;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -237,6 +238,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<NetworkStatusBadge />
|
||||
<div className="user-chip">
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -83,7 +84,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'locked') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -132,7 +133,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'register') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_create_account')}>
|
||||
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -216,7 +217,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_log_in')}>
|
||||
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Wifi, WifiOff } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
browserReportsOffline,
|
||||
getCurrentNetworkStatus,
|
||||
probeNodeWardenService,
|
||||
setCurrentNetworkStatus,
|
||||
subscribeNetworkStatus,
|
||||
type NetworkStatus,
|
||||
} from '@/lib/network-status';
|
||||
|
||||
const STATUS_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
function statusLabel(status: NetworkStatus): string {
|
||||
if (status === 'online') return t('txt_online');
|
||||
return t('txt_offline');
|
||||
}
|
||||
|
||||
export default function NetworkStatusBadge() {
|
||||
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||
const label = statusLabel(status);
|
||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
|
||||
const checkService = async () => {
|
||||
if (browserReportsOffline()) {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return;
|
||||
}
|
||||
const reachable = await probeNodeWardenService();
|
||||
if (!cancelled) {
|
||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNextCheck = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
}, STATUS_CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const handleOnline = () => {
|
||||
void checkService();
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setCurrentNetworkStatus('offline');
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') void checkService();
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeNetworkStatus(setStatus);
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
window.addEventListener('focus', handleOnline);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
window.removeEventListener('focus', handleOnline);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`network-status-badge ${status}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className="network-status-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
|
||||
interface StandalonePageFrameProps {
|
||||
title: string;
|
||||
eyebrow?: ComponentChildren;
|
||||
titleAccessory?: ComponentChildren;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
|
||||
<div className="auth-card">
|
||||
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
<div className="standalone-title-row">
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
{props.titleAccessory}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -716,6 +716,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -729,6 +731,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setPendingDelete(null);
|
||||
cancelEdit();
|
||||
if (isMobileLayout) setMobilePanel('list');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -741,6 +745,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === cipher.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -760,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
}
|
||||
setSelectedMap({});
|
||||
setBulkDeleteOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -776,6 +784,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkMove(ids, folderId);
|
||||
setSelectedMap({});
|
||||
setMoveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -785,6 +795,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onRefresh();
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -819,6 +831,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onCreateFolder(newFolderName);
|
||||
setCreateFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -833,6 +847,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setPendingDeleteFolder(null);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -850,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -864,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkRestore(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -878,6 +898,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -892,6 +914,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
delete next[cipher.id];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -907,6 +931,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkArchive(ids);
|
||||
setSelectedMap({});
|
||||
setBulkArchiveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -921,6 +947,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkUnarchive(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -935,6 +963,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setDeleteAllFoldersOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
subscribeWebsiteIconStatus,
|
||||
} from '@/lib/website-icon-cache';
|
||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
|
||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||
@@ -26,8 +27,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
||||
const [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus);
|
||||
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
|
||||
|
||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
setShouldLoad(true);
|
||||
@@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
useEffect(() => {
|
||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||
if (demoIconUrl) return;
|
||||
if (networkStatus !== 'online') return;
|
||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||
beginWebsiteIconLoad(host, src);
|
||||
}, [demoIconUrl, host, src, shouldLoad, status]);
|
||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
||||
|
||||
if (demoIconUrl) {
|
||||
return (
|
||||
|
||||
@@ -302,6 +302,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
||||
};
|
||||
|
||||
const requireOnlineWrite = () => {
|
||||
if (session?.accessToken) return;
|
||||
throw new Error(t('txt_offline_vault_readonly'));
|
||||
};
|
||||
|
||||
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
||||
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
||||
if (options?.includeFolders) {
|
||||
@@ -447,6 +452,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const optimistic = optimisticCipherFromDraft(draft, null);
|
||||
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
|
||||
try {
|
||||
@@ -471,6 +482,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
if (hasUnresolvedCipherData(cipher)) {
|
||||
throw new Error(t('txt_decrypt_failed_2'));
|
||||
}
|
||||
@@ -543,6 +560,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async deleteVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
|
||||
try {
|
||||
@@ -571,6 +594,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async archiveVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
const archivedDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
|
||||
@@ -587,6 +616,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async unarchiveVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
const revisionDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
|
||||
@@ -603,6 +638,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteCiphers(authedFetch, ids);
|
||||
const deletedDate = new Date().toISOString();
|
||||
@@ -616,6 +657,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkArchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkArchiveCiphers(authedFetch, ids);
|
||||
const archivedDate = new Date().toISOString();
|
||||
@@ -629,6 +676,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||
@@ -641,6 +694,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||
@@ -658,6 +717,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
const created = await createFolder(authedFetch, session, folderName);
|
||||
@@ -683,6 +748,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_not_found'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await deleteFolder(authedFetch, id);
|
||||
patchFolderBatch([id], () => null);
|
||||
@@ -706,6 +777,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await updateFolder(authedFetch, session, id, nextName);
|
||||
@@ -719,6 +796,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||
@@ -731,6 +814,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, () => null);
|
||||
@@ -745,6 +834,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkDeleteFolders(folderIds: string[]) {
|
||||
const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteFolders(authedFetch, ids);
|
||||
const removedIds = new Set(ids);
|
||||
@@ -765,6 +860,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async createSend(draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : '';
|
||||
if (fileName) {
|
||||
@@ -790,6 +891,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const updated = await updateSend(authedFetch, session, send, draft);
|
||||
await refetchSends();
|
||||
@@ -806,6 +913,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async deleteSend(send: Send) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await deleteSend(authedFetch, send.id);
|
||||
await refetchSends();
|
||||
@@ -817,6 +930,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkDeleteSends(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteSends(authedFetch, ids);
|
||||
await refetchSends();
|
||||
@@ -833,6 +952,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
attachments: ImportAttachmentFile[] = []
|
||||
): Promise<ImportResultSummary> {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
requireOnlineWrite();
|
||||
|
||||
const mode = options.folderMode || 'original';
|
||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||
|
||||
@@ -458,7 +458,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
};
|
||||
|
||||
const session = getSession();
|
||||
if (!session?.accessToken) throw new Error('Unauthorized');
|
||||
if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
|
||||
const headers = new Headers(init.headers || {});
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
headers.set('X-NodeWarden-Web', '1');
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
saveOfflineUnlockRecord,
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import { probeNodeWardenService } from '@/lib/network-status';
|
||||
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
@@ -268,6 +270,16 @@ export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
@@ -357,6 +369,7 @@ export async function performPasswordLogin(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -377,7 +390,7 @@ export async function performTotpLogin(
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, kdfIterationsFromLogin(token, 600000));
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
@@ -455,8 +468,14 @@ export async function performUnlock(
|
||||
}
|
||||
};
|
||||
|
||||
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
if (hasOfflineUnlock) {
|
||||
if (browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return unlockOffline();
|
||||
}
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
@@ -493,6 +512,7 @@ export async function performUnlock(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -735,6 +735,7 @@ const en: Record<string, string> = {
|
||||
"txt_status": "Status",
|
||||
"txt_online": "Online",
|
||||
"txt_offline": "Offline",
|
||||
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
||||
"txt_submit": "Submit",
|
||||
"txt_sync": "Sync",
|
||||
"txt_sync_vault": "Sync Vault",
|
||||
|
||||
@@ -735,6 +735,7 @@ const es: Record<string, string> = {
|
||||
"txt_status": "Estado",
|
||||
"txt_online": "En línea",
|
||||
"txt_offline": "Sin conexión",
|
||||
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
|
||||
"txt_submit": "Enviar",
|
||||
"txt_sync": "Sincronizar",
|
||||
"txt_sync_vault": "Sincronizar bóveda",
|
||||
|
||||
@@ -735,6 +735,7 @@ const ru: Record<string, string> = {
|
||||
"txt_status": "Статус",
|
||||
"txt_online": "Онлайн",
|
||||
"txt_offline": "Офлайн",
|
||||
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
||||
"txt_submit": "Отправить",
|
||||
"txt_sync": "Синхронизировать",
|
||||
"txt_sync_vault": "Синхронизировать хранилище",
|
||||
|
||||
@@ -735,6 +735,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_status": "状态",
|
||||
"txt_online": "在线",
|
||||
"txt_offline": "离线",
|
||||
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"txt_sync_vault": "同步",
|
||||
|
||||
@@ -735,6 +735,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_status": "狀態",
|
||||
"txt_online": "在線",
|
||||
"txt_offline": "離線",
|
||||
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"txt_sync_vault": "同步",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
export type NetworkStatus = 'online' | 'offline';
|
||||
|
||||
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
||||
const STATUS_PROBE_CACHE_MS = 5000;
|
||||
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||
let pendingProbe: Promise<boolean> | null = null;
|
||||
let lastProbeAt = 0;
|
||||
let lastProbeResult = false;
|
||||
|
||||
export function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
export function getInitialNetworkStatus(): NetworkStatus {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
export function getCurrentNetworkStatus(): NetworkStatus {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
export function setCurrentNetworkStatus(status: NetworkStatus): void {
|
||||
if (currentStatus === status) return;
|
||||
currentStatus = status;
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeNodeWardenService(): Promise<boolean> {
|
||||
if (browserReportsOffline()) {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (pendingProbe) return pendingProbe;
|
||||
if (now - lastProbeAt < STATUS_PROBE_CACHE_MS) return lastProbeResult;
|
||||
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = controller
|
||||
? window.setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS)
|
||||
: 0;
|
||||
|
||||
pendingProbe = (async () => {
|
||||
const response = await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
signal: controller?.signal,
|
||||
});
|
||||
return response.ok;
|
||||
})()
|
||||
.catch(() => false)
|
||||
.then((result) => {
|
||||
lastProbeAt = Date.now();
|
||||
lastProbeResult = result;
|
||||
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
pendingProbe = null;
|
||||
});
|
||||
|
||||
return pendingProbe;
|
||||
}
|
||||
@@ -141,9 +141,10 @@ export async function unlockOfflineVaultWithMasterKey(
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const keys = await unlockVaultKey(record.profileKey, masterKey);
|
||||
const { accessToken: _accessToken, refreshToken: _refreshToken, ...offlineSession } = session;
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
...offlineSession,
|
||||
email: record.email,
|
||||
...keys,
|
||||
},
|
||||
|
||||
@@ -3,9 +3,16 @@ export function registerNodeWardenServiceWorker(): void {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const register = () => {
|
||||
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||
// PWA support is progressive enhancement; the vault still works without it.
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
register();
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', register, { once: true });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Send } from './types';
|
||||
import { getCurrentNetworkStatus } from './network-status';
|
||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||
|
||||
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||
@@ -12,6 +13,7 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
|
||||
function getWorker(): Worker | null {
|
||||
if (typeof Worker === 'undefined') return null;
|
||||
if (worker) return worker;
|
||||
if (getCurrentNetworkStatus() === 'offline') return null;
|
||||
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||
const message = event.data;
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
@apply m-0 mb-1 text-center;
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.standalone-eyebrow {
|
||||
@@ -486,7 +486,17 @@
|
||||
}
|
||||
|
||||
.standalone-title {
|
||||
@apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal;
|
||||
@apply m-0 text-center text-3xl font-bold leading-tight tracking-normal;
|
||||
}
|
||||
|
||||
.standalone-title-row {
|
||||
@apply relative mb-1 flex min-h-9 items-center justify-center;
|
||||
padding-inline: 84px;
|
||||
}
|
||||
|
||||
.standalone-title-row > .network-status-badge {
|
||||
@apply absolute right-0 top-1/2;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.standalone-muted {
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.standalone-title-row {
|
||||
padding-inline: 68px;
|
||||
}
|
||||
|
||||
.standalone-footer {
|
||||
@apply text-xs leading-[1.4];
|
||||
}
|
||||
@@ -128,6 +132,14 @@
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply h-8 px-2 text-[0];
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge svg {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle,
|
||||
.mobile-lock-btn {
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
|
||||
|
||||
@@ -44,6 +44,31 @@
|
||||
@apply flex items-center gap-2.5;
|
||||
}
|
||||
|
||||
.network-status-badge {
|
||||
@apply inline-flex h-[30px] shrink-0 select-none items-center gap-1.5 rounded-full border px-2.5 text-xs font-bold leading-none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.network-status-badge svg {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.network-status-badge.online {
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--success) 36%, var(--line));
|
||||
color: color-mix(in srgb, var(--success) 78%, var(--text));
|
||||
}
|
||||
|
||||
.network-status-badge.offline {
|
||||
background: color-mix(in srgb, var(--warning) 13%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
|
||||
color: color-mix(in srgb, var(--warning) 82%, var(--text));
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply h-[34px] px-3;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`;
|
||||
const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1';
|
||||
|
||||
const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
|
||||
const CRITICAL_SHELL_URLS = ['/', '/index.html', '/vault'];
|
||||
const CRITICAL_SHELL_URLS = ['/', '/index.html'];
|
||||
const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/;
|
||||
const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/;
|
||||
const OFFLINE_FALLBACK_HTML = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>NodeWarden</title><style>html,body{height:100%;margin:0;background:#eef4ff;color:#0f172a;font-family:ui-sans-serif,system-ui,sans-serif}.boot-screen{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}.boot-card{width:min(420px,100%);display:grid;gap:12px;justify-items:center;padding:28px;border:1px solid rgba(148,163,184,.35);border-radius:22px;background:rgba(255,255,255,.86);box-shadow:0 20px 45px rgba(15,23,42,.1)}.boot-logo{width:74px;height:58px;object-fit:contain}.boot-title{font-weight:700}.boot-sub{color:#475569;text-align:center;font-size:14px;line-height:1.5}</style></head><body><div class="boot-screen"><div class="boot-card"><img class="boot-logo" src="/nodewarden-logo.svg" alt=""><div class="boot-title">NodeWarden</div><div class="boot-sub">Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.</div></div></div></body></html>';
|
||||
@@ -196,7 +196,7 @@ function pwaServiceWorkerPlugin(isDemo: boolean): Plugin {
|
||||
buildUrls.add(`/${fileName}`);
|
||||
}
|
||||
|
||||
const sortedUrls = Array.from(urls).sort();
|
||||
const sortedUrls = Array.from(buildUrls).sort();
|
||||
const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort());
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
|
||||
Reference in New Issue
Block a user