From 634eb622fda637c1e08edf507255a63accd6d386 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 11:30:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(pwa):=20wei=C3=9Fe=20Seite=20nach=20Android?= =?UTF-8?q?-Neustart=20ohne=20Master-Key=20vermeiden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erzwingt Login wenn nur die HTTP-Session übrig ist, begrenzt SW-Reloads, fängt Bootstrap-/Render-Fehler ab und stabilisiert den PWA-Kaltstart. Co-authored-by: Cursor --- client/index.html | 2 +- client/src/App.tsx | 84 ++++++++++++++++++---- client/src/components/AppErrorBoundary.tsx | 42 +++++++++++ client/src/hooks/usePwaUpdate.ts | 7 ++ client/src/main.tsx | 38 ++++++++-- client/src/services/auth.ts | 17 ++++- 6 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 client/src/components/AppErrorBoundary.tsx diff --git a/client/index.html b/client/index.html index f35383a..b986e09 100644 --- a/client/index.html +++ b/client/index.html @@ -36,7 +36,7 @@ Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei) - +
diff --git a/client/src/App.tsx b/client/src/App.tsx index e2524ad..1907a66 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,7 +15,13 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx' import AppTourOverlay from './components/AppTourOverlay.tsx' import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx' import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx' -import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js' +import { + getActiveMasterKey, + logoutUser, + checkServerSession, + hasUnlockedLocalSession +} from './services/auth.js' +import AppErrorBoundary from './components/AppErrorBoundary.tsx' import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js' import { applyAppearanceToDocument, @@ -208,6 +214,52 @@ function App() { } }, []) + const clearAuthenticatedAppState = useCallback(() => { + setIsAuthenticated(false) + setActiveLogbookId(null) + setActiveLogbookTitle(null) + setShowUserProfile(false) + setTourSelectedEntryId(null) + setDemoHighlightEntryId(null) + }, []) + + /** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */ + const enforceUnlockedSession = useCallback(() => { + if (isViewerMode || isDemoMode || isAcceptingInvite) return + if (isAuthenticated && !hasUnlockedLocalSession()) { + clearAuthenticatedAppState() + } + }, [ + isAuthenticated, + isViewerMode, + isDemoMode, + isAcceptingInvite, + clearAuthenticatedAppState + ]) + + useEffect(() => { + enforceUnlockedSession() + }, [enforceUnlockedSession]) + + useEffect(() => { + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + enforceUnlockedSession() + } + } + const onVisibility = () => { + if (document.visibilityState === 'visible') { + enforceUnlockedSession() + } + } + window.addEventListener('pageshow', onPageShow) + document.addEventListener('visibilitychange', onVisibility) + return () => { + window.removeEventListener('pageshow', onPageShow) + document.removeEventListener('visibilitychange', onVisibility) + } + }, [enforceUnlockedSession]) + useEffect(() => { let cancelled = false @@ -220,9 +272,7 @@ function App() { localStorage.setItem('active_userid', session.userId) } - const savedUser = localStorage.getItem('active_username') - const key = getActiveMasterKey() - if (session.authenticated && savedUser && key) { + if (session.authenticated && hasUnlockedLocalSession()) { setIsAuthenticated(true) const savedLogbookId = localStorage.getItem('active_logbook_id') const savedLogbookTitle = localStorage.getItem('active_logbook_title') @@ -230,6 +280,8 @@ function App() { setActiveLogbookId(savedLogbookId) setActiveLogbookTitle(savedLogbookTitle) } + } else if (session.authenticated) { + clearAuthenticatedAppState() } } catch (err) { if (!cancelled) { @@ -241,7 +293,7 @@ function App() { return () => { cancelled = true } - }, []) + }, [clearAuthenticatedAppState]) useEffect(() => { syncRouteFromLocation() @@ -630,15 +682,17 @@ function App() { export default function AppWrapper() { return ( - - - - - - - - - - + + + + + + + + + + + + ) } diff --git a/client/src/components/AppErrorBoundary.tsx b/client/src/components/AppErrorBoundary.tsx new file mode 100644 index 0000000..c421811 --- /dev/null +++ b/client/src/components/AppErrorBoundary.tsx @@ -0,0 +1,42 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +interface State { + error: Error | null +} + +export default class AppErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('Unhandled app error:', error, info.componentStack) + } + + render() { + if (!this.state.error) { + return this.props.children + } + + return ( +
+
+

Kapteins Daagbok

+

+ Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden + oder die App vollständig beenden und erneut öffnen. +

+ +
+
+ ) + } +} diff --git a/client/src/hooks/usePwaUpdate.ts b/client/src/hooks/usePwaUpdate.ts index 47d5ac4..60cbb88 100644 --- a/client/src/hooks/usePwaUpdate.ts +++ b/client/src/hooks/usePwaUpdate.ts @@ -6,6 +6,8 @@ const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until' const UPDATE_SUPPRESS_MS = 30_000 const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000 const UPDATE_RELOAD_FALLBACK_MS = 2000 +/** Prevent Android PWA cold-start reload loops from onNeedReload. */ +const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done' function isUpdateSuppressed(): boolean { const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0') @@ -50,6 +52,11 @@ export function usePwaUpdate() { } = useRegisterSW({ immediate: !import.meta.env.DEV, onNeedReload() { + // First SW takeover requires one reload; guard against repeated reloads on Android PWA resume. + if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) { + return + } + sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1') clearUpdateSuppression() setNeedRefresh(false) window.location.reload() diff --git a/client/src/main.tsx b/client/src/main.tsx index 0b22ae9..a3b4d70 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client' import 'leaflet/dist/leaflet.css' import './themes.css' import './index.css' +import './i18n' +import App from './App.tsx' import { applyAppearanceToDocument } from './services/appearance.ts' -/** Stale PWA precache on localhost can shadow Vite dev modules and old locale bundles. */ +/** Stale PWA precache on localhost can shadow Vite dev modules. */ async function clearDevServiceWorkerCaches(): Promise { if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return const regs = await navigator.serviceWorker.getRegistrations() @@ -16,16 +18,40 @@ async function clearDevServiceWorkerCaches(): Promise { } } +function renderBootstrapError(message: string): void { + const root = document.getElementById('root') + if (!root) return + root.innerHTML = ` +
+ +
` +} + async function bootstrap(): Promise { - await clearDevServiceWorkerCaches() - await import('./i18n') - const { default: App } = await import('./App.tsx') applyAppearanceToDocument() - createRoot(document.getElementById('root')!).render( + await clearDevServiceWorkerCaches() + + const rootEl = document.getElementById('root') + if (!rootEl) { + throw new Error('Missing #root element') + } + + createRoot(rootEl).render( , ) } -void bootstrap() +void bootstrap().catch((err) => { + console.error('App bootstrap failed:', err) + renderBootstrapError( + 'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.', + ) +}) diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index f0711c6..778c064 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -33,13 +33,28 @@ export function setActiveMasterKey(key: ArrayBuffer | null) { } export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> { + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), 8_000) try { - return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`) + return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`, { + signal: controller.signal + }) } catch { return { authenticated: false } + } finally { + window.clearTimeout(timeoutId) } } +/** Master key is memory-only; after process kill the HTTP session may outlive local crypto state. */ +export function hasUnlockedLocalSession(): boolean { + return !!( + getActiveMasterKey() && + localStorage.getItem('active_username') && + localStorage.getItem('active_userid') + ) +} + export async function reauthWithPasskey(): Promise { const options = await apiJson(`${API_BASE}/reauth-options`, { method: 'POST'