fix(pwa): weiße Seite nach Android-Neustart ohne Master-Key vermeiden

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 11:30:37 +02:00
parent 04b822b263
commit 634eb622fd
6 changed files with 167 additions and 23 deletions
+1 -1
View File
@@ -36,7 +36,7 @@
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script> <script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title> <title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head> </head>
<body> <body style="margin:0;background:#0b0c10;color:#e2e8f0">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+59 -5
View File
@@ -15,7 +15,13 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx' import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx' import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.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 { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import { import {
applyAppearanceToDocument, 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -220,9 +272,7 @@ function App() {
localStorage.setItem('active_userid', session.userId) localStorage.setItem('active_userid', session.userId)
} }
const savedUser = localStorage.getItem('active_username') if (session.authenticated && hasUnlockedLocalSession()) {
const key = getActiveMasterKey()
if (session.authenticated && savedUser && key) {
setIsAuthenticated(true) setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id') const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title') const savedLogbookTitle = localStorage.getItem('active_logbook_title')
@@ -230,6 +280,8 @@ function App() {
setActiveLogbookId(savedLogbookId) setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle) setActiveLogbookTitle(savedLogbookTitle)
} }
} else if (session.authenticated) {
clearAuthenticatedAppState()
} }
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
@@ -241,7 +293,7 @@ function App() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [clearAuthenticatedAppState])
useEffect(() => { useEffect(() => {
syncRouteFromLocation() syncRouteFromLocation()
@@ -630,6 +682,7 @@ function App() {
export default function AppWrapper() { export default function AppWrapper() {
return ( return (
<AppErrorBoundary>
<DialogProvider> <DialogProvider>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppTourProvider> <AppTourProvider>
@@ -640,5 +693,6 @@ export default function AppWrapper() {
<AppFooter /> <AppFooter />
</UnsavedChangesProvider> </UnsavedChangesProvider>
</DialogProvider> </DialogProvider>
</AppErrorBoundary>
) )
} }
@@ -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<Props, State> {
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 (
<div className="auth-screen">
<div className="auth-card glass" role="alert">
<h2 style={{ marginTop: 0 }}>Kapteins Daagbok</h2>
<p style={{ color: 'var(--app-text-muted)', lineHeight: 1.5 }}>
Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden
oder die App vollständig beenden und erneut öffnen.
</p>
<button type="button" className="btn primary" style={{ width: '100%', marginTop: 16 }} onClick={() => window.location.reload()}>
Neu laden
</button>
</div>
</div>
)
}
}
+7
View File
@@ -6,6 +6,8 @@ const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000 const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000 const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000 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 { function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0') const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -50,6 +52,11 @@ export function usePwaUpdate() {
} = useRegisterSW({ } = useRegisterSW({
immediate: !import.meta.env.DEV, immediate: !import.meta.env.DEV,
onNeedReload() { 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() clearUpdateSuppression()
setNeedRefresh(false) setNeedRefresh(false)
window.location.reload() window.location.reload()
+32 -6
View File
@@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import './themes.css' import './themes.css'
import './index.css' import './index.css'
import './i18n'
import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts' 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<void> { async function clearDevServiceWorkerCaches(): Promise<void> {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
const regs = await navigator.serviceWorker.getRegistrations() const regs = await navigator.serviceWorker.getRegistrations()
@@ -16,16 +18,40 @@ async function clearDevServiceWorkerCaches(): Promise<void> {
} }
} }
function renderBootstrapError(message: string): void {
const root = document.getElementById('root')
if (!root) return
root.innerHTML = `
<div class="auth-screen">
<div class="auth-card glass" role="alert" style="max-width:420px">
<h2 style="margin-top:0">Kapteins Daagbok</h2>
<p style="color:var(--app-text-muted);line-height:1.5">${message}</p>
<button type="button" class="btn primary" style="width:100%;margin-top:16px" onclick="location.reload()">
Neu laden
</button>
</div>
</div>`
}
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
await clearDevServiceWorkerCaches()
await import('./i18n')
const { default: App } = await import('./App.tsx')
applyAppearanceToDocument() applyAppearanceToDocument()
createRoot(document.getElementById('root')!).render( await clearDevServiceWorkerCaches()
const rootEl = document.getElementById('root')
if (!rootEl) {
throw new Error('Missing #root element')
}
createRoot(rootEl).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) )
} }
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.',
)
})
+16 -1
View File
@@ -33,13 +33,28 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
} }
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> { export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
try { 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 { } catch {
return { authenticated: false } 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<boolean> { export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, { const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST' method: 'POST'