feat: improve offline PWA resilience

This commit is contained in:
shuaiplus
2026-06-09 14:09:46 +08:00
parent 1a10df4a18
commit 615caf5946
23 changed files with 432 additions and 21 deletions
+1 -1
View File
@@ -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');
+23 -3
View File
@@ -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,
},
};
}
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "Синхронизировать хранилище",
+1
View File
@@ -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": "同步",
+1
View File
@@ -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": "同步",
+79
View File
@@ -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;
}
+2 -1
View File
@@ -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,
},
+9 -2
View File
@@ -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 });
}
+2
View File
@@ -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;