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:
+1
-1
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user